diff --git a/.claude/hooks/session-init.sh b/.claude/hooks/session-init.sh
new file mode 100755
index 000000000..3c767fc54
--- /dev/null
+++ b/.claude/hooks/session-init.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+set -e
+
+# Only run in remote/cloud environments
+if [ "$CLAUDE_CODE_REMOTE" != "true" ]; then
+ exit 0
+fi
+
+command -v gh &> /dev/null && exit 0
+
+LOCAL_BIN="$HOME/.local/bin"
+mkdir -p "$LOCAL_BIN"
+
+ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
+VERSION=$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
+TARBALL="gh_${VERSION#v}_linux_${ARCH}.tar.gz"
+
+echo "Installing gh ${VERSION}..."
+TEMP=$(mktemp -d)
+trap 'rm -rf "$TEMP"' EXIT
+curl -fsSL "https://github.com/cli/cli/releases/download/${VERSION}/${TARBALL}" | tar -xz -C "$TEMP"
+cp "$TEMP"/gh_*/bin/gh "$LOCAL_BIN/gh"
+chmod 755 "$LOCAL_BIN/gh"
+
+[ -n "$CLAUDE_ENV_FILE" ] && echo "export PATH=\"$LOCAL_BIN:\$PATH\"" >> "$CLAUDE_ENV_FILE"
+echo "gh installed: $("$LOCAL_BIN/gh" --version | head -1)"
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 000000000..afc82c2ea
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,15 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-init.sh",
+ "timeout": 120
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index 3d9a53394..267df6812 100644
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -17,6 +17,8 @@ body:
- π **Make sure you're testing on the latest version of FastMCP** - many issues are already fixed in newer versions
- π **Check if someone else has already reported this issue** or if it's been fixed on the main branch
- π **You MUST include a copy/pasteable and properly formatted MRE** (minimal reproducible example) below or your issue may be closed without response
+ - π‘ **The ideal issue is a clear problem description and an MRE β that's it.** If you've done a genuine investigation and have a non-obvious insight into the root cause, include it. But please don't speculate or ask an LLM to generate a diagnosis or proposed fix. We have LLMs too, and an incorrect analysis is harder to work with than none at all.
+ - βοΈ **Keep it short.** A one-paragraph description and a working MRE is the ideal bug report. Issues that are difficult to parse β due to length, speculation, or generated content β may be closed without response.
Thanks for helping to make FastMCP better! π
diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml
index 43b8b4de9..a803ec399 100644
--- a/.github/ISSUE_TEMPLATE/enhancement.yml
+++ b/.github/ISSUE_TEMPLATE/enhancement.yml
@@ -17,6 +17,7 @@ body:
- π **Check if this has already been requested** - search existing issues first
- π **Think about the broader impact** - how would this affect other users?
- π **Consider implementation complexity** - is this a small change or a major feature?
+ - βοΈ **Keep it short.** Describe the problem you're trying to solve and why existing behavior falls short. Skip proposed implementations unless you have a specific, well-considered suggestion β we don't need LLM-generated API designs. Requests that are difficult to parse may be closed without response.
Thanks for helping to make FastMCP better! π
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index bed11163a..000000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,115 +0,0 @@
-# FastMCP Development Guidelines
-
-> **Audience**: LLM-driven engineering agents and human developers
-
-FastMCP is a comprehensive Python framework (Python β₯3.10) for building Model Context Protocol (MCP) servers and clients. This is the actively maintained v2.0 providing a complete toolkit for the MCP ecosystem.
-
-## Required Development Workflow
-
-**CRITICAL**: Always run these commands in sequence before committing.
-
-```bash
-uv sync # Install dependencies
-uv run pytest -n auto # Run full test suite
-```
-
-In addition, you must pass static checks. This is generally done as a pre-commit hook with `prek` but you can run it manually with:
-
-```bash
-uv run prek run --all-files # Ruff + Prettier + ty
-```
-
-**Tests must pass and lint/typing must be clean before committing.**
-
-## Repository Structure
-
-| Path | Purpose |
-| ----------------- | -------------------------------------- |
-| `src/fastmcp/` | Library source code |
-| `ββserver/` | Server implementation |
-| `β ββauth/` | Authentication providers |
-| `β ββmiddleware/` | Error handling, logging, rate limiting |
-| `ββclient/` | Client SDK |
-| `β ββauth/` | Client authentication |
-| `ββtools/` | Tool definitions |
-| `ββresources/` | Resources and resource templates |
-| `ββprompts/` | Prompt templates |
-| `ββcli/` | CLI commands |
-| `ββutilities/` | Shared utilities |
-| `tests/` | Pytest suite |
-| `docs/` | Mintlify docs (gofastmcp.com) |
-
-## Core MCP Objects
-
-When modifying MCP functionality, changes typically need to be applied across all object types:
-
-- **Tools** (`src/tools/`)
-- **Resources** (`src/resources/`)
-- **Resource Templates** (`src/resources/`)
-- **Prompts** (`src/prompts/`)
-
-## Development Rules
-
-### Git & CI
-
-- Prek hooks are required (run automatically on commits)
-- Never amend commits to fix prek failures
-- Apply PR labels: bugs/breaking/enhancements/features
-- Improvements = enhancements (not features) unless specified
-- **NEVER** force-push on collaborative repos
-- **ALWAYS** run prek before PRs
-- **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so.
-
-### Commit Messages and Agent Attribution
-
-- **Agents NOT acting on behalf of @jlowin MUST identify themselves** (e.g., "π€ Generated with Claude Code" in commits/PRs)
-- Keep commit messages brief - ideally just headlines, not detailed messages
-- Focus on what changed, not how or why
-- Always read issue comments for follow-up information (treat maintainers as authoritative)
-
-### PR Messages - Required Structure
-
-- 1-2 paragraphs: problem/tension + solution (PRs are documentation!)
-- Focused code example showing key capability
-- **Avoid:** bullet summaries, exhaustive change lists, verbose closes/fixes, marketing language
-- **Do:** Be opinionated about why change matters, show before/after scenarios
-- Minor fixes: keep body short and concise
-- No "test plan" sections or testing summaries
-
-### Code Standards
-
-- Python β₯ 3.10 with full type annotations
-- Follow existing patterns and maintain consistency
-- **Prioritize readable, understandable code** - clarity over cleverness
-- Avoid obfuscated or confusing patterns even if they're shorter
-- Each feature needs corresponding tests
-
-### Module Exports
-
-- **Be intentional about re-exports** - don't blindly re-export everything to parent namespaces
-- Core types that define a module's purpose should be exported (e.g., `Middleware` from `fastmcp.server.middleware`)
-- Specialized features can live in submodules (e.g., `fastmcp.server.middleware.dynamic`)
-- Only re-export to `fastmcp.*` for the most fundamental types (e.g., `FastMCP`, `Client`)
-- When in doubt, prefer users importing from the specific submodule over re-exporting
-
-### Documentation
-
-- Uses Mintlify framework
-- Files must be in docs.json to be included
-- Do not manually modify `docs/python-sdk/**` β these files are auto-generated from source code by a bot and maintained via a long-lived PR. Do not include changes to these files in contributor PRs.
-- Do not manually modify `docs/public/schemas/**` or `src/fastmcp/utilities/mcp_server_config/v1/schema.json` β these are auto-generated and maintained via a long-lived PR.
-- **Core Principle:** A feature doesn't exist unless it is documented!
-
-### Documentation Guidelines
-
-- **Code Examples:** Explain before showing code, make blocks fully runnable (include imports)
-- **Structure:** Headers form navigation guide, logical H2/H3 hierarchy
-- **Content:** User-focused sections, motivate features (why) before mechanics (how)
-- **Style:** Prose over code comments for important information
-
-## Critical Patterns
-
-- Never use bare `except` - be specific with exception types
-- File sizes enforced by [loq](https://github.com/jlowin/loq). Edit `loq.toml` to raise limits; `loq baseline` to ratchet down.
-- Always `uv sync` first when debugging build issues
-- Default test timeout is 5s - optimize or mark as integration tests
diff --git a/AGENTS.md b/AGENTS.md
new file mode 120000
index 000000000..681311eb9
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+CLAUDE.md
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 120000
index 47dc3e3d8..000000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-AGENTS.md
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..9b9ca8214
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,119 @@
+# FastMCP Development Guidelines
+
+> **Audience**: LLM-driven engineering agents and human developers
+
+> **Note**: `AGENTS.md` is a symlink to this file. Edit `CLAUDE.md` directly.
+
+FastMCP is a comprehensive Python framework (Python β₯3.10) for building Model Context Protocol (MCP) servers and clients. This is the actively maintained v2.0 providing a complete toolkit for the MCP ecosystem.
+
+## Required Development Workflow
+
+**CRITICAL**: Always run these commands in sequence before committing.
+
+```bash
+uv sync # Install dependencies
+uv run pytest -n auto # Run full test suite
+```
+
+In addition, you must pass static checks. This is generally done as a pre-commit hook with `prek` but you can run it manually with:
+
+```bash
+uv run prek run --all-files # Ruff + Prettier + ty
+```
+
+**Tests must pass and lint/typing must be clean before committing.**
+
+## Repository Structure
+
+| Path | Purpose |
+| ----------------- | -------------------------------------- |
+| `src/fastmcp/` | Library source code |
+| `ββserver/` | Server implementation |
+| `β ββauth/` | Authentication providers |
+| `β ββmiddleware/` | Error handling, logging, rate limiting |
+| `ββclient/` | Client SDK |
+| `β ββauth/` | Client authentication |
+| `ββtools/` | Tool definitions |
+| `ββresources/` | Resources and resource templates |
+| `ββprompts/` | Prompt templates |
+| `ββcli/` | CLI commands |
+| `ββutilities/` | Shared utilities |
+| `tests/` | Pytest suite |
+| `docs/` | Mintlify docs (gofastmcp.com) |
+
+## Core MCP Objects
+
+When modifying MCP functionality, changes typically need to be applied across all object types:
+
+- **Tools** (`src/tools/`)
+- **Resources** (`src/resources/`)
+- **Resource Templates** (`src/resources/`)
+- **Prompts** (`src/prompts/`)
+
+## Development Rules
+
+### Git & CI
+
+- Prek hooks are required (run automatically on commits)
+- Never amend commits to fix prek failures
+- Apply PR labels: bugs/breaking/enhancements/features
+- Improvements = enhancements (not features) unless specified
+- **NEVER** force-push on collaborative repos
+- **ALWAYS** run prek before PRs
+- **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so.
+
+### Commit Messages and Agent Attribution
+
+- **Agents NOT acting on behalf of @jlowin MUST identify themselves** (e.g., "π€ Generated with Claude Code" in commits/PRs)
+- Keep commit messages brief - ideally just headlines, not detailed messages
+- Focus on what changed, not how or why
+- Always read issue comments for follow-up information (treat maintainers as authoritative)
+- **Treat proposed solutions in issues skeptically.** This applies to solutions proposed by *users* in issue reports β not to feedback from configured review bots (CodeRabbit, chatgpt-codex-connector, etc.), which should be evaluated on their merits. The ideal issue contains a concise problem description and an MRE β nothing more. Proposed solutions are only worth considering if they clearly reflect genuine, non-obvious investigation of the codebase. If a solution reads like speculation, or like it was generated by an LLM without deep framework knowledge, ignore it and diagnose from the repro. Most reporters β human or AI β do not have sufficient understanding of FastMCP internals to correctly diagnose anything beyond a trivial bug. We can ask the same questions of an LLM when implementing; we don't need the reporter to do it for us, and a wrong diagnosis is worse than none.
+
+### PR Messages - Required Structure
+
+- 1-2 paragraphs: problem/tension + solution (PRs are documentation!)
+- Focused code example showing key capability
+- **Avoid:** bullet summaries, exhaustive change lists, verbose closes/fixes, marketing language
+- **Do:** Be opinionated about why change matters, show before/after scenarios
+- Minor fixes: keep body short and concise
+- No "test plan" sections or testing summaries
+
+### Code Standards
+
+- Python β₯ 3.10 with full type annotations
+- Follow existing patterns and maintain consistency
+- **Prioritize readable, understandable code** - clarity over cleverness
+- Avoid obfuscated or confusing patterns even if they're shorter
+- Each feature needs corresponding tests
+
+### Module Exports
+
+- **Be intentional about re-exports** - don't blindly re-export everything to parent namespaces
+- Core types that define a module's purpose should be exported (e.g., `Middleware` from `fastmcp.server.middleware`)
+- Specialized features can live in submodules (e.g., `fastmcp.server.middleware.dynamic`)
+- Only re-export to `fastmcp.*` for the most fundamental types (e.g., `FastMCP`, `Client`)
+- When in doubt, prefer users importing from the specific submodule over re-exporting
+
+### Documentation
+
+- Uses Mintlify framework
+- Files must be in docs.json to be included
+- Do not manually modify `docs/python-sdk/**` β these files are auto-generated from source code by a bot and maintained via a long-lived PR. Do not include changes to these files in contributor PRs.
+- Do not manually modify `docs/public/schemas/**` or `src/fastmcp/utilities/mcp_server_config/v1/schema.json` β these are auto-generated and maintained via a long-lived PR.
+- **Core Principle:** A feature doesn't exist unless it is documented!
+
+### Documentation Guidelines
+
+- **Code Examples:** Explain before showing code, make blocks fully runnable (include imports)
+- **Code Formatting:** Keep code blocks visually clean β avoid deeply nested function calls. Extract intermediate values into named variables rather than inlining everything into one expression. Code in docs is read more than it's run; optimize for scannability.
+- **Structure:** Headers form navigation guide, logical H2/H3 hierarchy
+- **Content:** User-focused sections, motivate features (why) before mechanics (how)
+- **Style:** Prose over code comments for important information
+
+## Critical Patterns
+
+- Never use bare `except` - be specific with exception types
+- File sizes enforced by [loq](https://github.com/jakekaplan/loq). Edit `loq.toml` to raise limits; `loq baseline` to ratchet down.
+- Always `uv sync` first when debugging build issues
+- Default test timeout is 5s - optimize or mark as integration tests
diff --git a/docs/apps/low-level.mdx b/docs/apps/low-level.mdx
index a908dff4b..944e48498 100644
--- a/docs/apps/low-level.mdx
+++ b/docs/apps/low-level.mdx
@@ -1,7 +1,7 @@
---
-title: Low-Level API
-sidebarTitle: Low-Level API
-description: Integrate directly with the MCP Apps extension to build interactive tool UIs.
+title: Custom HTML Apps
+sidebarTitle: Custom HTML
+description: Build apps with your own HTML, CSS, and JavaScript using the MCP Apps extension directly.
icon: code
tag: NEW
---
@@ -10,9 +10,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
-The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) (`io.modelcontextprotocol/ui`) lets tools return interactive UIs β an HTML page rendered in a sandboxed iframe inside the host client. Instead of returning plain text or JSON, a tool can show a chart, a form, an image viewer, or anything you can build with HTML and JavaScript.
+The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) is an open protocol that lets tools return interactive UIs β an HTML page rendered in a sandboxed iframe inside the host client. [Prefab UI](/apps/prefab) builds on this protocol so you never have to think about it, but when you need full control β custom rendering, a specific JavaScript framework, maps, 3D, video β you can use the MCP Apps extension directly.
-This page covers the low-level extension API directly. FastMCP provides typed models for app configuration, automatic `ui://` resource handling, and CSP/permission management.
+This page covers how to write custom HTML apps and wire them up in FastMCP. You'll be working with the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resource and CSP management.
## How It Works
diff --git a/docs/apps/overview.mdx b/docs/apps/overview.mdx
index 07b1d8bc0..d8dcfc3fd 100644
--- a/docs/apps/overview.mdx
+++ b/docs/apps/overview.mdx
@@ -10,22 +10,67 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
-MCP Apps let your tools return interactive UIs β rendered in a sandboxed iframe right inside the host client's conversation. Instead of returning plain text or JSON, a tool can show a chart, a form, an image viewer, or anything you can build with HTML and JavaScript.
+MCP Apps let your tools return interactive UIs β rendered in a sandboxed iframe right inside the host client's conversation. Instead of returning plain text, a tool can show a chart, a sortable table, a form, or anything you can build with HTML.
-FastMCP implements the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps), so you can start building apps today. FastMCP 3.1 will introduce a full Python-native app framework that makes building rich UIs dramatically simpler β no HTML or JavaScript required.
+FastMCP implements the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) and provides two approaches:
-## What's Available Today
+## Prefab Apps (Recommended)
-FastMCP provides typed models and helpers for working with the MCP Apps extension directly:
+
-- **`AppConfig`** to link tools to UI resources and control visibility
-- **`ui://` resources** that automatically serve HTML with the correct MIME type
-- **`ResourceCSP`** and **`ResourcePermissions`** for security and sandboxing
+
+[Prefab](https://prefab.prefect.io) is in extremely early, active development β its API changes frequently and breaking changes can occur with any release. The FastMCP integration is equally new and under rapid development. These docs are included for users who want to work on the cutting edge; production use is not recommended. Always [pin `prefab-ui` to a specific version](/apps/prefab#getting-started) in your dependencies.
+
-This is the [low-level API](/apps/low-level) β you write the HTML yourself and wire up communication with the host via the `@modelcontextprotocol/ext-apps` JavaScript SDK. It gives you full control over the UI.
+[Prefab UI](https://prefab.prefect.io) is a declarative UI framework for Python. You describe layouts, charts, tables, forms, and interactive behaviors using a Python DSL β and the framework compiles them to a JSON protocol that a shared renderer interprets. It started as a component library inside FastMCP and grew into its own framework with [comprehensive documentation](https://prefab.prefect.io).
-## What's Coming in 3.1
+```python
+from prefab_ui.components import Column, Heading, BarChart, ChartSeries
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
-FastMCP 3.1 will ship a Python-native app framework that lets you build interactive UIs entirely in Python. Define layouts, handle events, and manage state without writing any HTML or JavaScript β FastMCP generates the app for you.
+mcp = FastMCP("Dashboard")
-Stay tuned. In the meantime, the [low-level API](/apps/low-level) is ready to use.
+@mcp.tool(app=True)
+def sales_chart(year: int) -> PrefabApp:
+ """Show sales data as an interactive chart."""
+ data = get_sales_data(year)
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(f"{year} Sales")
+ BarChart(
+ data=data,
+ series=[ChartSeries(data_key="revenue", label="Revenue")],
+ x_axis="month",
+ )
+
+ return PrefabApp(view=view)
+```
+
+Install with `pip install "fastmcp[apps]"` and see [Prefab Apps](/apps/prefab) for the integration guide.
+
+## Custom HTML Apps
+
+The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) is an open protocol, and you can use it directly when you need full control. You write your own HTML/CSS/JavaScript and communicate with the host via the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) SDK.
+
+This is the right choice for custom rendering (maps, 3D, video), specific JavaScript frameworks, or capabilities beyond what the component library offers.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.apps import AppConfig, ResourceCSP
+
+mcp = FastMCP("Custom App")
+
+@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html"))
+def my_tool() -> str:
+ return '{"values": [1, 2, 3]}'
+
+@mcp.resource(
+ "ui://my-app/view.html",
+ app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])),
+)
+def view() -> str:
+ return "..."
+```
+
+See [Custom HTML Apps](/apps/low-level) for the full reference.
diff --git a/docs/apps/patterns.mdx b/docs/apps/patterns.mdx
new file mode 100644
index 000000000..ffc699f89
--- /dev/null
+++ b/docs/apps/patterns.mdx
@@ -0,0 +1,483 @@
+---
+title: Patterns
+sidebarTitle: Patterns
+description: Charts, tables, forms, and other common tool UIs.
+icon: grid-2-plus
+tag: SOON
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+
+[Prefab](https://prefab.prefect.io) is in extremely early, active development β its API changes frequently and breaking changes can occur with any release. The FastMCP integration is equally new and under rapid development. These docs are included for users who want to work on the cutting edge; production use is not recommended. Always pin `prefab-ui` to a specific version in your dependencies.
+
+
+The most common use of Prefab is giving your tools a visual representation β a chart instead of raw numbers, a sortable table instead of a text dump, a status dashboard instead of a list of booleans. Each pattern below is a complete, copy-pasteable tool.
+
+## Charts
+
+Prefab includes [bar, line, area, pie, radar, and radial charts](https://prefab.prefect.io/docs/components/charts). They all render client-side with tooltips, legends, and responsive sizing.
+
+### Bar Chart
+
+```python
+from prefab_ui.components import Column, Heading, BarChart, ChartSeries
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Charts")
+
+
+@mcp.tool(app=True)
+def quarterly_revenue(year: int) -> PrefabApp:
+ """Show quarterly revenue as a bar chart."""
+ data = [
+ {"quarter": "Q1", "revenue": 42000, "costs": 28000},
+ {"quarter": "Q2", "revenue": 51000, "costs": 31000},
+ {"quarter": "Q3", "revenue": 47000, "costs": 29000},
+ {"quarter": "Q4", "revenue": 63000, "costs": 35000},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(f"{year} Revenue vs Costs")
+ BarChart(
+ data=data,
+ series=[
+ ChartSeries(data_key="revenue", label="Revenue"),
+ ChartSeries(data_key="costs", label="Costs"),
+ ],
+ x_axis="quarter",
+ show_legend=True,
+ )
+
+ return PrefabApp(view=view)
+```
+
+Multiple `ChartSeries` entries plot different data keys. Add `stacked=True` to stack bars, or `horizontal=True` to flip the axes.
+
+### Area Chart
+
+`LineChart` and `AreaChart` share the same API as `BarChart`, with `curve` for interpolation (`"linear"`, `"smooth"`, `"step"`) and `show_dots` for data points:
+
+```python
+from prefab_ui.components import Column, Heading, AreaChart, ChartSeries
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Charts")
+
+
+@mcp.tool(app=True)
+def usage_trend() -> PrefabApp:
+ """Show API usage over time."""
+ data = [
+ {"date": "Feb 1", "requests": 1200},
+ {"date": "Feb 2", "requests": 1350},
+ {"date": "Feb 3", "requests": 980},
+ {"date": "Feb 4", "requests": 1500},
+ {"date": "Feb 5", "requests": 1420},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("API Usage")
+ AreaChart(
+ data=data,
+ series=[ChartSeries(data_key="requests", label="Requests")],
+ x_axis="date",
+ curve="smooth",
+ height=250,
+ )
+
+ return PrefabApp(view=view)
+```
+
+### Pie and Donut Charts
+
+`PieChart` uses `data_key` (the numeric value) and `name_key` (the label) instead of series. Set `inner_radius` for a donut:
+
+```python
+from prefab_ui.components import Column, Heading, PieChart
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Charts")
+
+
+@mcp.tool(app=True)
+def ticket_breakdown() -> PrefabApp:
+ """Show open tickets by category."""
+ data = [
+ {"category": "Bug", "count": 23},
+ {"category": "Feature", "count": 15},
+ {"category": "Docs", "count": 8},
+ {"category": "Infra", "count": 12},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Open Tickets")
+ PieChart(
+ data=data,
+ data_key="count",
+ name_key="category",
+ show_legend=True,
+ inner_radius=60,
+ )
+
+ return PrefabApp(view=view)
+```
+
+## Data Tables
+
+[DataTable](https://prefab.prefect.io/docs/components/data-display/data-table) provides sortable columns, full-text search, and pagination β all running client-side in the browser.
+
+```python
+from prefab_ui.components import Column, Heading, DataTable, DataTableColumn
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Directory")
+
+
+@mcp.tool(app=True)
+def employee_directory() -> PrefabApp:
+ """Show a searchable, sortable employee directory."""
+ employees = [
+ {"name": "Alice Chen", "department": "Engineering", "role": "Staff Engineer", "location": "SF"},
+ {"name": "Bob Martinez", "department": "Design", "role": "Lead Designer", "location": "NYC"},
+ {"name": "Carol Johnson", "department": "Engineering", "role": "Senior Engineer", "location": "London"},
+ {"name": "David Kim", "department": "Product", "role": "Product Manager", "location": "SF"},
+ {"name": "Eva MΓΌller", "department": "Engineering", "role": "Engineer", "location": "Berlin"},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Employee Directory")
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="department", header="Department", sortable=True),
+ DataTableColumn(key="role", header="Role"),
+ DataTableColumn(key="location", header="Office", sortable=True),
+ ],
+ rows=employees,
+ searchable=True,
+ paginated=True,
+ page_size=15,
+ )
+
+ return PrefabApp(view=view)
+```
+
+## Forms
+
+A form collects input, but it needs somewhere to send that input. The [`CallTool`](https://prefab.prefect.io/docs/concepts/actions) action connects a form to a tool on your MCP server β so you need two tools: one that renders the form, and one that handles the submission.
+
+```python
+from prefab_ui.components import (
+ Column, Heading, Row, Muted, Badge, Input, Select,
+ Textarea, Button, Form, ForEach, Separator,
+)
+from prefab_ui.actions import ShowToast
+from prefab_ui.actions.mcp import CallTool
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Contacts")
+
+contacts_db: list[dict] = [
+ {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Partner"},
+]
+
+
+@mcp.tool(app=True)
+def contact_form() -> PrefabApp:
+ """Show a contact list with a form to add new contacts."""
+ with Column(gap=6, css_class="p-6") as view:
+ Heading("Contacts")
+
+ with ForEach("contacts"):
+ with Row(gap=2, align="center"):
+ Muted("{{ name }}")
+ Muted("{{ email }}")
+ Badge("{{ category }}")
+
+ Separator()
+
+ with Form(
+ on_submit=CallTool(
+ "save_contact",
+ result_key="contacts",
+ on_success=ShowToast("Contact saved!", variant="success"),
+ on_error=ShowToast("{{ $error }}", variant="error"),
+ )
+ ):
+ Input(name="name", label="Full Name", required=True)
+ Input(name="email", label="Email", input_type="email", required=True)
+ Select(
+ name="category",
+ label="Category",
+ options=["Customer", "Vendor", "Partner", "Other"],
+ )
+ Textarea(name="notes", label="Notes", placeholder="Optional notes...")
+ Button("Save Contact")
+
+ return PrefabApp(view=view, state={"contacts": list(contacts_db)})
+
+
+@mcp.tool
+def save_contact(
+ name: str,
+ email: str,
+ category: str = "Other",
+ notes: str = "",
+) -> list[dict]:
+ """Save a new contact and return the updated list."""
+ contacts_db.append({"name": name, "email": email, "category": category, "notes": notes})
+ return list(contacts_db)
+```
+
+When the user submits the form, the renderer calls `save_contact` on the server with all named input values as arguments. Because `result_key="contacts"` is set, the returned list replaces the `contacts` state β and the `ForEach` re-renders with the new data automatically.
+
+The `save_contact` tool is a regular MCP tool. The LLM can also call it directly in conversation. Your UI actions and your conversational tools are the same thing.
+
+### Pydantic Model Forms
+
+For complex forms, `Form.from_model()` generates the entire form from a Pydantic model β inputs, labels, validation, and submit wiring:
+
+```python
+from typing import Literal
+
+from pydantic import BaseModel, Field
+from prefab_ui.components import Column, Heading, Form
+from prefab_ui.actions.mcp import CallTool
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Bug Tracker")
+
+
+class BugReport(BaseModel):
+ title: str = Field(title="Bug Title")
+ severity: Literal["low", "medium", "high", "critical"] = Field(
+ title="Severity", default="medium"
+ )
+ description: str = Field(title="Description")
+ steps_to_reproduce: str = Field(title="Steps to Reproduce")
+
+
+@mcp.tool(app=True)
+def report_bug() -> PrefabApp:
+ """Show a bug report form."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Report a Bug")
+ Form.from_model(BugReport, on_submit=CallTool("create_bug_report"))
+
+ return PrefabApp(view=view)
+
+
+@mcp.tool
+def create_bug_report(data: dict) -> str:
+ """Create a bug report from the form submission."""
+ report = BugReport(**data)
+ # save to database...
+ return f"Created bug report: {report.title}"
+```
+
+`str` fields become text inputs, `Literal` becomes a select, `bool` becomes a checkbox. The `on_submit` CallTool receives all field values under a `data` key.
+
+## Status Displays
+
+Cards, badges, progress bars, and grids combine naturally for dashboards. See the [Prefab layout](https://prefab.prefect.io/docs/concepts/composition) and [container](https://prefab.prefect.io/docs/components/containers) docs for the full set of layout and display components.
+
+```python
+from prefab_ui.components import (
+ Column, Row, Grid, Heading, Text, Muted, Badge,
+ Card, CardContent, Progress, Separator,
+)
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Monitoring")
+
+
+@mcp.tool(app=True)
+def system_status() -> PrefabApp:
+ """Show current system health."""
+ services = [
+ {"name": "API Gateway", "status": "healthy", "ok": True, "latency_ms": 12, "uptime_pct": 99.9},
+ {"name": "Database", "status": "healthy", "ok": True, "latency_ms": 3, "uptime_pct": 99.99},
+ {"name": "Cache", "status": "degraded", "ok": False, "latency_ms": 45, "uptime_pct": 98.2},
+ {"name": "Queue", "status": "healthy", "ok": True, "latency_ms": 8, "uptime_pct": 99.8},
+ ]
+ all_ok = all(s["ok"] for s in services)
+
+ with Column(gap=4, css_class="p-6") as view:
+ with Row(gap=2, align="center"):
+ Heading("System Status")
+ Badge(
+ "All Healthy" if all_ok else "Degraded",
+ variant="success" if all_ok else "destructive",
+ )
+
+ Separator()
+
+ with Grid(columns=2, gap=4):
+ for svc in services:
+ with Card():
+ with CardContent():
+ with Row(gap=2, align="center"):
+ Text(svc["name"], css_class="font-medium")
+ Badge(
+ svc["status"],
+ variant="success" if svc["ok"] else "destructive",
+ )
+ Muted(f"Response: {svc['latency_ms']}ms")
+ Progress(value=svc["uptime_pct"])
+
+ return PrefabApp(view=view)
+```
+
+## Conditional Content
+
+[`If`, `Elif`, and `Else`](https://prefab.prefect.io/docs/concepts/composition#conditional-rendering) show or hide content based on state. Changes are instant β no server round-trip.
+
+```python
+from prefab_ui.components import Column, Heading, Switch, Separator, Alert, If
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Flags")
+
+
+@mcp.tool(app=True)
+def feature_flags() -> PrefabApp:
+ """Toggle feature flags with live preview."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Feature Flags")
+
+ Switch(name="dark_mode", label="Dark Mode")
+ Switch(name="beta_features", label="Beta Features")
+
+ Separator()
+
+ with If("{{ dark_mode }}"):
+ Alert(title="Dark mode enabled", description="UI will use dark theme.")
+ with If("{{ beta_features }}"):
+ Alert(
+ title="Beta features active",
+ description="Experimental features are now visible.",
+ variant="warning",
+ )
+
+ return PrefabApp(view=view, state={"dark_mode": False, "beta_features": False})
+```
+
+## Tabs
+
+[Tabs](https://prefab.prefect.io/docs/components/containers/tabs) organize content into switchable views. Switching is client-side β no server round-trip.
+
+```python
+from prefab_ui.components import (
+ Column, Heading, Text, Muted, Badge, Row,
+ DataTable, DataTableColumn, Tabs, Tab, ForEach,
+)
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Projects")
+
+
+@mcp.tool(app=True)
+def project_overview(project_id: str) -> PrefabApp:
+ """Show project details organized in tabs."""
+ project = {
+ "name": "FastMCP v3",
+ "description": "Next generation MCP framework with Apps support.",
+ "status": "Active",
+ "created_at": "2025-01-15",
+ "members": [
+ {"name": "Alice Chen", "role": "Lead"},
+ {"name": "Bob Martinez", "role": "Design"},
+ ],
+ "activity": [
+ {"timestamp": "2 hours ago", "message": "Merged PR #342"},
+ {"timestamp": "1 day ago", "message": "Released v3.0.1"},
+ ],
+ }
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(project["name"])
+
+ with Tabs():
+ with Tab("Overview"):
+ Text(project["description"])
+ with Row(gap=4):
+ Badge(project["status"])
+ Muted(f"Created: {project['created_at']}")
+
+ with Tab("Members"):
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Role"),
+ ],
+ rows=project["members"],
+ )
+
+ with Tab("Activity"):
+ with ForEach("activity"):
+ with Row(gap=2):
+ Muted("{{ timestamp }}")
+ Text("{{ message }}")
+
+ return PrefabApp(view=view, state={"activity": project["activity"]})
+```
+
+## Accordion
+
+[Accordion](https://prefab.prefect.io/docs/components/containers/accordion) collapses sections to save space. `multiple=True` lets users expand several items at once:
+
+```python
+from prefab_ui.components import (
+ Column, Heading, Row, Text, Badge, Progress,
+ Accordion, AccordionItem,
+)
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("API Monitor")
+
+
+@mcp.tool(app=True)
+def api_health() -> PrefabApp:
+ """Show health details for each API endpoint."""
+ endpoints = [
+ {"path": "/api/users", "status": 200, "healthy": True, "avg_ms": 45, "p99_ms": 120, "uptime_pct": 99.9},
+ {"path": "/api/orders", "status": 200, "healthy": True, "avg_ms": 82, "p99_ms": 250, "uptime_pct": 99.7},
+ {"path": "/api/search", "status": 200, "healthy": True, "avg_ms": 150, "p99_ms": 500, "uptime_pct": 99.5},
+ {"path": "/api/webhooks", "status": 503, "healthy": False, "avg_ms": 2000, "p99_ms": 5000, "uptime_pct": 95.1},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("API Health")
+
+ with Accordion(multiple=True):
+ for ep in endpoints:
+ with AccordionItem(ep["path"]):
+ with Row(gap=4):
+ Badge(
+ f"{ep['status']}",
+ variant="success" if ep["healthy"] else "destructive",
+ )
+ Text(f"Avg: {ep['avg_ms']}ms")
+ Text(f"P99: {ep['p99_ms']}ms")
+ Progress(value=ep["uptime_pct"])
+
+ return PrefabApp(view=view)
+```
+
+## Next Steps
+
+- **[Custom HTML Apps](/apps/low-level)** β When you need your own HTML, CSS, and JavaScript
+- **[Prefab UI Docs](https://prefab.prefect.io)** β Components, state, expressions, and actions
diff --git a/docs/apps/prefab.mdx b/docs/apps/prefab.mdx
new file mode 100644
index 000000000..907670d4b
--- /dev/null
+++ b/docs/apps/prefab.mdx
@@ -0,0 +1,195 @@
+---
+title: Prefab Apps
+sidebarTitle: Prefab Apps
+description: Build interactive tool UIs in pure Python β no HTML or JavaScript required.
+icon: palette
+tag: SOON
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+
+[Prefab](https://prefab.prefect.io) is in extremely early, active development β its API changes frequently and breaking changes can occur with any release. The FastMCP integration is equally new and under rapid development. These docs are included for users who want to work on the cutting edge; production use is not recommended. Always pin `prefab-ui` to a specific version in your dependencies (see below).
+
+
+[Prefab UI](https://prefab.prefect.io) is a declarative UI framework for Python. You describe what your interface should look like β a chart, a table, a form β and return it from your tool. FastMCP takes care of everything else: registering the renderer, wiring the protocol metadata, and delivering the component tree to the host.
+
+Prefab started as a component library inside FastMCP and grew into a full framework for building interactive applications β with its own state management, reactive expression system, and action model. The [Prefab documentation](https://prefab.prefect.io) covers all of this in depth. This page focuses on the FastMCP integration: what you return from a tool, and what FastMCP does with it.
+
+```bash
+pip install "fastmcp[apps]"
+```
+
+
+Prefab UI is in active early development and its API changes frequently. We strongly recommend pinning `prefab-ui` to a specific version in your project's dependencies. Installing `fastmcp[apps]` pulls in `prefab-ui` but won't pin it β so a routine `pip install --upgrade` could introduce breaking changes.
+
+```toml
+# pyproject.toml
+dependencies = [
+ "fastmcp[apps]",
+ "prefab-ui==0.8.0", # pin to a known working version
+]
+```
+
+
+Here's the simplest possible Prefab App β a tool that returns a bar chart:
+
+```python
+from prefab_ui.components import Column, Heading, BarChart, ChartSeries
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Dashboard")
+
+
+@mcp.tool(app=True)
+def revenue_chart(year: int) -> PrefabApp:
+ """Show annual revenue as an interactive bar chart."""
+ data = [
+ {"quarter": "Q1", "revenue": 42000},
+ {"quarter": "Q2", "revenue": 51000},
+ {"quarter": "Q3", "revenue": 47000},
+ {"quarter": "Q4", "revenue": 63000},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(f"{year} Revenue")
+ BarChart(
+ data=data,
+ series=[ChartSeries(data_key="revenue", label="Revenue")],
+ x_axis="quarter",
+ )
+
+ return PrefabApp(view=view)
+```
+
+That's it β you declare a layout using Python's `with` statement, and return it. When the host calls this tool, the user sees an interactive bar chart instead of a JSON blob. The [Patterns](/apps/patterns) page has more examples: area charts, data tables, forms, status dashboards, and more.
+
+## What You Return
+
+### Components
+
+The simplest way to get started. If you're returning a visual representation of data and don't need Prefab's more advanced features like initial state or stylesheets, just return the components directly. FastMCP wraps them in a `PrefabApp` automatically:
+
+```python
+from prefab_ui.components import Column, Heading, Badge
+from fastmcp import FastMCP
+
+mcp = FastMCP("Status")
+
+
+@mcp.tool(app=True)
+def status_badge() -> Column:
+ """Show system status."""
+ with Column(gap=2) as view:
+ Heading("All Systems Operational")
+ Badge("Healthy", variant="success")
+ return view
+```
+
+Want a chart? Return a chart. Want a table? Return a table. FastMCP handles the wiring.
+
+### PrefabApp
+
+When you need more control β setting initial state values that components can read and react to, or configuring the rendering engine β return a `PrefabApp` explicitly:
+
+```python
+from prefab_ui.components import Column, Heading, Text, Button, If, Badge
+from prefab_ui.actions import ToggleState
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Demo")
+
+
+@mcp.tool(app=True)
+def toggle_demo() -> PrefabApp:
+ """Interactive toggle with state."""
+ with Column(gap=4, css_class="p-6") as view:
+ Button("Toggle", on_click=ToggleState("show"))
+ with If("{{ show }}"):
+ Badge("Visible!", variant="success")
+
+ return PrefabApp(view=view, state={"show": False})
+```
+
+The `state` dict provides the initial values. Components reference state with `{{ expression }}` templates. State mutations like `ToggleState` happen entirely in the browser β no server round-trip. The [Prefab state guide](https://prefab.prefect.io/docs/concepts/state) covers this in detail.
+
+### ToolResult
+
+Every tool result has two audiences: the renderer (which displays the UI) and the LLM (which reads the text content to understand what happened). By default, Prefab Apps send `"[Rendered Prefab UI]"` as the text content, which tells the LLM almost nothing.
+
+If you want the LLM to understand the result β so it can reference the data in conversation, summarize it, or decide what to do next β wrap your return in a `ToolResult` with a meaningful `content` string:
+
+```python
+from prefab_ui.components import Column, Heading, BarChart, ChartSeries
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+from fastmcp.tools import ToolResult
+
+mcp = FastMCP("Sales")
+
+
+@mcp.tool(app=True)
+def sales_overview(year: int) -> ToolResult:
+ """Show sales data visually and summarize for the model."""
+ data = get_sales_data(year)
+ total = sum(row["revenue"] for row in data)
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Sales Overview")
+ BarChart(data=data, series=[ChartSeries(data_key="revenue")])
+
+ return ToolResult(
+ content=f"Total revenue for {year}: ${total:,} across {len(data)} quarters",
+ structured_content=view,
+ )
+```
+
+The user sees the chart. The LLM sees `"Total revenue for 2025: $203,000 across 4 quarters"` and can reason about it.
+
+## Type Inference
+
+If your tool's return type annotation is a Prefab type β `PrefabApp`, `Component`, or their `Optional` variants β FastMCP detects this and enables app rendering automatically:
+
+```python
+@mcp.tool
+def greet(name: str) -> PrefabApp:
+ return PrefabApp(view=Heading(f"Hello, {name}!"))
+```
+
+This is equivalent to `@mcp.tool(app=True)`. Explicit `app=True` is recommended for clarity, and is required when the return type doesn't reveal a Prefab type (e.g., `-> ToolResult`).
+
+## How It Works
+
+Behind the scenes, when a tool returns a Prefab component or `PrefabApp`, FastMCP:
+
+1. **Registers a shared renderer** β a `ui://prefab/renderer.html` resource containing the JavaScript rendering engine, fetched once by the host and reused across all your Prefab tools.
+2. **Wires the tool metadata** β so the host knows to load the renderer iframe when displaying the tool result.
+3. **Serializes the component tree** β your Python components become `structuredContent` on the tool result, which the renderer interprets and displays.
+
+None of this requires any configuration. The `app=True` flag (or type inference) is the only thing you need.
+
+## Mixing with Custom HTML Apps
+
+Prefab tools and [custom HTML tools](/apps/low-level) coexist in the same server. Prefab tools share a single renderer resource; custom tools point to their own. Both use the same MCP Apps protocol:
+
+```python
+from fastmcp.server.apps import AppConfig
+
+@mcp.tool(app=True)
+def team_directory() -> PrefabApp:
+ ...
+
+@mcp.tool(app=AppConfig(resource_uri="ui://my-app/map.html"))
+def map_view() -> str:
+ ...
+```
+
+## Next Steps
+
+- **[Patterns](/apps/patterns)** β Charts, tables, forms, and other common tool UIs
+- **[Custom HTML Apps](/apps/low-level)** β When you need your own HTML, CSS, and JavaScript
+- **[Prefab UI Docs](https://prefab.prefect.io)** β Components, state, expressions, and actions
diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx
new file mode 100644
index 000000000..71b89e08a
--- /dev/null
+++ b/docs/cli/auth.mdx
@@ -0,0 +1,85 @@
+---
+title: Auth Utilities
+sidebarTitle: Auth
+description: Create and validate CIMD documents for OAuth
+icon: key
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+The `fastmcp auth` commands help with CIMD (Client ID Metadata Document) management β part of MCP's OAuth authentication flow. A CIMD is a JSON document you host at an HTTPS URL to identify your client application to MCP servers.
+
+## Creating a CIMD
+
+`fastmcp auth cimd create` generates a CIMD document:
+
+```bash
+fastmcp auth cimd create \
+ --name "My App" \
+ --redirect-uri "http://localhost:*/callback"
+```
+
+```json
+{
+ "client_id": "https://your-domain.com/oauth/client.json",
+ "client_name": "My App",
+ "redirect_uris": ["http://localhost:*/callback"],
+ "token_endpoint_auth_method": "none"
+}
+```
+
+The generated document includes a placeholder `client_id` β update it to match the URL where you'll host the document before deploying.
+
+### Options
+
+| Option | Flag | Description |
+| ------ | ---- | ----------- |
+| Name | `--name` | **Required.** Human-readable client name |
+| Redirect URI | `--redirect-uri` | **Required.** Allowed redirect URIs (repeatable) |
+| Client URI | `--client-uri` | Client's home page URL |
+| Logo URI | `--logo-uri` | Client's logo URL |
+| Scope | `--scope` | Space-separated list of scopes |
+| Output | `--output`, `-o` | Save to file (default: stdout) |
+| Pretty | `--pretty` | Pretty-print JSON (default: true) |
+
+### Example
+
+```bash
+fastmcp auth cimd create \
+ --name "My Production App" \
+ --redirect-uri "http://localhost:*/callback" \
+ --redirect-uri "https://myapp.example.com/callback" \
+ --client-uri "https://myapp.example.com" \
+ --scope "read write" \
+ --output client.json
+```
+
+## Validating a CIMD
+
+`fastmcp auth cimd validate` fetches a hosted CIMD and verifies it conforms to the spec:
+
+```bash
+fastmcp auth cimd validate https://myapp.example.com/oauth/client.json
+```
+
+The validator checks that the URL is valid (HTTPS, non-root path), the document is valid JSON, the `client_id` matches the URL, and no shared-secret auth methods are used.
+
+On success:
+
+```
+β Fetching https://myapp.example.com/oauth/client.json...
+β Valid CIMD document
+
+Document details:
+ client_id: https://myapp.example.com/oauth/client.json
+ client_name: My App
+ token_endpoint_auth_method: none
+ redirect_uris:
+ β’ http://localhost:*/callback
+```
+
+| Option | Flag | Description |
+| ------ | ---- | ----------- |
+| Timeout | `--timeout`, `-t` | HTTP request timeout in seconds (default: 10) |
diff --git a/docs/cli/client.mdx b/docs/cli/client.mdx
new file mode 100644
index 000000000..bd72b163d
--- /dev/null
+++ b/docs/cli/client.mdx
@@ -0,0 +1,140 @@
+---
+title: Client Commands
+sidebarTitle: Client
+description: List tools, call them, and discover configured servers
+icon: satellite-dish
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+The CLI can act as an MCP client β connecting to any server (local or remote) to list what it exposes and call its tools directly. This is useful for development, debugging, scripting, and giving shell-capable LLM agents access to MCP servers.
+
+## Listing Tools
+
+`fastmcp list` connects to a server and prints its tools as function signatures, showing parameter names, types, and descriptions at a glance:
+
+```bash
+fastmcp list http://localhost:8000/mcp
+fastmcp list server.py
+fastmcp list weather # name-based resolution
+```
+
+When you need the full JSON Schema for a tool's inputs or outputs β for understanding nested objects, enum constraints, or complex types β opt in with `--input-schema` or `--output-schema`:
+
+```bash
+fastmcp list server.py --input-schema
+```
+
+### Resources and Prompts
+
+By default, only tools are shown. Add `--resources` or `--prompts` to include those:
+
+```bash
+fastmcp list server.py --resources --prompts
+```
+
+### Machine-Readable Output
+
+The `--json` flag switches to structured JSON with full schemas included. This is the format to use when feeding tool definitions to an LLM or building automation:
+
+```bash
+fastmcp list server.py --json
+```
+
+### Options
+
+| Option | Flag | Description |
+| ------ | ---- | ----------- |
+| Command | `--command` | Connect via stdio (e.g., `'npx -y @mcp/server'`) |
+| Transport | `--transport`, `-t` | Force `http` or `sse` for URL targets |
+| Resources | `--resources` | Include resources in output |
+| Prompts | `--prompts` | Include prompts in output |
+| Input Schema | `--input-schema` | Show full input schemas |
+| Output Schema | `--output-schema` | Show full output schemas |
+| JSON | `--json` | Structured JSON output |
+| Timeout | `--timeout` | Connection timeout in seconds |
+| Auth | `--auth` | `oauth` (default for HTTP), a bearer token, or `none` |
+
+## Calling Tools
+
+`fastmcp call` invokes a single tool on a server. Pass arguments as `key=value` pairs β the CLI fetches the tool's schema and coerces your string values to the right types automatically:
+
+```bash
+fastmcp call server.py greet name=World
+fastmcp call http://localhost:8000/mcp search query=hello limit=5
+```
+
+Type coercion is schema-driven: `"5"` becomes the integer `5` when the schema expects an integer. Booleans accept `true`/`false`, `yes`/`no`, and `1`/`0`. Arrays and objects are parsed as JSON.
+
+### Complex Arguments
+
+For tools with nested or structured parameters, `key=value` syntax gets awkward. Pass a single JSON object instead:
+
+```bash
+fastmcp call server.py create_item '{"name": "Widget", "tags": ["sale"], "metadata": {"color": "blue"}}'
+```
+
+Or use `--input-json` to provide a base dictionary, then override individual keys with `key=value` pairs:
+
+```bash
+fastmcp call server.py search --input-json '{"query": "hello", "limit": 5}' limit=10
+```
+
+### Error Handling
+
+If you misspell a tool name, the CLI suggests corrections via fuzzy matching. Missing required arguments produce a clear message with the tool's signature as a reminder. Tool execution errors are printed with a non-zero exit code, making the CLI straightforward to use in scripts.
+
+### Structured Output
+
+`--json` emits the raw result including content blocks, error status, and structured content:
+
+```bash
+fastmcp call server.py get_weather city=London --json
+```
+
+### Interactive Elicitation
+
+Some tools request additional input during execution through MCP's elicitation mechanism. When this happens, the CLI prompts you in the terminal β showing each field's name, type, and whether it's required. You can type `decline` to skip a question or `cancel` to abort the call entirely.
+
+### Options
+
+| Option | Flag | Description |
+| ------ | ---- | ----------- |
+| Command | `--command` | Connect via stdio |
+| Transport | `--transport`, `-t` | Force `http` or `sse` |
+| Input JSON | `--input-json` | Base arguments as JSON (merged with `key=value`) |
+| JSON | `--json` | Raw JSON output |
+| Timeout | `--timeout` | Connection timeout in seconds |
+| Auth | `--auth` | `oauth`, a bearer token, or `none` |
+
+## Discovering Configured Servers
+
+`fastmcp discover` scans your machine for MCP servers configured in editors and tools. It checks:
+
+- **Claude Desktop** β `claude_desktop_config.json`
+- **Claude Code** β `~/.claude.json`
+- **Cursor** β `.cursor/mcp.json` (walks up from current directory)
+- **Gemini CLI** β `~/.gemini/settings.json`
+- **Goose** β `~/.config/goose/config.yaml`
+- **Project** β `./mcp.json` in the current directory
+
+```bash
+fastmcp discover
+```
+
+The output groups servers by source, showing each server's name and transport. Filter by source or get machine-readable output:
+
+```bash
+fastmcp discover --source claude-code
+fastmcp discover --source cursor --source gemini --json
+```
+
+Any server that appears here can be used by name with `list`, `call`, and other commands β so you can go from "I have a server in Claude Code" to querying it without copying URLs or paths.
+
+## LLM Agent Integration
+
+For LLM agents that can execute shell commands but don't have native MCP support, the CLI provides a clean bridge. The agent calls `fastmcp list --json` to discover available tools with full schemas, then `fastmcp call --json` to invoke them with structured results.
+
+Because the CLI handles connection management, transport selection, and type coercion internally, the agent doesn't need to understand MCP protocol details β it just reads JSON and constructs shell commands.
diff --git a/docs/cli/generate-cli.mdx b/docs/cli/generate-cli.mdx
new file mode 100644
index 000000000..2754d199a
--- /dev/null
+++ b/docs/cli/generate-cli.mdx
@@ -0,0 +1,106 @@
+---
+title: Generate CLI
+sidebarTitle: Generate CLI
+description: Scaffold a standalone typed CLI from any MCP server
+icon: wand-magic-sparkles
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+`fastmcp list` and `fastmcp call` are general-purpose β you always specify the server, the tool name, and the arguments from scratch. `fastmcp generate-cli` goes further: it connects to a server, reads its tool schemas, and writes a standalone Python script where every tool is a proper subcommand with typed flags, help text, and tab completion. The result is a CLI that feels hand-written for that specific server.
+
+MCP tool schemas already contain everything a CLI framework needs β parameter names, types, descriptions, required/optional status, and defaults. `generate-cli` maps that into [cyclopts](https://cyclopts.readthedocs.io/) commands, so JSON Schema types become Python type annotations, descriptions become `--help` text, and required parameters become mandatory flags.
+
+## Generating a Script
+
+Point the command at any [server target](/cli/overview#server-targets) and it writes a CLI script:
+
+```bash
+fastmcp generate-cli weather
+fastmcp generate-cli http://localhost:8000/mcp
+fastmcp generate-cli server.py my_weather_cli.py
+```
+
+The second positional argument sets the output path (defaults to `cli.py`). If the file already exists, pass `-f` to overwrite:
+
+```bash
+fastmcp generate-cli weather -f
+```
+
+## What You Get
+
+The generated script is a regular Python file β executable, editable, and yours:
+
+```
+$ python cli.py call-tool --help
+Usage: weather-cli call-tool COMMAND
+
+Call a tool on the server
+
+Commands:
+ get_forecast Get the weather forecast for a city.
+ search_city Search for a city by name.
+```
+
+Each tool has typed parameters with help text pulled directly from the server's schema:
+
+```
+$ python cli.py call-tool get_forecast --help
+Usage: weather-cli call-tool get_forecast [OPTIONS]
+
+Get the weather forecast for a city.
+
+Options:
+ --city [str] City name (required)
+ --days [int] Number of forecast days (default: 3)
+```
+
+Beyond tool commands, the script includes generic MCP operations β `list-tools`, `list-resources`, `read-resource`, `list-prompts`, and `get-prompt` β that always reflect the server's current state, even if tools have changed since generation.
+
+## Parameter Handling
+
+Parameters are mapped based on their JSON Schema type:
+
+**Simple types** (`string`, `integer`, `number`, `boolean`) become typed flags:
+
+```bash
+python cli.py call-tool get_forecast --city London --days 3
+```
+
+**Arrays of simple types** become repeatable flags:
+
+```bash
+python cli.py call-tool tag_items --tags python --tags fastapi --tags mcp
+```
+
+**Complex types** (objects, nested arrays, unions) accept JSON strings. The `--help` output shows the full schema so you know what structure to pass:
+
+```bash
+python cli.py call-tool create_user \
+ --name John \
+ --metadata '{"role": "admin", "dept": "engineering"}'
+```
+
+## Agent Skill
+
+Alongside the CLI script, `generate-cli` writes a `SKILL.md` file β a [Claude Code agent skill](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) that documents every tool's exact invocation syntax, parameter flags, types, and descriptions. An agent can pick up the CLI immediately without running `--help` or experimenting with flag names.
+
+To skip skill generation:
+
+```bash
+fastmcp generate-cli weather --no-skill
+```
+
+## How It Works
+
+The generated script is a *client*, not a server β it connects to the server on every invocation rather than bundling it. A `CLIENT_SPEC` variable at the top holds the resolved transport (a URL string or `StdioTransport` with baked-in command and arguments).
+
+The most common edit is changing `CLIENT_SPEC` β for example, pointing a script generated from a dev server at production. Beyond that, the helper functions (`_call_tool`, `_print_tool_result`) are thin wrappers around `fastmcp.Client` that are easy to adapt.
+
+The script requires `fastmcp` as a dependency. If it lives outside a project that already has FastMCP installed:
+
+```bash
+uv run --with fastmcp python cli.py call-tool get_forecast --city London
+```
diff --git a/docs/cli/inspecting.mdx b/docs/cli/inspecting.mdx
new file mode 100644
index 000000000..657921357
--- /dev/null
+++ b/docs/cli/inspecting.mdx
@@ -0,0 +1,72 @@
+---
+title: Inspecting Servers
+sidebarTitle: Inspecting
+description: View a server's components and metadata
+icon: magnifying-glass
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+`fastmcp inspect` loads a server and reports what it contains β its tools, resources, prompts, version, and metadata. The default output is a human-readable summary:
+
+```bash
+fastmcp inspect server.py
+```
+
+```
+Server: MyServer
+Instructions: A helpful MCP server
+Version: 1.0.0
+
+Components:
+ Tools: 5
+ Prompts: 2
+ Resources: 3
+ Templates: 1
+
+Environment:
+ FastMCP: 2.0.0
+ MCP: 1.0.0
+
+Use --format [fastmcp|mcp] for complete JSON output
+```
+
+## JSON Output
+
+For programmatic use, two JSON formats are available:
+
+**FastMCP format** (`--format fastmcp`) includes everything FastMCP knows about the server β tool tags, enabled status, output schemas, annotations, and custom metadata. Field names use `snake_case`. This is the format for debugging and introspecting FastMCP servers.
+
+**MCP protocol format** (`--format mcp`) shows exactly what MCP clients see through the protocol β only standard MCP fields, `camelCase` names, no FastMCP-specific extensions. This is the format for verifying client compatibility and debugging what clients actually receive.
+
+```bash
+# Full FastMCP metadata to stdout
+fastmcp inspect server.py --format fastmcp
+
+# MCP protocol view saved to file
+fastmcp inspect server.py --format mcp -o manifest.json
+```
+
+## Options
+
+| Option | Flag | Description |
+| ------ | ---- | ----------- |
+| Format | `--format`, `-f` | `fastmcp` or `mcp` (required when using `-o`) |
+| Output File | `--output`, `-o` | Save to file instead of stdout |
+
+## Entrypoints
+
+The `inspect` command supports the same local entrypoints as [`fastmcp run`](/cli/running): inferred instances, explicit entrypoints, factory functions, and `fastmcp.json` configs.
+
+```bash
+fastmcp inspect server.py # inferred instance
+fastmcp inspect server.py:my_server # explicit entrypoint
+fastmcp inspect server.py:create_server # factory function
+fastmcp inspect fastmcp.json # config file
+```
+
+
+`inspect` only works with local files and `fastmcp.json` β it doesn't connect to remote URLs or standard MCP config files.
+
diff --git a/docs/cli/install-mcp.mdx b/docs/cli/install-mcp.mdx
new file mode 100644
index 000000000..bf1b60b36
--- /dev/null
+++ b/docs/cli/install-mcp.mdx
@@ -0,0 +1,141 @@
+---
+title: Install MCP Servers
+sidebarTitle: Install MCPs
+description: Install MCP servers into Claude, Cursor, Gemini, and other clients
+icon: download
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+`fastmcp install` registers a server with an MCP client application so the client can launch it automatically. Each MCP client runs servers in its own isolated environment, which means dependencies need to be explicitly declared β you can't rely on whatever happens to be installed locally.
+
+```bash
+fastmcp install claude-desktop server.py
+fastmcp install claude-code server.py --with pandas --with matplotlib
+fastmcp install cursor server.py -e .
+```
+
+
+`uv` must be installed and available in your system PATH. Both Claude Desktop and Cursor run servers in isolated environments managed by `uv`. On macOS, install it globally with Homebrew for Claude Desktop compatibility: `brew install uv`.
+
+
+## Supported Clients
+
+| Client | Install method |
+| ------ | -------------- |
+| `claude-code` | Claude Code's built-in MCP management |
+| `claude-desktop` | Direct config file modification |
+| `cursor` | Deeplink that opens Cursor for confirmation |
+| `gemini-cli` | Gemini CLI's built-in MCP management |
+| `goose` | Deeplink that opens Goose for confirmation (uses `uvx`) |
+| `mcp-json` | Generates standard MCP JSON config for manual use |
+| `stdio` | Outputs the shell command to run via stdio |
+
+## Declaring Dependencies
+
+Because MCP clients run servers in isolation, you need to tell the install command what your server needs. There are two approaches:
+
+**Command-line flags** let you specify dependencies directly:
+
+```bash
+fastmcp install claude-desktop server.py --with pandas --with "sqlalchemy>=2.0"
+fastmcp install cursor server.py -e . --with-requirements requirements.txt
+```
+
+**`fastmcp.json`** configuration files declare dependencies alongside the server definition. When you install from a config file, dependencies are picked up automatically:
+
+```bash
+fastmcp install claude-desktop fastmcp.json
+fastmcp install claude-desktop # auto-detects fastmcp.json in current directory
+```
+
+See [Server Configuration](/deployment/server-configuration) for the full config format.
+
+## Options
+
+| Option | Flag | Description |
+| ------ | ---- | ----------- |
+| Server Name | `--server-name`, `-n` | Custom name for the server |
+| Editable Package | `--with-editable`, `-e` | Install a directory in editable mode |
+| Extra Packages | `--with` | Additional packages (repeatable) |
+| Environment Variables | `--env` | `KEY=VALUE` pairs (repeatable) |
+| Environment File | `--env-file`, `-f` | Load env vars from a `.env` file |
+| Python | `--python` | Python version (e.g., `3.11`) |
+| Project | `--project` | Run within a uv project directory |
+| Requirements | `--with-requirements` | Install from a requirements file |
+
+## Examples
+
+```bash
+# Basic install with auto-detected server instance
+fastmcp install claude-desktop server.py
+
+# Install from fastmcp.json with auto-detection
+fastmcp install claude-desktop
+
+# Explicit entrypoint with dependencies
+fastmcp install claude-desktop server.py:my_server \
+ --server-name "My Analysis Server" \
+ --with pandas
+
+# With environment variables
+fastmcp install claude-code server.py \
+ --env API_KEY=secret \
+ --env DEBUG=true
+
+# With env file
+fastmcp install cursor server.py --env-file .env
+
+# Specific Python version and requirements file
+fastmcp install claude-desktop server.py \
+ --python 3.11 \
+ --with-requirements requirements.txt
+```
+
+## Generating MCP JSON
+
+The `mcp-json` target generates standard MCP configuration JSON instead of installing into a specific client. This is useful for clients that FastMCP doesn't directly support, for CI/CD environments, or for sharing server configs:
+
+```bash
+fastmcp install mcp-json server.py
+```
+
+The output follows the standard format used by Claude Desktop, Cursor, and other MCP clients:
+
+```json
+{
+ "server-name": {
+ "command": "uv",
+ "args": ["run", "--with", "fastmcp", "fastmcp", "run", "/path/to/server.py"],
+ "env": {
+ "API_KEY": "value"
+ }
+ }
+}
+```
+
+Use `--copy` to send it to your clipboard instead of stdout.
+
+## Generating Stdio Commands
+
+The `stdio` target outputs the shell command an MCP host would use to start your server over stdio:
+
+```bash
+fastmcp install stdio server.py
+# Output: uv run --with fastmcp fastmcp run /absolute/path/to/server.py
+```
+
+When installing from a `fastmcp.json`, dependencies from the config are included automatically:
+
+```bash
+fastmcp install stdio fastmcp.json
+# Output: uv run --with fastmcp --with pillow --with 'qrcode[pil]>=8.0' fastmcp run /path/to/server.py
+```
+
+Use `--copy` to copy to clipboard.
+
+
+`fastmcp install` is designed for local server files with stdio transport. For remote servers running over HTTP, use your client's native configuration β FastMCP's value here is simplifying the complex local setup with `uv`, dependencies, and environment variables.
+
diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx
new file mode 100644
index 000000000..2cd4e145d
--- /dev/null
+++ b/docs/cli/overview.mdx
@@ -0,0 +1,103 @@
+---
+title: CLI
+sidebarTitle: Overview
+description: The fastmcp command-line interface
+icon: terminal
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+The `fastmcp` CLI is installed automatically with FastMCP. It's the primary way to run, test, install, and interact with MCP servers from your terminal.
+
+```bash
+fastmcp --help
+```
+
+## Commands at a Glance
+
+| Command | What it does |
+| ------- | ------------ |
+| [`run`](/cli/running) | Run a server (local file, factory function, remote URL, or config file) |
+| [`dev inspector`](/cli/running#development-with-the-inspector) | Launch a server inside the MCP Inspector for interactive testing |
+| [`install`](/cli/install-mcp) | Install a server into Claude Code, Claude Desktop, Cursor, Gemini CLI, or Goose |
+| [`inspect`](/cli/inspecting) | Print a server's tools, resources, and prompts as a summary or JSON report |
+| [`list`](/cli/client) | List a server's tools (and optionally resources and prompts) |
+| [`call`](/cli/client#calling-tools) | Call a single tool with arguments |
+| [`discover`](/cli/client#discovering-configured-servers) | Find MCP servers configured in your editors and tools |
+| [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas |
+| [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project |
+| [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth |
+| `version` | Print version info (`--copy` to copy to clipboard) |
+
+## Server Targets
+
+Most commands need to know *which server* to talk to. You pass a "server spec" as the first argument, and FastMCP resolves the right transport automatically.
+
+**URLs** connect to a running HTTP server:
+
+```bash
+fastmcp list http://localhost:8000/mcp
+fastmcp call http://localhost:8000/mcp get_forecast city=London
+```
+
+**Python files** are loaded directly β no `mcp.run()` boilerplate needed. FastMCP finds a server instance named `mcp`, `server`, or `app` in the file, or you can specify one explicitly:
+
+```bash
+fastmcp list server.py
+fastmcp run server.py:my_custom_server
+```
+
+**Config files** work too β both FastMCP's own `fastmcp.json` format and standard MCP config files with an `mcpServers` key:
+
+```bash
+fastmcp run fastmcp.json
+fastmcp list mcp-config.json
+```
+
+**Stdio commands** connect to any MCP server that speaks over standard I/O. Use `--command` instead of a positional argument:
+
+```bash
+fastmcp list --command 'npx -y @modelcontextprotocol/server-github'
+```
+
+### Name-Based Resolution
+
+If your servers are already configured in an editor or tool, you can refer to them by name. FastMCP scans configs from Claude Desktop, Claude Code, Cursor, Gemini CLI, and Goose:
+
+```bash
+fastmcp list weather
+fastmcp call weather get_forecast city=London
+```
+
+When the same name appears in multiple configs, use the `source:name` form to be specific:
+
+```bash
+fastmcp list claude-code:my-server
+fastmcp call cursor:weather get_forecast city=London
+```
+
+Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what's available on your machine.
+
+## Authentication
+
+When targeting an HTTP URL, the CLI enables OAuth authentication by default. If the server requires it, you'll be guided through the flow (typically opening a browser). If it doesn't, the setup is a silent no-op.
+
+To skip authentication entirely β useful for local development servers β pass `--auth none`:
+
+```bash
+fastmcp call http://localhost:8000/mcp my_tool --auth none
+```
+
+You can also pass a bearer token directly:
+
+```bash
+fastmcp list http://localhost:8000/mcp --auth "Bearer sk-..."
+```
+
+## Transport Override
+
+FastMCP defaults to Streamable HTTP for URL targets. If the server only supports Server-Sent Events (SSE), force the older transport:
+
+```bash
+fastmcp list http://localhost:8000 --transport sse
+```
diff --git a/docs/cli/running.mdx b/docs/cli/running.mdx
new file mode 100644
index 000000000..dd976d561
--- /dev/null
+++ b/docs/cli/running.mdx
@@ -0,0 +1,141 @@
+---
+title: Running Servers
+sidebarTitle: Running
+description: Start, develop, and configure servers from the command line
+icon: play
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+## Starting a Server
+
+`fastmcp run` starts a server. Point it at a Python file, a factory function, a remote URL, or a config file:
+
+```bash
+fastmcp run server.py
+fastmcp run server.py:create_server
+fastmcp run https://example.com/mcp
+fastmcp run fastmcp.json
+```
+
+By default, the server runs over **stdio** β the transport that MCP clients like Claude Desktop expect. To serve over HTTP instead, specify the transport:
+
+```bash
+fastmcp run server.py --transport http
+fastmcp run server.py --transport http --host 0.0.0.0 --port 9000
+```
+
+### Entrypoints
+
+FastMCP supports several ways to locate and start your server:
+
+**Inferred instance** β FastMCP imports the file and looks for a variable named `mcp`, `server`, or `app`:
+
+```bash
+fastmcp run server.py
+```
+
+**Explicit instance** β point at a specific variable:
+
+```bash
+fastmcp run server.py:my_server
+```
+
+**Factory function** β FastMCP calls the function and uses the returned server. Useful when your server needs async setup or configuration that runs before startup:
+
+```bash
+fastmcp run server.py:create_server
+```
+
+**Remote URL** β starts a local proxy that bridges to a remote server. Handy for local development against a deployed server, or for bridging a remote HTTP server to stdio:
+
+```bash
+fastmcp run https://example.com/mcp
+```
+
+**FastMCP config** β uses a `fastmcp.json` file that declaratively specifies the server, its dependencies, and deployment settings. When you run `fastmcp run` with no arguments, it auto-detects `fastmcp.json` in the current directory:
+
+```bash
+fastmcp run
+fastmcp run my-config.fastmcp.json
+```
+
+See [Server Configuration](/deployment/server-configuration) for the full `fastmcp.json` format.
+
+**MCP config** β runs servers defined in a standard MCP configuration file (any `.json` with an `mcpServers` key):
+
+```bash
+fastmcp run mcp.json
+```
+
+
+`fastmcp run` completely ignores the `if __name__ == "__main__"` block. Any setup code in that block won't execute. If you need initialization logic to run, use a [factory function](/cli/overview#factory-functions).
+
+
+### Options
+
+| Option | Flag | Description |
+| ------ | ---- | ----------- |
+| Transport | `--transport`, `-t` | `stdio` (default), `http`, or `sse` |
+| Host | `--host` | Bind address for HTTP (default: `127.0.0.1`) |
+| Port | `--port`, `-p` | Bind port for HTTP (default: `8000`) |
+| Path | `--path` | URL path for HTTP (default: `/mcp/`) |
+| Log Level | `--log-level`, `-l` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
+| No Banner | `--no-banner` | Suppress the startup banner |
+| Auto-Reload | `--reload` / `--no-reload` | Watch for file changes and restart automatically |
+| Reload Dirs | `--reload-dir` | Directories to watch (repeatable) |
+| Skip Env | `--skip-env` | Don't set up a uv environment (use when already in one) |
+| Python | `--python` | Python version to use (e.g., `3.11`) |
+| Extra Packages | `--with` | Additional packages to install (repeatable) |
+| Project | `--project` | Run within a specific uv project directory |
+| Requirements | `--with-requirements` | Install from a requirements file |
+
+### Dependency Management
+
+By default, `fastmcp run` uses your current Python environment directly. When you pass `--python`, `--with`, `--project`, or `--with-requirements`, it switches to running via `uv run` in a subprocess, which handles dependency isolation automatically.
+
+The `--skip-env` flag is useful when you're already inside an activated venv, a Docker container with pre-installed dependencies, or a uv-managed project β it prevents uv from trying to set up another environment layer.
+
+## Development with the Inspector
+
+`fastmcp dev inspector` launches your server inside the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), a browser-based tool for interactively testing MCP servers. Auto-reload is on by default, so your server restarts when you save changes.
+
+```bash
+fastmcp dev inspector server.py
+fastmcp dev inspector server.py -e . --with pandas
+```
+
+
+The Inspector always runs your server via `uv run` in a subprocess β it never uses your local environment directly. Specify dependencies with `--with`, `--with-editable`, `--with-requirements`, or through a `fastmcp.json` file.
+
+
+
+The Inspector connects over **stdio only**. When it launches, you may need to select "STDIO" from the transport dropdown and click connect. To test a server over HTTP, start it separately with `fastmcp run server.py --transport http` and point the Inspector at the URL.
+
+
+| Option | Flag | Description |
+| ------ | ---- | ----------- |
+| Editable Package | `--with-editable`, `-e` | Install a directory in editable mode |
+| Extra Packages | `--with` | Additional packages (repeatable) |
+| Inspector Version | `--inspector-version` | MCP Inspector version to use |
+| UI Port | `--ui-port` | Port for the Inspector UI |
+| Server Port | `--server-port` | Port for the Inspector proxy |
+| Auto-Reload | `--reload` / `--no-reload` | File watching (default: on) |
+| Reload Dirs | `--reload-dir` | Directories to watch (repeatable) |
+| Python | `--python` | Python version |
+| Project | `--project` | Run within a uv project directory |
+| Requirements | `--with-requirements` | Install from a requirements file |
+
+## Pre-Building Environments
+
+`fastmcp project prepare` creates a persistent uv project from a `fastmcp.json` file, pre-installing all dependencies. This separates environment setup from server execution β install once, run many times.
+
+```bash
+# Step 1: Build the environment (slow, does dependency resolution)
+fastmcp project prepare fastmcp.json --output-dir ./env
+
+# Step 2: Run using the prepared environment (fast, no install step)
+fastmcp run fastmcp.json --project ./env
+```
+
+The prepared directory contains a `pyproject.toml`, a `.venv` with all packages installed, and a `uv.lock` for reproducibility. This is particularly useful in deployment scenarios where you want deterministic, pre-built environments.
diff --git a/docs/clients/prompts.mdx b/docs/clients/prompts.mdx
index 2bf8c3c19..bb50d475f 100644
--- a/docs/clients/prompts.mdx
+++ b/docs/clients/prompts.mdx
@@ -90,9 +90,9 @@ async with client:
"expertise": "python programming"
})
- # Typically returns messages with role="system"
- system_message = result.messages[0]
- print(f"System prompt: {system_message.content}")
+ # Access the returned messages
+ message = result.messages[0]
+ print(f"Prompt: {message.content}")
```
Conversation templates generate multi-turn flows:
diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx
index b1114da0d..a2e0e9942 100644
--- a/docs/clients/sampling.mdx
+++ b/docs/clients/sampling.mdx
@@ -97,7 +97,7 @@ client = Client(
## Built-in Handlers
-FastMCP provides built-in handlers for OpenAI and Anthropic APIs that support the full sampling API including tool use.
+FastMCP provides built-in handlers for OpenAI, Anthropic, and Google Gemini APIs that support the full sampling API including tool use.
### OpenAI Handler
@@ -149,6 +149,24 @@ client = Client(
Install the Anthropic handler with `pip install fastmcp[anthropic]`.
+### Google Gemini Handler
+
+
+
+```python
+from fastmcp import Client
+from fastmcp.client.sampling.handlers.google_genai import GoogleGenAISamplingHandler
+
+client = Client(
+ "my_mcp_server.py",
+ sampling_handler=GoogleGenAISamplingHandler(default_model="gemini-2.0-flash"),
+)
+```
+
+
+Install the Google Gemini handler with `pip install fastmcp[gemini]`.
+
+
## Sampling Capabilities
When you provide a `sampling_handler`, FastMCP automatically advertises full sampling capabilities to the server, including tool support. To disable tool support for simpler handlers:
diff --git a/docs/css/style.css b/docs/css/style.css
index 8c484ce96..99844f692 100644
--- a/docs/css/style.css
+++ b/docs/css/style.css
@@ -1,3 +1,7 @@
+html:not([data-page-mode="wide"]) #content-area {
+ max-width: 44rem !important;
+}
+
img.nav-logo {
max-width: 200px;
}
diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx
index a0ff7a66e..e961de9d1 100644
--- a/docs/deployment/http.mdx
+++ b/docs/deployment/http.mdx
@@ -725,6 +725,134 @@ Both parameters are required for production. Without an explicit signing key, ke
For more details on the token architecture and key management, see [OAuth Proxy Key and Storage Management](/servers/auth/oauth-proxy#key-and-storage-management).
+## Reverse Proxy (nginx)
+
+In production, you'll typically run your FastMCP server behind a reverse proxy like nginx. A reverse proxy provides TLS termination, domain-based routing, static file serving, and an additional layer of security between the internet and your application.
+
+### Running FastMCP as a Linux Service
+
+Before configuring nginx, you need your FastMCP server running as a background service. A systemd unit file ensures your server starts automatically and restarts on failure.
+
+Create a file at `/etc/systemd/system/fastmcp.service`:
+
+```ini
+[Unit]
+Description=FastMCP Server
+After=network.target
+
+[Service]
+User=www-data
+Group=www-data
+WorkingDirectory=/opt/fastmcp
+ExecStart=/opt/fastmcp/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000
+Restart=always
+RestartSec=5
+Environment="PATH=/opt/fastmcp/.venv/bin"
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Enable and start the service:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable fastmcp
+sudo systemctl start fastmcp
+```
+
+This assumes your ASGI application is in `/opt/fastmcp/app.py` with a virtual environment at `/opt/fastmcp/.venv`. Adjust paths to match your deployment layout.
+
+### nginx Configuration
+
+FastMCP's Streamable HTTP transport uses Server-Sent Events (SSE) for streaming responses. This requires specific nginx settings to prevent buffering from breaking the event stream.
+
+Create a site configuration at `/etc/nginx/sites-available/fastmcp`:
+
+```nginx
+server {
+ listen 80;
+ server_name mcp.example.com;
+
+ # Redirect HTTP to HTTPS
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ server_name mcp.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
+
+ location / {
+ proxy_pass http://127.0.0.1:8000;
+ proxy_http_version 1.1;
+ proxy_set_header Connection '';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Required for SSE (Server-Sent Events) streaming
+ proxy_buffering off;
+ proxy_cache off;
+
+ # Allow long-lived connections for streaming responses
+ proxy_read_timeout 300s;
+ proxy_send_timeout 300s;
+ }
+}
+```
+
+Enable the site and reload nginx:
+
+```bash
+sudo ln -s /etc/nginx/sites-available/fastmcp /etc/nginx/sites-enabled/
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+Your FastMCP server is now accessible at `https://mcp.example.com/mcp`.
+
+
+**SSE buffering is the most common issue.** If clients connect but never receive streaming responses (progress updates, tool results), verify that `proxy_buffering off` is set. Without it, nginx buffers the entire SSE stream and delivers it only when the connection closes, which breaks real-time communication.
+
+
+### Key Considerations
+
+When deploying FastMCP behind a reverse proxy, keep these points in mind:
+
+- **Disable buffering**: SSE requires `proxy_buffering off` so events reach clients immediately. This is the single most important setting.
+- **Increase timeouts**: The default nginx `proxy_read_timeout` is 60 seconds. Long-running MCP tools will cause the connection to drop. Set timeouts to at least 300 seconds, or higher if your tools run longer. For tools that may exceed any timeout, use [SSE Polling](#sse-polling-for-long-running-operations) to gracefully handle proxy disconnections.
+- **Use HTTP/1.1**: Set `proxy_http_version 1.1` and `proxy_set_header Connection ''` to enable keep-alive connections between nginx and your server. Clearing the `Connection` header prevents clients from sending `Connection: close` to your upstream, which would break SSE streams. Both settings are required for proper SSE support.
+- **Forward headers**: Pass `X-Forwarded-For` and `X-Forwarded-Proto` so your FastMCP server can determine the real client IP and protocol. This is important for logging and for OAuth redirect URLs.
+- **TLS termination**: Let nginx handle TLS certificates (e.g., via Let's Encrypt with Certbot). Your FastMCP server can then run on plain HTTP internally.
+
+### Mounting Under a Path Prefix
+
+If you want your MCP server available at a subpath like `https://example.com/api/mcp` instead of at the root domain, adjust the nginx `location` block:
+
+```nginx
+location /api/ {
+ proxy_pass http://127.0.0.1:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Connection '';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Required for SSE streaming
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 300s;
+ proxy_send_timeout 300s;
+}
+```
+
+Note the trailing `/` on both `location /api/` and `proxy_pass http://127.0.0.1:8000/` β this ensures nginx strips the `/api` prefix before forwarding to your server. If you're using OAuth authentication with a mount prefix, see [Mounting Authenticated Servers](#mounting-authenticated-servers) for additional configuration.
+
## Testing Your Deployment
Once your server is deployed, you'll need to verify it's accessible and functioning correctly. For comprehensive testing strategies including connectivity tests, client testing, and authentication testing, see the [Testing Your Server](/development/tests) guide.
diff --git a/docs/deployment/running-server.mdx b/docs/deployment/running-server.mdx
index bf4b83161..c10855345 100644
--- a/docs/deployment/running-server.mdx
+++ b/docs/deployment/running-server.mdx
@@ -157,7 +157,7 @@ fastmcp run database_server.py -- --database-path /tmp/db.sqlite --debug
This is useful for servers that need configuration files, database paths, API keys, or other runtime options.
-For more CLI features including development mode with the MCP Inspector, see the [CLI documentation](/patterns/cli).
+For more CLI features including development mode with the MCP Inspector, see the [CLI documentation](/cli/running).
### Auto-Reload for Development
diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx
index 04fa9e343..da9fc87db 100644
--- a/docs/development/v3-notes/v3-features.mdx
+++ b/docs/development/v3-notes/v3-features.mdx
@@ -23,6 +23,38 @@ result = await ctx.sample(
)
```
+### Google GenAI Sampling Handler
+
+FastMCP now includes a sampling handler for Google's Gemini models ([#2977](https://github.com/jlowin/fastmcp/pull/2977)). This enables MCP clients to use Google's GenAI models with the sampling protocol, including full tool calling support.
+
+```python
+from fastmcp import Client
+from fastmcp.client.sampling.handlers import GoogleGenaiSamplingHandler
+from google.genai import Client as GoogleGenaiClient
+
+# Initialize the handler
+handler = GoogleGenaiSamplingHandler(
+ default_model="gemini-2.0-flash-exp",
+ client=GoogleGenaiClient(), # Optional - creates one if not provided
+)
+
+# Use with MCP sampling (handler is configured at Client construction)
+async with Client("http://server/mcp", sampling_handler=handler) as client:
+ result = await client.sample(
+ messages=[...],
+ tools=[...],
+ )
+```
+
+Key features:
+- Converts MCP tool schemas to Google's function calling format
+- Supports all Google GenAI models that implement function calling
+- Handles nullable types, nested objects, and arrays in tool schemas
+- Properly maps tool choices (`auto`, `required`, `none`) to Google's configuration
+- Preserves model preferences from MCP sampling parameters
+
+The handler joins the existing Anthropic and OpenAI handlers, providing a consistent interface for model-agnostic sampling across providers.
+
### Concurrent Tool Execution in Sampling
When an LLM returns multiple tool calls in a single sampling response, they can now be executed concurrently ([#3022](https://github.com/PrefectHQ/fastmcp/pull/3022)). Default behavior remains sequential; opt in with `tool_concurrency`. Tools can declare `sequential=True` to force sequential execution even when concurrency is enabled.
@@ -153,7 +185,7 @@ Key features:
- Fuzzy tool name matching suggests alternatives on typos
- Interactive terminal elicitation for tools that request user input mid-execution
-Documentation: [Client CLI](/clients/cli)
+Documentation: [CLI Querying](/cli/client)
### CLI: `fastmcp discover` and name-based resolution
@@ -175,7 +207,7 @@ fastmcp call cursor:weather get_forecast city=London
fastmcp discover --source claude-code --source cursor
```
-Documentation: [Client CLI](/clients/cli)
+Documentation: [CLI Querying](/cli/client)
### CLI: Expanded Reload File Watching
@@ -283,7 +315,7 @@ python my_weather_cli.py read-resource docs://readme
The generated script embeds the resolved transport (URL or stdio command), so it's self-contained β users don't need to know about MCP or FastMCP to use it. Supports `-f` to overwrite existing files, and name-based resolution via `fastmcp discover`.
-Documentation: [Generate CLI](/clients/generate-cli)
+Documentation: [Generate CLI](/cli/generate-cli)
### CLI: Goose Integration
diff --git a/docs/docs.json b/docs/docs.json
index 6c7b732ac..758524c66 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -88,18 +88,7 @@
"pages": [
"getting-started/welcome",
"getting-started/installation",
- "getting-started/quickstart",
- {
- "collapsed": true,
- "group": "Upgrade",
- "icon": "up",
- "tag": "NEW",
- "pages": [
- "getting-started/upgrading/from-fastmcp-2",
- "getting-started/upgrading/from-mcp-sdk",
- "getting-started/upgrading/from-low-level-sdk"
- ]
- }
+ "getting-started/quickstart"
]
},
{
@@ -120,10 +109,10 @@
{
"collapsed": true,
"group": "Features",
- "tag": "NEW",
"icon": "stars",
"pages": [
"servers/tasks",
+ "servers/composition",
"servers/dependency-injection",
"servers/elicitation",
"servers/icons",
@@ -135,13 +124,14 @@
"servers/sampling",
"servers/storage-backends",
"servers/telemetry",
+ "servers/testing",
"servers/versioning"
- ]
+ ],
+ "tag": "UPDATED"
},
{
"collapsed": true,
"group": "Providers",
- "tag": "NEW",
"icon": "layer-group",
"pages": [
"servers/providers/overview",
@@ -149,35 +139,39 @@
"servers/providers/filesystem",
"servers/providers/proxy",
"servers/providers/skills",
- "servers/providers/custom",
- "servers/providers/mounting"
- ]
+ "servers/providers/custom"
+ ],
+ "tag": "NEW"
},
{
"collapsed": true,
"group": "Transforms",
- "tag": "NEW",
"icon": "wand-magic-sparkles",
"pages": [
"servers/transforms/transforms",
"servers/transforms/namespace",
"servers/transforms/tool-transformation",
"servers/visibility",
+ "servers/transforms/code-mode",
+ "servers/transforms/tool-search",
"servers/transforms/resources-as-tools",
"servers/transforms/prompts-as-tools"
- ]
+ ],
+ "tag": "NEW"
},
{
"collapsed": true,
"group": "Authentication",
"icon": "key",
+ "tag": "UPDATED",
"pages": [
"servers/auth/authentication",
"servers/auth/token-verification",
"servers/auth/remote-oauth",
"servers/auth/oauth-proxy",
"servers/auth/oidc-proxy",
- "servers/auth/full-oauth-server"
+ "servers/auth/full-oauth-server",
+ "servers/auth/multi-auth"
]
},
"servers/authorization",
@@ -189,9 +183,7 @@
"deployment/running-server",
"deployment/http",
"deployment/prefect-horizon",
- "deployment/server-configuration",
- "patterns/cli",
- "patterns/testing"
+ "deployment/server-configuration"
]
}
]
@@ -200,6 +192,8 @@
"group": "Apps",
"pages": [
"apps/overview",
+ "apps/prefab",
+ "apps/patterns",
"apps/low-level"
]
},
@@ -208,16 +202,6 @@
"pages": [
"clients/client",
"clients/transports",
- {
- "collapsed": true,
- "group": "CLI",
- "tag": "NEW",
- "icon": "terminal",
- "pages": [
- "clients/cli",
- "clients/generate-cli"
- ]
- },
{
"collapsed": true,
"group": "Core Operations",
@@ -232,6 +216,7 @@
"collapsed": true,
"group": "Handlers",
"icon": "hand",
+ "tag": "UPDATED",
"pages": [
"clients/notifications",
"clients/sampling",
@@ -245,13 +230,13 @@
{
"collapsed": true,
"group": "Authentication",
- "tag": "NEW",
"icon": "key",
"pages": [
"clients/auth/oauth",
"clients/auth/cimd",
"clients/auth/bearer"
- ]
+ ],
+ "tag": "UPDATED"
}
]
},
@@ -269,14 +254,15 @@
"integrations/azure",
"integrations/descope",
"integrations/discord",
+ "integrations/eunomia-authorization",
"integrations/github",
"integrations/google",
"integrations/oci",
+ "integrations/permit",
+ "integrations/propelauth",
"integrations/scalekit",
"integrations/supabase",
- "integrations/workos",
- "integrations/eunomia-authorization",
- "integrations/permit"
+ "integrations/workos"
]
},
{
@@ -298,8 +284,7 @@
"integrations/claude-desktop",
"integrations/cursor",
"integrations/gemini-cli",
- "integrations/goose",
- "integrations/mcp-json-configuration"
+ "integrations/goose"
]
},
{
@@ -311,18 +296,55 @@
"integrations/gemini",
"integrations/openai"
]
- }
+ },
+ "integrations/mcp-json-configuration"
]
},
{
- "group": "Development",
+ "group": "CLI",
"pages": [
- "development/contributing",
- "development/tests",
- "development/releases",
- "updates",
- "changelog",
- "patterns/contrib"
+ "cli/overview",
+ "cli/running",
+ "cli/install-mcp",
+ "cli/inspecting",
+ "cli/client",
+ "cli/generate-cli",
+ "cli/auth"
+ ]
+ },
+ {
+ "group": "More",
+ "pages": [
+ {
+ "collapsed": true,
+ "group": "Upgrading",
+ "icon": "up",
+ "pages": [
+ "getting-started/upgrading/from-fastmcp-2",
+ "getting-started/upgrading/from-mcp-sdk",
+ "getting-started/upgrading/from-low-level-sdk"
+ ]
+ },
+ {
+ "collapsed": true,
+ "group": "Development",
+ "icon": "code",
+ "pages": [
+ "development/contributing",
+ "development/tests",
+ "development/releases",
+ "patterns/contrib"
+ ]
+ },
+ {
+ "collapsed": true,
+ "group": "What's New",
+ "icon": "sparkles",
+ "pages": [
+ "updates",
+ "changelog"
+ ]
+ }
]
}
],
@@ -428,6 +450,26 @@
}
]
},
+ {
+ "group": "fastmcp.experimental",
+ "pages": [
+ "python-sdk/fastmcp-experimental-__init__",
+ {
+ "group": "sampling",
+ "pages": [
+ "python-sdk/fastmcp-experimental-sampling-__init__",
+ "python-sdk/fastmcp-experimental-sampling-handlers"
+ ]
+ },
+ {
+ "group": "transforms",
+ "pages": [
+ "python-sdk/fastmcp-experimental-transforms-__init__",
+ "python-sdk/fastmcp-experimental-transforms-code_mode"
+ ]
+ }
+ ]
+ },
{
"group": "fastmcp.prompts",
"pages": [
@@ -487,6 +529,7 @@
"python-sdk/fastmcp-server-auth-providers-introspection",
"python-sdk/fastmcp-server-auth-providers-jwt",
"python-sdk/fastmcp-server-auth-providers-oci",
+ "python-sdk/fastmcp-server-auth-providers-propelauth",
"python-sdk/fastmcp-server-auth-providers-scalekit",
"python-sdk/fastmcp-server-auth-providers-supabase",
"python-sdk/fastmcp-server-auth-providers-workos"
@@ -616,9 +659,19 @@
"group": "transforms",
"pages": [
"python-sdk/fastmcp-server-transforms-__init__",
+ "python-sdk/fastmcp-server-transforms-catalog",
"python-sdk/fastmcp-server-transforms-namespace",
"python-sdk/fastmcp-server-transforms-prompts_as_tools",
"python-sdk/fastmcp-server-transforms-resources_as_tools",
+ {
+ "group": "search",
+ "pages": [
+ "python-sdk/fastmcp-server-transforms-search-__init__",
+ "python-sdk/fastmcp-server-transforms-search-base",
+ "python-sdk/fastmcp-server-transforms-search-bm25",
+ "python-sdk/fastmcp-server-transforms-search-regex"
+ ]
+ },
"python-sdk/fastmcp-server-transforms-tool_transform",
"python-sdk/fastmcp-server-transforms-version_filter",
"python-sdk/fastmcp-server-transforms-visibility"
@@ -911,6 +964,22 @@
]
},
"redirects": [
+ {
+ "destination": "/cli/overview",
+ "source": "/patterns/cli"
+ },
+ {
+ "destination": "/servers/testing",
+ "source": "/patterns/testing"
+ },
+ {
+ "destination": "/cli/client",
+ "source": "/clients/cli"
+ },
+ {
+ "destination": "/cli/generate-cli",
+ "source": "/clients/generate-cli"
+ },
{
"destination": "/deployment/prefect-horizon",
"source": "/deployment/fastmcp-cloud"
@@ -932,7 +1001,7 @@
"source": "/patterns/proxy"
},
{
- "destination": "/servers/providers/mounting",
+ "destination": "/servers/composition",
"source": "/patterns/composition"
},
{
@@ -940,8 +1009,8 @@
"source": "/servers/proxy"
},
{
- "destination": "/servers/providers/mounting",
- "source": "/servers/composition"
+ "destination": "/servers/composition",
+ "source": "/servers/providers/mounting"
},
{
"destination": "/servers/transforms/transforms",
diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx
index 657b995fc..20443337b 100644
--- a/docs/getting-started/installation.mdx
+++ b/docs/getting-started/installation.mdx
@@ -62,15 +62,17 @@ Alternatively, wait for the stable v5 release. See [this issue](https://github.c
## Upgrading
-### From FastMCP 2.x
+### From FastMCP 2.0
See the [Upgrade Guide](/getting-started/upgrading/from-fastmcp-2) for a complete list of breaking changes and migration steps.
-### From FastMCP 1.0 (in the Low-Level SDK)
+### From the MCP SDK
-If you're using FastMCP 1.0 via the `mcp` package (`from mcp.server.fastmcp import FastMCP`), upgrading is straightforward β for most servers, it's a single import change. See the [full upgrade guide](/getting-started/upgrading/from-mcp-sdk) for details.
+#### From FastMCP 1.0
-### From the Low-Level Server API
+If you're using FastMCP 1.0 via the `mcp` package (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward β for most servers, it's a single import change. See the [full upgrade guide](/getting-started/upgrading/from-mcp-sdk) for details.
+
+#### From the Low-Level Server API
If you built your server directly on the `mcp` package's `Server` class β with `list_tools()`/`call_tool()` handlers and hand-written JSON Schema β see the [migration guide](/getting-started/upgrading/from-low-level-sdk) for a full walkthrough.
@@ -86,18 +88,6 @@ fastmcp>=3.0.0 # Bad - may install breaking changes
See the full [versioning and release policy](/development/releases#versioning-policy) for details on our public API, deprecation practices, and breaking change philosophy.
-### Looking Ahead: FastMCP 4.0
-
-The MCP Python SDK v2 is expected in early 2026 and will include breaking changes. When released, FastMCP will incorporate these upstream changes in a new major version (FastMCP 4.0).
-
-To avoid unexpected breaking changes, we recommend pinning your dependency with an upper bound:
-
-```
-fastmcp>=3.0,<4
-```
-
-We'll provide migration guidance when FastMCP 4.0 is released.
-
## Contributing to FastMCP
Interested in contributing to FastMCP? See the [Contributing Guide](/development/contributing) for details on:
diff --git a/docs/getting-started/upgrading/from-fastmcp-2.mdx b/docs/getting-started/upgrading/from-fastmcp-2.mdx
index fb8c57bf5..47baa5655 100644
--- a/docs/getting-started/upgrading/from-fastmcp-2.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-2.mdx
@@ -3,7 +3,6 @@ title: Upgrading from FastMCP 2
sidebarTitle: "From FastMCP 2"
description: Migration instructions for upgrading between FastMCP versions
icon: up
-tag: NEW
---
This guide covers breaking changes and migration steps when upgrading FastMCP.
diff --git a/docs/getting-started/upgrading/from-low-level-sdk.mdx b/docs/getting-started/upgrading/from-low-level-sdk.mdx
index e2be2630e..ce4ddea75 100644
--- a/docs/getting-started/upgrading/from-low-level-sdk.mdx
+++ b/docs/getting-started/upgrading/from-low-level-sdk.mdx
@@ -3,7 +3,6 @@ title: Upgrading from the MCP Low-Level SDK
sidebarTitle: "From MCP Low-Level SDK"
description: Upgrade your MCP server from the low-level Python SDK's Server class to FastMCP
icon: up
-tag: NEW
---
If you've been building MCP servers directly on the `mcp` package's `Server` class β writing `list_tools()` and `call_tool()` handlers, hand-crafting JSON Schema dicts, and wiring up transport boilerplate β this guide is for you. FastMCP replaces all of that machinery with a declarative, Pythonic API where your functions *are* the protocol surface.
@@ -583,11 +582,11 @@ if __name__ == "__main__":
Once you've upgraded, you have access to everything FastMCP provides beyond the basics:
-- **[Server composition](/servers/providers/mounting)** β Mount sub-servers to build modular applications
+- **[Server composition](/servers/composition)** β Mount sub-servers to build modular applications
- **[Middleware](/servers/middleware)** β Add logging, rate limiting, error handling, and caching
- **[Proxy servers](/servers/providers/proxy)** β Create a proxy to any existing MCP server
- **[OpenAPI integration](/integrations/openapi)** β Generate an MCP server from an OpenAPI spec
- **[Authentication](/servers/auth/authentication)** β Built-in OAuth and token verification
-- **[Testing](/patterns/testing)** β Test your server directly in Python without running a subprocess
+- **[Testing](/servers/testing)** β Test your server directly in Python without running a subprocess
Explore the full documentation at [gofastmcp.com](https://gofastmcp.com).
diff --git a/docs/getting-started/upgrading/from-mcp-sdk.mdx b/docs/getting-started/upgrading/from-mcp-sdk.mdx
index 4d1c2ddfc..494d06bdc 100644
--- a/docs/getting-started/upgrading/from-mcp-sdk.mdx
+++ b/docs/getting-started/upgrading/from-mcp-sdk.mdx
@@ -3,7 +3,6 @@ title: Upgrading from the MCP SDK
sidebarTitle: "From MCP SDK"
description: Upgrade from FastMCP in the MCP Python SDK to the standalone FastMCP framework
icon: up
-tag: NEW
---
If your server starts with `from mcp.server.fastmcp import FastMCP`, you're using FastMCP 1.0 β the version bundled with v1 of the `mcp` package. Upgrading to the standalone FastMCP framework is easy. **For most servers, it's a single import change.**
diff --git a/docs/integrations/propelauth.mdx b/docs/integrations/propelauth.mdx
new file mode 100644
index 000000000..7f21d2010
--- /dev/null
+++ b/docs/integrations/propelauth.mdx
@@ -0,0 +1,164 @@
+---
+title: PropelAuth π€ FastMCP
+sidebarTitle: PropelAuth
+description: Secure your FastMCP server with PropelAuth
+icon: shield-check
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx";
+
+
+
+This guide shows you how to secure your FastMCP server using [**PropelAuth**](https://www.propelauth.com), a complete authentication and user management solution. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where PropelAuth handles user login, consent management, and your FastMCP server validates the tokens.
+
+## Configuration
+
+### Prerequisites
+
+Before you begin, you will need:
+
+1. A [PropelAuth](https://www.propelauth.com) account
+2. Your FastMCP server's base URL (can be localhost for development, e.g., `http://localhost:8000`)
+
+### Step 1: Configure PropelAuth
+
+
+
+ Navigate to the **MCP** section in your PropelAuth dashboard, click **Enable MCP**, and choose which environments to enable it for (Test, Staging, Prod).
+
+
+
+ Under **MCP > Allowed MCP Clients**, add redirect URIs for each MCP client you want to allow. PropelAuth provides templates for popular clients like Claude, Cursor, and ChatGPT.
+
+
+
+ Under **MCP > Scopes**, define the permissions available to MCP clients (e.g., `read:user_data`).
+
+
+
+ Under **MCP > Settings > How Do Users Create OAuth Clients?**, you can optionally enable:
+ - **Dynamic Client Registration** β clients self-register automatically via the DCR protocol
+ - **Manually via Hosted Pages** β PropelAuth creates a UI for your users to register OAuth clients
+
+ You can enable neither, one, or both. If you enable neither, you'll manage OAuth client creation yourself.
+
+
+
+ Go to **MCP > Request Validation** and click **Create Credentials**. Note the **Client ID** and **Client Secret** - you'll need these to validate tokens.
+
+
+
+ Find your Auth URL in the **Backend Integration** section of the dashboard (e.g., `https://auth.yourdomain.com`).
+
+
+
+For more details, see the [PropelAuth MCP documentation](https://docs.propelauth.com/mcp-authentication/overview).
+
+### Step 2: Environment Setup
+
+Create a `.env` file with your PropelAuth configuration:
+
+```bash
+PROPELAUTH_AUTH_URL=https://auth.yourdomain.com # From Backend Integration page
+PROPELAUTH_INTROSPECTION_CLIENT_ID=your-client-id # From MCP > Request Validation
+PROPELAUTH_INTROSPECTION_CLIENT_SECRET=your-client-secret # From MCP > Request Validation
+SERVER_URL=http://localhost:8000 # Your server's base URL
+```
+
+### Step 3: FastMCP Configuration
+
+Create your FastMCP server file and use the PropelAuthProvider to handle all the OAuth integration automatically:
+
+```python server.py
+import os
+from fastmcp import FastMCP
+from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
+
+auth_provider = PropelAuthProvider(
+ auth_url=os.environ["PROPELAUTH_AUTH_URL"],
+ introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
+ introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
+ base_url=os.environ["SERVER_URL"],
+ required_scopes=["read:user_data"], # Optional scope enforcement
+)
+
+mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth_provider)
+```
+
+## Testing
+
+With your `.env` loaded, start the server:
+
+```bash
+fastmcp run server.py --transport http --port 8000
+```
+
+Then use a FastMCP client to verify authentication works:
+
+```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())
+```
+
+## Accessing User Information
+
+You can use `get_access_token()` inside your tools to identify the authenticated user:
+
+```python server.py
+import os
+from fastmcp import FastMCP
+from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
+from fastmcp.server.dependencies import get_access_token
+
+auth = PropelAuthProvider(
+ auth_url=os.environ["PROPELAUTH_AUTH_URL"],
+ introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
+ introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
+ base_url=os.environ["SERVER_URL"],
+ required_scopes=["read:user_data"],
+)
+
+mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth)
+
+@mcp.tool
+def whoami() -> dict:
+ """Return the authenticated user's ID."""
+ token = get_access_token()
+ if token is None:
+ return {"error": "Not authenticated"}
+ user_id = token.claims.get("sub")
+ return {"user_id": user_id}
+```
+
+## Advanced Configuration
+
+The `PropelAuthProvider` supports optional overrides for token introspection behavior, including caching and request timeouts:
+
+```python server.py
+import os
+from fastmcp import FastMCP
+from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
+
+auth = PropelAuthProvider(
+ auth_url=os.environ["PROPELAUTH_AUTH_URL"],
+ introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
+ introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
+ base_url=os.environ.get("BASE_URL", "https://your-server.com"),
+ required_scopes=["read:user_data"],
+ resource="https://your-server.com/mcp", # Restrict to tokens intended for this server (RFC 8707)
+ token_introspection_overrides={
+ "cache_ttl_seconds": 300, # Cache introspection results for 5 minutes
+ "max_cache_size": 1000, # Maximum cached tokens
+ "timeout_seconds": 15, # HTTP request timeout
+ },
+)
+
+mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth)
+```
diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx
index 4b7054fcf..60804a298 100644
--- a/docs/python-sdk/fastmcp-cli-cli.mdx
+++ b/docs/python-sdk/fastmcp-cli-cli.mdx
@@ -50,7 +50,7 @@ Run an MCP server with the MCP Inspector for development.
- `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json
-### `run`
+### `run`
```python
run(server_spec: str | None = None, *server_args: str) -> None
@@ -66,6 +66,7 @@ The server can be specified in several ways:
4. MCPConfig file: "mcp.json" - runs as a proxy server for the MCP Servers in the MCPConfig file
5. FastMCP config: "fastmcp.json" - runs server using FastMCP configuration
6. No argument: looks for fastmcp.json in current directory
+7. Module mode: "-m my_module" - runs the module directly via python -m
Server arguments can be passed after -- :
fastmcp run server.py -- --config config.json --debug
@@ -74,7 +75,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 +106,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-client.mdx b/docs/python-sdk/fastmcp-cli-client.mdx
index 0351b627e..726663bfb 100644
--- a/docs/python-sdk/fastmcp-cli-client.mdx
+++ b/docs/python-sdk/fastmcp-cli-client.mdx
@@ -10,7 +10,7 @@ Client-side CLI commands for querying and invoking MCP servers.
## Functions
-### `resolve_server_spec`
+### `resolve_server_spec`
```python
resolve_server_spec(server_spec: str | None) -> str | dict[str, Any] | ClientTransport
@@ -32,7 +32,7 @@ When ``command`` is provided, the string is shell-split into a
``StdioTransport(command, args)``.
-### `coerce_value`
+### `coerce_value`
```python
coerce_value(raw: str, schema: dict[str, Any]) -> Any
@@ -42,7 +42,7 @@ coerce_value(raw: str, schema: dict[str, Any]) -> Any
Coerce a string CLI value according to a JSON-Schema type hint.
-### `parse_tool_arguments`
+### `parse_tool_arguments`
```python
parse_tool_arguments(raw_args: tuple[str, ...], input_json: str | None, input_schema: dict[str, Any]) -> dict[str, Any]
@@ -56,7 +56,7 @@ A single JSON object argument is treated as the full argument dict.
Values are coerced using the tool's ``inputSchema``.
-### `format_tool_signature`
+### `format_tool_signature`
```python
format_tool_signature(tool: mcp.types.Tool) -> str
@@ -66,7 +66,7 @@ format_tool_signature(tool: mcp.types.Tool) -> str
Build ``name(param: type, ...) -> return_type`` from a tool's JSON schemas.
-### `list_command`
+### `list_command`
```python
list_command(server_spec: Annotated[str | None, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, or .js file')] = None) -> None
@@ -84,7 +84,7 @@ fastmcp list --command 'npx -y @mcp/server' --resources
fastmcp list http://server/mcp --transport sse
-### `call_command`
+### `call_command`
```python
call_command(server_spec: Annotated[str | None, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, or .js file')] = None, target: Annotated[str, cyclopts.Parameter(help='Tool name, resource URI, or prompt name (with --prompt)')] = '', *arguments: str) -> None
@@ -110,7 +110,7 @@ fastmcp call http://server/mcp create --input-json '{"tags": ["a","b"]}'
```
-### `discover_command`
+### `discover_command`
```python
discover_command() -> None
diff --git a/docs/python-sdk/fastmcp-cli-run.mdx b/docs/python-sdk/fastmcp-cli-run.mdx
index 52df74f4c..0b341c459 100644
--- a/docs/python-sdk/fastmcp-cli-run.mdx
+++ b/docs/python-sdk/fastmcp-cli-run.mdx
@@ -10,7 +10,7 @@ FastMCP run command implementation with enhanced type hints.
## Functions
-### `is_url`
+### `is_url`
```python
is_url(path: str) -> bool
@@ -20,7 +20,7 @@ is_url(path: str) -> bool
Check if a string is a URL.
-### `create_client_server`
+### `create_client_server`
```python
create_client_server(url: str) -> Any
@@ -36,7 +36,7 @@ Create a FastMCP server from a client URL.
- A FastMCP server instance
-### `create_mcp_config_server`
+### `create_mcp_config_server`
```python
create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None]
@@ -46,7 +46,7 @@ create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None]
Create a FastMCP server from a MCPConfig.
-### `load_mcp_server_config`
+### `load_mcp_server_config`
```python
load_mcp_server_config(config_path: Path) -> MCPServerConfig
@@ -62,7 +62,7 @@ Load a FastMCP configuration from a fastmcp.json file.
- MCPServerConfig object
-### `run_command`
+### `run_command`
```python
run_command(server_spec: str, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False, skip_source: bool = False, stateless: bool = False) -> None
@@ -85,7 +85,26 @@ Run a MCP server or connect to a remote one.
- `stateless`: Whether to run in stateless mode (no session)
-### `run_v1_server_async`
+### `run_module_command`
+
+```python
+run_module_command(module_name: str) -> None
+```
+
+
+Run a Python module directly using ``python -m ``.
+
+When ``-m`` is used, the module manages its own server startup.
+No server-object discovery or transport overrides are applied.
+
+**Args:**
+- `module_name`: Dotted module name (e.g. ``my_package``).
+- `env_command_builder`: An optional callable that wraps a command list
+with environment setup (e.g. ``UVEnvironment.build_command``).
+- `extra_args`: Extra arguments forwarded after the module name.
+
+
+### `run_v1_server_async`
```python
run_v1_server_async(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None
@@ -101,7 +120,7 @@ Run a FastMCP 1.x server using async methods.
- `transport`: Transport protocol to use
-### `run_with_reload`
+### `run_with_reload`
```python
run_with_reload(cmd: list[str], reload_dirs: list[Path] | None = None, is_stdio: bool = False) -> None
diff --git a/docs/python-sdk/fastmcp-client-client.mdx b/docs/python-sdk/fastmcp-client-client.mdx
index 7b72fa3f7..6c3ac689a 100644
--- a/docs/python-sdk/fastmcp-client-client.mdx
+++ b/docs/python-sdk/fastmcp-client-client.mdx
@@ -85,7 +85,7 @@ async with client:
**Methods:**
-#### `session`
+#### `session`
```python
session(self) -> ClientSession
@@ -94,7 +94,7 @@ session(self) -> ClientSession
Get the current active session. Raises RuntimeError if not connected.
-#### `initialize_result`
+#### `initialize_result`
```python
initialize_result(self) -> mcp.types.InitializeResult | None
@@ -103,7 +103,7 @@ initialize_result(self) -> mcp.types.InitializeResult | None
Get the result of the initialization request.
-#### `set_roots`
+#### `set_roots`
```python
set_roots(self, roots: RootsList | RootsHandler) -> None
@@ -112,7 +112,7 @@ set_roots(self, roots: RootsList | RootsHandler) -> None
Set the roots for the client. This does not automatically call `send_roots_list_changed`.
-#### `set_sampling_callback`
+#### `set_sampling_callback`
```python
set_sampling_callback(self, sampling_callback: SamplingHandler, sampling_capabilities: mcp.types.SamplingCapability | None = None) -> None
@@ -121,7 +121,7 @@ set_sampling_callback(self, sampling_callback: SamplingHandler, sampling_capabil
Set the sampling callback for the client.
-#### `set_elicitation_callback`
+#### `set_elicitation_callback`
```python
set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None
@@ -130,7 +130,7 @@ set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None
Set the elicitation callback for the client.
-#### `is_connected`
+#### `is_connected`
```python
is_connected(self) -> bool
@@ -139,7 +139,7 @@ is_connected(self) -> bool
Check if the client is currently connected.
-#### `new`
+#### `new`
```python
new(self) -> Client[ClientTransportT]
@@ -155,7 +155,7 @@ share state with the original client.
- A new Client instance with the same configuration but disconnected state.
-#### `initialize`
+#### `initialize`
```python
initialize(self, timeout: datetime.timedelta | float | int | None = None) -> mcp.types.InitializeResult
@@ -183,13 +183,13 @@ capabilities, protocol version, and optional instructions.
- `RuntimeError`: If the client is not connected or initialization times out.
-#### `close`
+#### `close`
```python
close(self)
```
-#### `ping`
+#### `ping`
```python
ping(self) -> bool
@@ -198,7 +198,7 @@ ping(self) -> bool
Send a ping request.
-#### `cancel`
+#### `cancel`
```python
cancel(self, request_id: str | int, reason: str | None = None) -> None
@@ -207,7 +207,7 @@ cancel(self, request_id: str | int, reason: str | None = None) -> None
Send a cancellation notification for an in-progress request.
-#### `progress`
+#### `progress`
```python
progress(self, progress_token: str | int, progress: float, total: float | None = None, message: str | None = None) -> None
@@ -216,7 +216,7 @@ progress(self, progress_token: str | int, progress: float, total: float | None =
Send a progress notification.
-#### `set_logging_level`
+#### `set_logging_level`
```python
set_logging_level(self, level: mcp.types.LoggingLevel) -> None
@@ -225,7 +225,7 @@ set_logging_level(self, level: mcp.types.LoggingLevel) -> None
Send a logging/setLevel request.
-#### `send_roots_list_changed`
+#### `send_roots_list_changed`
```python
send_roots_list_changed(self) -> None
@@ -234,7 +234,7 @@ send_roots_list_changed(self) -> None
Send a roots/list_changed notification.
-#### `complete_mcp`
+#### `complete_mcp`
```python
complete_mcp(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None) -> mcp.types.CompleteResult
@@ -257,7 +257,7 @@ containing the completion and any additional metadata.
- `McpError`: If the request results in a TimeoutError | JSONRPCError
-#### `complete`
+#### `complete`
```python
complete(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None) -> mcp.types.Completion
@@ -279,7 +279,7 @@ include with the completion request. Defaults to None.
- `McpError`: If the request results in a TimeoutError | JSONRPCError
-#### `generate_name`
+#### `generate_name`
```python
generate_name(cls, name: str | None = None) -> str
diff --git a/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx b/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx
new file mode 100644
index 000000000..69d71fe3a
--- /dev/null
+++ b/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx
@@ -0,0 +1,17 @@
+---
+title: google_genai
+sidebarTitle: google_genai
+---
+
+# `fastmcp.client.sampling.handlers.google_genai`
+
+
+Google GenAI sampling handler with tool support for FastMCP 3.0.
+
+## Classes
+
+### `GoogleGenaiSamplingHandler`
+
+
+Sampling handler that uses the Google GenAI API with tool support.
+
diff --git a/docs/python-sdk/fastmcp-client-transports-config.mdx b/docs/python-sdk/fastmcp-client-transports-config.mdx
index 09f03a3d5..7ad10e0df 100644
--- a/docs/python-sdk/fastmcp-client-transports-config.mdx
+++ b/docs/python-sdk/fastmcp-client-transports-config.mdx
@@ -65,7 +65,7 @@ async with client:
connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession]
```
-#### `close`
+#### `close`
```python
close(self)
diff --git a/docs/python-sdk/fastmcp-dependencies.mdx b/docs/python-sdk/fastmcp-dependencies.mdx
index e803029c3..f27566ef3 100644
--- a/docs/python-sdk/fastmcp-dependencies.mdx
+++ b/docs/python-sdk/fastmcp-dependencies.mdx
@@ -8,11 +8,10 @@ sidebarTitle: dependencies
Dependency injection exports for FastMCP.
-This module re-exports dependency injection symbols from Docket and FastMCP
-to provide a clean, centralized import location for all dependency-related
-functionality.
+This module re-exports dependency injection symbols to provide a clean,
+centralized import location for all dependency-related functionality.
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
-using a vendored DI engine. Only task-related dependencies (CurrentDocket,
+using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket,
CurrentWorker) and background task execution require fastmcp[tasks].
diff --git a/docs/python-sdk/fastmcp-experimental-__init__.mdx b/docs/python-sdk/fastmcp-experimental-__init__.mdx
new file mode 100644
index 000000000..04ef31f33
--- /dev/null
+++ b/docs/python-sdk/fastmcp-experimental-__init__.mdx
@@ -0,0 +1,8 @@
+---
+title: __init__
+sidebarTitle: __init__
+---
+
+# `fastmcp.experimental`
+
+*This module is empty or contains only private/internal implementations.*
diff --git a/docs/python-sdk/fastmcp-experimental-sampling-__init__.mdx b/docs/python-sdk/fastmcp-experimental-sampling-__init__.mdx
new file mode 100644
index 000000000..f37691267
--- /dev/null
+++ b/docs/python-sdk/fastmcp-experimental-sampling-__init__.mdx
@@ -0,0 +1,8 @@
+---
+title: __init__
+sidebarTitle: __init__
+---
+
+# `fastmcp.experimental.sampling`
+
+*This module is empty or contains only private/internal implementations.*
diff --git a/docs/python-sdk/fastmcp-experimental-sampling-handlers.mdx b/docs/python-sdk/fastmcp-experimental-sampling-handlers.mdx
new file mode 100644
index 000000000..9220684bb
--- /dev/null
+++ b/docs/python-sdk/fastmcp-experimental-sampling-handlers.mdx
@@ -0,0 +1,8 @@
+---
+title: handlers
+sidebarTitle: handlers
+---
+
+# `fastmcp.experimental.sampling.handlers`
+
+*This module is empty or contains only private/internal implementations.*
diff --git a/docs/python-sdk/fastmcp-experimental-transforms-__init__.mdx b/docs/python-sdk/fastmcp-experimental-transforms-__init__.mdx
new file mode 100644
index 000000000..a33a00679
--- /dev/null
+++ b/docs/python-sdk/fastmcp-experimental-transforms-__init__.mdx
@@ -0,0 +1,8 @@
+---
+title: __init__
+sidebarTitle: __init__
+---
+
+# `fastmcp.experimental.transforms`
+
+*This module is empty or contains only private/internal implementations.*
diff --git a/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx b/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx
new file mode 100644
index 000000000..6ef971e8e
--- /dev/null
+++ b/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx
@@ -0,0 +1,121 @@
+---
+title: code_mode
+sidebarTitle: code_mode
+---
+
+# `fastmcp.experimental.transforms.code_mode`
+
+## Classes
+
+### `SandboxProvider`
+
+
+Interface for executing LLM-generated Python code in a sandbox.
+
+WARNING: The ``code`` parameter passed to ``run`` contains untrusted,
+LLM-generated Python. Implementations MUST execute it in an isolated
+sandbox β never with plain ``exec()``. Use ``MontySandboxProvider``
+(backed by ``pydantic-monty``) for production workloads.
+
+
+**Methods:**
+
+#### `run`
+
+```python
+run(self, code: str) -> Any
+```
+
+### `MontySandboxProvider`
+
+
+Sandbox provider backed by `pydantic-monty`.
+
+**Args:**
+- `limits`: Resource limits for sandbox execution. Supported keys\:
+``max_duration_secs`` (float), ``max_allocations`` (int),
+``max_memory`` (int), ``max_recursion_depth`` (int),
+``gc_interval`` (int). All are optional; omit a key to
+leave that limit uncapped.
+
+
+**Methods:**
+
+#### `run`
+
+```python
+run(self, code: str) -> Any
+```
+
+### `Search`
+
+
+Discovery tool factory that searches the catalog by query.
+
+**Args:**
+- `search_fn`: Async callable ``(tools, query) -> matching_tools``.
+Defaults to BM25 ranking.
+- `name`: Name of the synthetic tool exposed to the LLM.
+- `default_detail`: Default detail level for search results.
+``"brief"`` returns tool names and descriptions only.
+``"detailed"`` returns compact markdown with parameter schemas.
+``"full"`` returns complete JSON tool definitions.
+
+
+### `GetSchemas`
+
+
+Discovery tool factory that returns schemas for tools by name.
+
+**Args:**
+- `name`: Name of the synthetic tool exposed to the LLM.
+- `default_detail`: Default detail level for schema results.
+``"brief"`` returns tool names and descriptions only.
+``"detailed"`` renders compact markdown with parameter names,
+types, and required markers.
+``"full"`` returns the complete JSON schema.
+
+
+### `GetTags`
+
+
+Discovery tool factory that lists tool tags from the catalog.
+
+Reads ``tool.tags`` from the catalog and groups tools by tag. Tools
+without tags appear under ``"untagged"``.
+
+**Args:**
+- `name`: Name of the synthetic tool exposed to the LLM.
+- `default_detail`: Default detail level.
+``"brief"`` returns tag names with tool counts.
+``"full"`` lists all tools under each tag.
+
+
+### `CodeMode`
+
+
+Transform that collapses all tools into discovery + execute meta-tools.
+
+Discovery tools are composable via the ``discovery_tools`` parameter.
+Each is a callable that receives catalog access and returns a ``Tool``.
+By default, ``Search`` and ``GetSchemas`` are included for
+progressive disclosure: search finds candidates, get_schema retrieves
+parameter details, and execute runs code.
+
+The ``execute`` tool is always present and provides a sandboxed Python
+environment with ``call_tool(name, params)`` in scope.
+
+
+**Methods:**
+
+#### `transform_tools`
+
+```python
+transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]
+```
+
+#### `get_tool`
+
+```python
+get_tool(self, name: str, call_next: GetToolNext) -> Tool | None
+```
diff --git a/docs/python-sdk/fastmcp-resources-types.mdx b/docs/python-sdk/fastmcp-resources-types.mdx
index 01f866ab8..d19b24eb2 100644
--- a/docs/python-sdk/fastmcp-resources-types.mdx
+++ b/docs/python-sdk/fastmcp-resources-types.mdx
@@ -27,7 +27,7 @@ read(self) -> ResourceResult
Read the text content.
-### `BinaryResource`
+### `BinaryResource`
A resource that reads from bytes.
@@ -35,7 +35,7 @@ A resource that reads from bytes.
**Methods:**
-#### `read`
+#### `read`
```python
read(self) -> ResourceResult
@@ -44,7 +44,7 @@ read(self) -> ResourceResult
Read the binary content.
-### `FileResource`
+### `FileResource`
A resource that reads from a file.
@@ -54,7 +54,7 @@ Set is_binary=True to read file as binary data instead of text.
**Methods:**
-#### `validate_absolute_path`
+#### `validate_absolute_path`
```python
validate_absolute_path(cls, path: Path) -> Path
@@ -63,7 +63,7 @@ validate_absolute_path(cls, path: Path) -> Path
Ensure path is absolute.
-#### `set_binary_from_mime_type`
+#### `set_binary_from_mime_type`
```python
set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool
@@ -72,7 +72,7 @@ set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool
Set is_binary based on mime_type if not explicitly set.
-#### `read`
+#### `read`
```python
read(self) -> ResourceResult
@@ -81,7 +81,7 @@ read(self) -> ResourceResult
Read the file content.
-### `HttpResource`
+### `HttpResource`
A resource that reads from an HTTP endpoint.
@@ -89,7 +89,7 @@ A resource that reads from an HTTP endpoint.
**Methods:**
-#### `read`
+#### `read`
```python
read(self) -> ResourceResult
@@ -98,7 +98,7 @@ read(self) -> ResourceResult
Read the HTTP content.
-### `DirectoryResource`
+### `DirectoryResource`
A resource that lists files in a directory.
@@ -106,7 +106,7 @@ A resource that lists files in a directory.
**Methods:**
-#### `validate_absolute_path`
+#### `validate_absolute_path`
```python
validate_absolute_path(cls, path: Path) -> Path
@@ -115,7 +115,7 @@ validate_absolute_path(cls, path: Path) -> Path
Ensure path is absolute.
-#### `list_files`
+#### `list_files`
```python
list_files(self) -> list[Path]
@@ -124,7 +124,7 @@ list_files(self) -> list[Path]
List files in the directory.
-#### `read`
+#### `read`
```python
read(self) -> ResourceResult
diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx
index d2a0728aa..2186df875 100644
--- a/docs/python-sdk/fastmcp-server-auth-auth.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-auth.mdx
@@ -254,7 +254,66 @@ Get routes for this provider.
Creates protected resource metadata routes (RFC 9728).
-### `OAuthProvider`
+### `MultiAuth`
+
+
+Composes an optional auth server with additional token verifiers.
+
+Use this when a single server needs to accept tokens from multiple sources.
+For example, an OAuth proxy for interactive clients combined with a JWT
+verifier for machine-to-machine tokens.
+
+Token verification tries the server first (if present), then each verifier
+in order, returning the first successful result. Routes and OAuth metadata
+come from the server; verifiers contribute only token verification.
+
+
+**Methods:**
+
+#### `verify_token`
+
+```python
+verify_token(self, token: str) -> AccessToken | None
+```
+
+Verify a token by trying the server, then each verifier in order.
+
+Each source is tried independently. If a source raises an exception,
+it is logged and treated as a non-match so that remaining sources
+still get a chance to verify the token.
+
+
+#### `set_mcp_path`
+
+```python
+set_mcp_path(self, mcp_path: str | None) -> None
+```
+
+Propagate MCP path to the server and all verifiers.
+
+
+#### `get_routes`
+
+```python
+get_routes(self, mcp_path: str | None = None) -> list[Route]
+```
+
+Delegate route creation to the server.
+
+
+#### `get_well_known_routes`
+
+```python
+get_well_known_routes(self, mcp_path: str | None = None) -> list[Route]
+```
+
+Delegate well-known route creation to the server.
+
+This ensures that server-specific well-known route logic (e.g.,
+OAuthProvider's RFC 8414 path-aware discovery) is preserved.
+
+
+### `OAuthProvider`
OAuth Authorization Server provider.
@@ -265,7 +324,7 @@ authorization flows, token issuance, and token verification.
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -283,7 +342,7 @@ to our existing load_access_token method.
- AccessToken object if valid, None if invalid or expired
-#### `get_routes`
+#### `get_routes`
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]
@@ -299,7 +358,7 @@ This method creates the full set of OAuth routes including:
- List of OAuth routes
-#### `get_well_known_routes`
+#### `get_well_known_routes`
```python
get_well_known_routes(self, mcp_path: str | None = None) -> list[Route]
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx
index 7c9a982e0..3a5ca0868 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx
@@ -14,7 +14,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows.
## Functions
-### `EntraOBOToken`
+### `EntraOBOToken`
```python
EntraOBOToken(scopes: list[str]) -> str
@@ -43,7 +43,7 @@ or OBO exchange fails
## Classes
-### `AzureProvider`
+### `AzureProvider`
Azure (Microsoft Entra) OAuth provider for FastMCP.
@@ -78,7 +78,7 @@ Setup:
**Methods:**
-#### `authorize`
+#### `authorize`
```python
authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str
@@ -98,7 +98,7 @@ scopes to determine the resource/audience instead of a separate parameter.
- Authorization URL to redirect the user to Azure AD
-#### `get_obo_credential`
+#### `get_obo_credential`
```python
get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential
@@ -120,7 +120,7 @@ calls multiple tools with the same scopes.
- `ImportError`: If azure-identity is not installed (requires fastmcp[azure]).
-#### `close_obo_credentials`
+#### `close_obo_credentials`
```python
close_obo_credentials(self) -> None
@@ -129,7 +129,7 @@ close_obo_credentials(self) -> None
Close all cached OBO credentials.
-### `AzureJWTVerifier`
+### `AzureJWTVerifier`
JWT verifier pre-configured for Azure AD / Microsoft Entra ID.
@@ -166,7 +166,7 @@ Example::
**Methods:**
-#### `scopes_supported`
+#### `scopes_supported`
```python
scopes_supported(self) -> list[str]
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx b/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx
index 7182fafdf..61b024b63 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx
@@ -29,7 +29,7 @@ Example:
## Classes
-### `DiscordTokenVerifier`
+### `DiscordTokenVerifier`
Token verifier for Discord OAuth tokens.
@@ -40,7 +40,7 @@ by calling Discord'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
@@ -49,7 +49,7 @@ verify_token(self, token: str) -> AccessToken | None
Verify Discord OAuth token by calling Discord's tokeninfo API.
-### `DiscordProvider`
+### `DiscordProvider`
Complete Discord OAuth provider for FastMCP.
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx
index 2cf69a6f0..66a808136 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx
@@ -29,7 +29,7 @@ Example:
## Classes
-### `GitHubTokenVerifier`
+### `GitHubTokenVerifier`
Token verifier for GitHub OAuth tokens.
@@ -40,7 +40,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
@@ -49,7 +49,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 d1aedfe7e..880488438 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx
@@ -29,7 +29,7 @@ Example:
## Classes
-### `GoogleTokenVerifier`
+### `GoogleTokenVerifier`
Token verifier for Google OAuth tokens.
@@ -40,7 +40,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
@@ -49,7 +49,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-introspection.mdx b/docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx
index 983c70d96..811737e34 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx
@@ -31,7 +31,7 @@ Example:
## Classes
-### `IntrospectionTokenVerifier`
+### `IntrospectionTokenVerifier`
OAuth 2.0 Token Introspection verifier (RFC 7662).
@@ -52,10 +52,14 @@ Use this when:
- Your tokens require real-time revocation checking
- Your authorization server supports RFC 7662 introspection
+Caching is disabled by default to preserve real-time revocation semantics.
+Set ``cache_ttl_seconds`` to enable caching and reduce load on the
+introspection endpoint (e.g., ``cache_ttl_seconds=300`` for 5 minutes).
+
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -67,6 +71,9 @@ This method makes a POST request to the introspection endpoint with the token,
authenticated using the configured client authentication method (client_secret_basic
or client_secret_post).
+Results are cached in-memory to reduce load on the introspection endpoint.
+Cache TTL and size are configurable via constructor parameters.
+
**Args:**
- `token`: The opaque token string to validate
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx
index febc5e795..6ba9054c2 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx
@@ -10,19 +10,19 @@ TokenVerifier implementations for FastMCP.
## Classes
-### `JWKData`
+### `JWKData`
JSON Web Key data structure.
-### `JWKSData`
+### `JWKSData`
JSON Web Key Set data structure.
-### `RSAKeyPair`
+### `RSAKeyPair`
RSA key pair for JWT testing.
@@ -30,7 +30,7 @@ RSA key pair for JWT testing.
**Methods:**
-#### `generate`
+#### `generate`
```python
generate(cls) -> RSAKeyPair
@@ -42,7 +42,7 @@ Generate an RSA key pair for testing.
- Generated key pair
-#### `create_token`
+#### `create_token`
```python
create_token(self, subject: str = 'fastmcp-user', issuer: str = 'https://fastmcp.example.com', audience: str | list[str] | None = None, scopes: list[str] | None = None, expires_in_seconds: int = 3600, additional_claims: dict[str, Any] | None = None, kid: str | None = None) -> str
@@ -60,7 +60,7 @@ Generate a test JWT token for testing purposes.
- `kid`: Key ID to include in header
-### `JWTVerifier`
+### `JWTVerifier`
JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms.
@@ -82,7 +82,7 @@ Use this when:
**Methods:**
-#### `load_access_token`
+#### `load_access_token`
```python
load_access_token(self, token: str) -> AccessToken | None
@@ -97,7 +97,7 @@ Validate a JWT bearer token and return an AccessToken when the token is valid.
- AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs.
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -115,7 +115,7 @@ to our existing load_access_token method.
- AccessToken object if valid, None if invalid or expired
-### `StaticTokenVerifier`
+### `StaticTokenVerifier`
Simple static token verifier for testing and development.
@@ -136,7 +136,7 @@ WARNING: Never use this in production - tokens are stored in plain text!
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-propelauth.mdx b/docs/python-sdk/fastmcp-server-auth-providers-propelauth.mdx
new file mode 100644
index 000000000..3b31b00d8
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-auth-providers-propelauth.mdx
@@ -0,0 +1,69 @@
+---
+title: propelauth
+sidebarTitle: propelauth
+---
+
+# `fastmcp.server.auth.providers.propelauth`
+
+
+PropelAuth authentication provider for FastMCP.
+
+Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
+
+ auth = PropelAuthProvider(
+ auth_url="https://auth.yourdomain.com",
+ introspection_client_id="your-client-id",
+ introspection_client_secret="your-client-secret",
+ base_url="https://your-fastmcp-server.com",
+ required_scopes=["read:user_data"],
+ )
+
+ mcp = FastMCP("My App", auth=auth)
+ ```
+
+
+## Classes
+
+### `PropelAuthTokenIntrospectionOverrides`
+
+### `PropelAuthProvider`
+
+
+PropelAuth resource server provider using OAuth 2.1 token introspection.
+
+This provider validates access tokens via PropelAuth's introspection endpoint
+and forwards authorization server metadata for OAuth discovery.
+
+For detailed setup instructions, see:
+https://docs.propelauth.com/mcp-authentication/overview
+
+
+**Methods:**
+
+#### `get_routes`
+
+```python
+get_routes(self, mcp_path: str | None = None) -> list[Route]
+```
+
+Get routes for this provider.
+
+Includes the standard routes from the RemoteAuthProvider (protected resource metadata routes (RFC 9728)),
+and creates an authorization server metadata route that forwards to PropelAuth's route
+
+**Args:**
+- `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp")
+This is used to advertise the resource URL in metadata.
+
+
+#### `verify_token`
+
+```python
+verify_token(self, token: str) -> AccessToken | None
+```
+
+Verify token and check the ``aud`` claim against the configured resource.
+
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx
index 72b54d6df..c54e93028 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx
@@ -18,7 +18,7 @@ Choose based on your WorkOS setup and authentication requirements.
## Classes
-### `WorkOSTokenVerifier`
+### `WorkOSTokenVerifier`
Token verifier for WorkOS OAuth tokens.
@@ -29,7 +29,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
@@ -38,7 +38,7 @@ verify_token(self, token: str) -> AccessToken | None
Verify WorkOS OAuth token by calling userinfo endpoint.
-### `WorkOSProvider`
+### `WorkOSProvider`
Complete WorkOS OAuth provider for FastMCP.
@@ -59,7 +59,7 @@ Setup Requirements:
4. Note your Client ID and Client Secret
-### `AuthKitProvider`
+### `AuthKitProvider`
AuthKit metadata provider for DCR (Dynamic Client Registration).
@@ -85,7 +85,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification
**Methods:**
-#### `get_routes`
+#### `get_routes`
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]
diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx
index 74b8fb40b..58a916faf 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_transport`
+### `set_transport`
```python
set_transport(transport: TransportType) -> Token[TransportType | None]
@@ -17,7 +17,7 @@ set_transport(transport: TransportType) -> Token[TransportType | None]
Set the current transport type. Returns token for reset.
-### `reset_transport`
+### `reset_transport`
```python
reset_transport(token: Token[TransportType | None]) -> None
@@ -27,7 +27,7 @@ reset_transport(token: Token[TransportType | None]) -> None
Reset transport to previous value.
-### `set_context`
+### `set_context`
```python
set_context(context: Context) -> Generator[Context, None, None]
@@ -35,7 +35,7 @@ set_context(context: Context) -> Generator[Context, None, None]
## Classes
-### `LogData`
+### `LogData`
Data object for passing log arguments to client-side handlers.
@@ -44,7 +44,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.
@@ -99,7 +99,7 @@ The context is optional - tools that don't need it can omit the parameter.
**Methods:**
-#### `is_background_task`
+#### `is_background_task`
```python
is_background_task(self) -> bool
@@ -112,7 +112,7 @@ task-aware implementations that can pause the task and wait for
client input.
-#### `task_id`
+#### `task_id`
```python
task_id(self) -> str | None
@@ -123,7 +123,20 @@ Get the background task ID if running in a background task.
Returns None if not running in a background task context.
-#### `fastmcp`
+#### `origin_request_id`
+
+```python
+origin_request_id(self) -> str | None
+```
+
+Get the request ID that originated this execution, if available.
+
+In foreground request mode, this is the current request_id.
+In background task mode, this is the request_id captured when the task
+was submitted, if one was available.
+
+
+#### `fastmcp`
```python
fastmcp(self) -> FastMCP
@@ -132,7 +145,7 @@ fastmcp(self) -> FastMCP
Get the FastMCP instance.
-#### `request_context`
+#### `request_context`
```python
request_context(self) -> RequestContext[ServerSession, Any, Request] | None
@@ -161,7 +174,7 @@ async def on_request(self, context, call_next):
```
-#### `lifespan_context`
+#### `lifespan_context`
```python
lifespan_context(self) -> dict[str, Any]
@@ -188,7 +201,7 @@ def my_tool(ctx: Context) -> str:
```
-#### `report_progress`
+#### `report_progress`
```python
report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None
@@ -205,7 +218,7 @@ Works in both foreground (MCP progress notifications) and background
- `message`: Optional status message describing current progress
-#### `list_resources`
+#### `list_resources`
```python
list_resources(self) -> list[SDKResource]
@@ -217,7 +230,7 @@ List all available resources from the server.
- List of Resource objects available on the server
-#### `list_prompts`
+#### `list_prompts`
```python
list_prompts(self) -> list[SDKPrompt]
@@ -229,7 +242,7 @@ List all available prompts from the server.
- List of Prompt objects available on the server
-#### `get_prompt`
+#### `get_prompt`
```python
get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult
@@ -245,7 +258,7 @@ Get a prompt by name with optional arguments.
- The prompt result
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self, uri: str | AnyUrl) -> ResourceResult
@@ -260,7 +273,7 @@ Read a resource by URI.
- ResourceResult with contents
-#### `log`
+#### `log`
```python
log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -278,7 +291,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien
- `extra`: Optional mapping for additional arguments
-#### `transport`
+#### `transport`
```python
transport(self) -> TransportType | None
@@ -290,7 +303,7 @@ Returns the transport type used to run this server: "stdio", "sse",
or "streamable-http". Returns None if called outside of a server context.
-#### `client_supports_extension`
+#### `client_supports_extension`
```python
client_supports_extension(self, extension_id: str) -> bool
@@ -315,7 +328,7 @@ Example::
return "text-only client"
-#### `client_id`
+#### `client_id`
```python
client_id(self) -> str | None
@@ -324,7 +337,7 @@ client_id(self) -> str | None
Get the client ID if available.
-#### `request_id`
+#### `request_id`
```python
request_id(self) -> str
@@ -335,7 +348,7 @@ Get the unique ID for this request.
Raises RuntimeError if MCP request context is not available.
-#### `session_id`
+#### `session_id`
```python
session_id(self) -> str
@@ -352,7 +365,7 @@ the same client session.
- for other transports.
-#### `session`
+#### `session`
```python
session(self) -> ServerSession
@@ -366,7 +379,7 @@ In background task mode: Returns the session stored at Context creation.
Raises RuntimeError if no session is available.
-#### `debug`
+#### `debug`
```python
debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -377,7 +390,7 @@ Send a `DEBUG`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `info`
+#### `info`
```python
info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -388,7 +401,7 @@ Send a `INFO`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `warning`
+#### `warning`
```python
warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -399,7 +412,7 @@ Send a `WARNING`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `error`
+#### `error`
```python
error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -410,7 +423,7 @@ Send a `ERROR`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `list_roots`
+#### `list_roots`
```python
list_roots(self) -> list[Root]
@@ -419,7 +432,7 @@ list_roots(self) -> list[Root]
List the roots available to the server, as indicated by the client.
-#### `send_notification`
+#### `send_notification`
```python
send_notification(self, notification: mcp.types.ServerNotificationType) -> None
@@ -431,7 +444,7 @@ Send a notification to the client immediately.
- `notification`: An MCP notification instance (e.g., ToolListChangedNotification())
-#### `close_sse_stream`
+#### `close_sse_stream`
```python
close_sse_stream(self) -> None
@@ -449,7 +462,7 @@ Instead of holding a connection open for minutes, you can periodically close
and let the client reconnect.
-#### `sample_step`
+#### `sample_step`
```python
sample_step(self, messages: str | Sequence[str | SamplingMessage]) -> SampleStep
@@ -492,7 +505,7 @@ regardless of this setting.
- - .text: The text content (if any)
-#### `sample`
+#### `sample`
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT]
@@ -501,7 +514,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[
Overload: With result_type, returns SamplingResult[ResultT].
-#### `sample`
+#### `sample`
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str]
@@ -510,7 +523,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[
Overload: Without result_type, returns SamplingResult[str].
-#### `sample`
+#### `sample`
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str]
@@ -558,43 +571,43 @@ regardless of this setting.
- - .history: All messages exchanged during sampling
-#### `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: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
@@ -623,7 +636,7 @@ type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
-#### `set_state`
+#### `set_state`
```python
set_state(self, key: str, value: Any) -> None
@@ -644,7 +657,7 @@ requests.
The key is automatically prefixed with the session identifier.
-#### `get_state`
+#### `get_state`
```python
get_state(self, key: str) -> Any
@@ -658,7 +671,7 @@ then falls back to the session-scoped state store.
Returns None if the key is not found.
-#### `delete_state`
+#### `delete_state`
```python
delete_state(self, key: str) -> None
@@ -669,7 +682,7 @@ Delete a value from the state store.
Removes from both request-scoped and session-scoped stores.
-#### `enable_components`
+#### `enable_components`
```python
enable_components(self) -> None
@@ -693,7 +706,7 @@ ResourceListChangedNotification, and PromptListChangedNotification.
- `match_all`: If True, matches all components regardless of other criteria.
-#### `disable_components`
+#### `disable_components`
```python
disable_components(self) -> None
@@ -717,7 +730,7 @@ ResourceListChangedNotification, and PromptListChangedNotification.
- `match_all`: If True, matches all components regardless of other criteria.
-#### `reset_visibility`
+#### `reset_visibility`
```python
reset_visibility(self) -> None
diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx
index 88a756ebc..60439e182 100644
--- a/docs/python-sdk/fastmcp-server-dependencies.mdx
+++ b/docs/python-sdk/fastmcp-server-dependencies.mdx
@@ -9,13 +9,13 @@ sidebarTitle: dependencies
Dependency injection for FastMCP.
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
-using a vendored DI engine. Only task-related dependencies (CurrentDocket,
+using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket,
CurrentWorker) and background task execution require fastmcp[tasks].
## Functions
-### `get_task_context`
+### `get_task_context`
```python
get_task_context() -> TaskContextInfo | None
@@ -31,7 +31,7 @@ Returns None if not running in a task context (e.g., foreground execution).
- TaskContextInfo with task_id and session_id, or None if not in a task.
-### `register_task_session`
+### `register_task_session`
```python
register_task_session(session_id: str, session: ServerSession) -> None
@@ -49,7 +49,7 @@ client disconnects.
- `session`: The ServerSession instance
-### `get_task_session`
+### `get_task_session`
```python
get_task_session(session_id: str) -> ServerSession | None
@@ -65,7 +65,7 @@ Get a registered session by ID if still alive.
- The ServerSession if found and alive, None otherwise
-### `is_docket_available`
+### `is_docket_available`
```python
is_docket_available() -> bool
@@ -75,7 +75,7 @@ is_docket_available() -> bool
Check if pydocket is installed.
-### `require_docket`
+### `require_docket`
```python
require_docket(feature: str) -> None
@@ -89,7 +89,7 @@ Raise ImportError with install instructions if docket not available.
"CurrentDocket()"). Will be included in the error message.
-### `transform_context_annotations`
+### `transform_context_annotations`
```python
transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]
@@ -115,7 +115,7 @@ allows them to have defaults in any order.
- Function with modified signature (same function object, updated __signature__)
-### `get_context`
+### `get_context`
```python
get_context() -> Context
@@ -125,7 +125,7 @@ get_context() -> Context
Get the current FastMCP Context instance directly.
-### `get_server`
+### `get_server`
```python
get_server() -> FastMCP
@@ -141,7 +141,7 @@ Get the current FastMCP server instance directly.
- `RuntimeError`: If no server in context
-### `get_http_request`
+### `get_http_request`
```python
get_http_request() -> Request
@@ -153,10 +153,10 @@ Get the current HTTP request.
Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context.
-### `get_http_headers`
+### `get_http_headers`
```python
-get_http_headers(include_all: bool = False) -> dict[str, str]
+get_http_headers(include_all: bool = False, include: set[str] | None = None) -> dict[str, str]
```
@@ -165,11 +165,16 @@ Extract headers from the current HTTP request if available.
Never raises an exception, even if there is no active HTTP request (in which case
an empty dict is returned).
-By default, strips problematic headers like `content-length` that cause issues
-if forwarded to downstream clients. If `include_all` is True, all headers are returned.
+By default, strips problematic headers like `content-length` and `authorization`
+that cause issues if forwarded to downstream services. If `include_all` is True,
+all headers are returned.
+
+The `include` parameter allows specific headers to be included even if they would
+normally be excluded. This is useful for proxy transports that need to forward
+authorization headers to upstream MCP servers.
-### `get_access_token`
+### `get_access_token`
```python
get_access_token() -> AccessToken | None
@@ -188,7 +193,7 @@ token snapshot stored in Redis at task submission time.
- The access token if an authenticated user is available, None otherwise.
-### `without_injected_parameters`
+### `without_injected_parameters`
```python
without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]
@@ -213,7 +218,7 @@ Handles:
- Async wrapper function without injected parameters
-### `resolve_dependencies`
+### `resolve_dependencies`
```python
resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None]
@@ -239,7 +244,7 @@ time, so all injection goes through the unified DI system.
which will be filtered out)
-### `CurrentContext`
+### `CurrentContext`
```python
CurrentContext() -> Context
@@ -258,7 +263,17 @@ current MCP operation (tool/resource/prompt call).
- `RuntimeError`: If no active context found (during resolution)
-### `CurrentDocket`
+### `OptionalCurrentContext`
+
+```python
+OptionalCurrentContext() -> Context | None
+```
+
+
+Get the current FastMCP Context, or None when no context is active.
+
+
+### `CurrentDocket`
```python
CurrentDocket() -> Docket
@@ -278,7 +293,7 @@ automatically creates for background task scheduling.
- `ImportError`: If fastmcp[tasks] not installed
-### `CurrentWorker`
+### `CurrentWorker`
```python
CurrentWorker() -> Worker
@@ -298,7 +313,7 @@ automatically creates for background task processing.
- `ImportError`: If fastmcp[tasks] not installed
-### `CurrentFastMCP`
+### `CurrentFastMCP`
```python
CurrentFastMCP() -> FastMCP
@@ -316,7 +331,7 @@ This dependency provides access to the active FastMCP server.
- `RuntimeError`: If no server in context (during resolution)
-### `CurrentRequest`
+### `CurrentRequest`
```python
CurrentRequest() -> Request
@@ -336,7 +351,7 @@ current HTTP request. Only available when running over HTTP transports
- `RuntimeError`: If no HTTP request in context (e.g., STDIO transport)
-### `CurrentHeaders`
+### `CurrentHeaders`
```python
CurrentHeaders() -> dict[str, str]
@@ -345,15 +360,16 @@ CurrentHeaders() -> dict[str, str]
Get the current HTTP request headers.
-This dependency provides access to the HTTP headers for the current request.
-Returns an empty dictionary when no HTTP request is available, making it
-safe to use in code that might run over any transport.
+This dependency provides access to the HTTP headers for the current request,
+including the authorization header. Returns an empty dictionary when no HTTP
+request is available, making it safe to use in code that might run over any
+transport.
**Returns:**
- A dependency that resolves to a dictionary of header name -> value
-### `CurrentAccessToken`
+### `CurrentAccessToken`
```python
CurrentAccessToken() -> AccessToken
@@ -372,7 +388,7 @@ authenticated request. Raises an error if no authentication is present.
- `RuntimeError`: If no authenticated user (use get_access_token() for optional)
-### `TokenClaim`
+### `TokenClaim`
```python
TokenClaim(name: str) -> str
@@ -397,7 +413,7 @@ without needing the full token object.
## Classes
-### `TaskContextInfo`
+### `TaskContextInfo`
Information about the current background task context.
@@ -406,7 +422,7 @@ Returned by ``get_task_context()`` when running inside a Docket worker.
Contains identifiers needed to communicate with the MCP session.
-### `ProgressLike`
+### `ProgressLike`
Protocol for progress tracking interface.
@@ -417,7 +433,7 @@ and Docket's Progress (worker context).
**Methods:**
-#### `current`
+#### `current`
```python
current(self) -> int | None
@@ -426,7 +442,7 @@ current(self) -> int | None
Current progress value.
-#### `total`
+#### `total`
```python
total(self) -> int
@@ -435,7 +451,7 @@ total(self) -> int
Total/target progress value.
-#### `message`
+#### `message`
```python
message(self) -> str | None
@@ -444,7 +460,7 @@ message(self) -> str | None
Current progress message.
-#### `set_total`
+#### `set_total`
```python
set_total(self, total: int) -> None
@@ -453,7 +469,7 @@ set_total(self, total: int) -> None
Set the total/target value for progress tracking.
-#### `increment`
+#### `increment`
```python
increment(self, amount: int = 1) -> None
@@ -462,7 +478,7 @@ increment(self, amount: int = 1) -> None
Atomically increment the current progress value.
-#### `set_message`
+#### `set_message`
```python
set_message(self, message: str | None) -> None
@@ -471,7 +487,7 @@ set_message(self, message: str | None) -> None
Update the progress status message.
-### `InMemoryProgress`
+### `InMemoryProgress`
In-memory progress tracker for immediate tool execution.
@@ -483,25 +499,25 @@ progress doesn't need to be observable across processes.
**Methods:**
-#### `current`
+#### `current`
```python
current(self) -> int | None
```
-#### `total`
+#### `total`
```python
total(self) -> int
```
-#### `message`
+#### `message`
```python
message(self) -> str | None
```
-#### `set_total`
+#### `set_total`
```python
set_total(self, total: int) -> None
@@ -510,7 +526,7 @@ set_total(self, total: int) -> None
Set the total/target value for progress tracking.
-#### `increment`
+#### `increment`
```python
increment(self, amount: int = 1) -> None
@@ -519,7 +535,7 @@ increment(self, amount: int = 1) -> None
Atomically increment the current progress value.
-#### `set_message`
+#### `set_message`
```python
set_message(self, message: str | None) -> None
@@ -528,7 +544,7 @@ set_message(self, message: str | None) -> None
Update the progress status message.
-### `Progress`
+### `Progress`
FastMCP Progress dependency that works in both server and worker contexts.
@@ -542,3 +558,59 @@ This allows tools to use Progress() regardless of whether they're called
immediately or as background tasks, and regardless of whether pydocket
is installed.
+
+**Methods:**
+
+#### `current`
+
+```python
+current(self) -> int | None
+```
+
+Current progress value.
+
+
+#### `total`
+
+```python
+total(self) -> int
+```
+
+Total/target progress value.
+
+
+#### `message`
+
+```python
+message(self) -> str | None
+```
+
+Current progress message.
+
+
+#### `set_total`
+
+```python
+set_total(self, total: int) -> None
+```
+
+Set the total/target value for progress tracking.
+
+
+#### `increment`
+
+```python
+increment(self, amount: int = 1) -> None
+```
+
+Atomically increment the current progress value.
+
+
+#### `set_message`
+
+```python
+set_message(self, message: str | None) -> None
+```
+
+Update the progress status message.
+
diff --git a/docs/python-sdk/fastmcp-server-middleware-authorization.mdx b/docs/python-sdk/fastmcp-server-middleware-authorization.mdx
index abc9ff6ae..5bf859a09 100644
--- a/docs/python-sdk/fastmcp-server-middleware-authorization.mdx
+++ b/docs/python-sdk/fastmcp-server-middleware-authorization.mdx
@@ -61,7 +61,7 @@ on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next:
Filter tools/list response based on auth checks.
-#### `on_call_tool`
+#### `on_call_tool`
```python
on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult
@@ -70,7 +70,7 @@ on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_ne
Check auth before tool execution.
-#### `on_list_resources`
+#### `on_list_resources`
```python
on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]]) -> Sequence[Resource]
@@ -79,7 +79,7 @@ on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], cal
Filter resources/list response based on auth checks.
-#### `on_read_resource`
+#### `on_read_resource`
```python
on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult]) -> ResourceResult
@@ -88,7 +88,7 @@ on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams],
Check auth before resource read.
-#### `on_list_resource_templates`
+#### `on_list_resource_templates`
```python
on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]]) -> Sequence[ResourceTemplate]
@@ -97,7 +97,7 @@ on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTempl
Filter resource templates/list response based on auth checks.
-#### `on_list_prompts`
+#### `on_list_prompts`
```python
on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]]) -> Sequence[Prompt]
@@ -106,7 +106,7 @@ on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_ne
Filter prompts/list response based on auth checks.
-#### `on_get_prompt`
+#### `on_get_prompt`
```python
on_get_prompt(self, context: MiddlewareContext[mt.GetPromptRequestParams], call_next: CallNext[mt.GetPromptRequestParams, PromptResult]) -> PromptResult
diff --git a/docs/python-sdk/fastmcp-server-mixins-lifespan.mdx b/docs/python-sdk/fastmcp-server-mixins-lifespan.mdx
index 0028ea080..9d0eec22e 100644
--- a/docs/python-sdk/fastmcp-server-mixins-lifespan.mdx
+++ b/docs/python-sdk/fastmcp-server-mixins-lifespan.mdx
@@ -10,7 +10,7 @@ Lifespan and Docket task infrastructure for FastMCP Server.
## Classes
-### `LifespanMixin`
+### `LifespanMixin`
Mixin providing lifespan and Docket task infrastructure for FastMCP.
@@ -18,7 +18,7 @@ Mixin providing lifespan and Docket task infrastructure for FastMCP.
**Methods:**
-#### `docket`
+#### `docket`
```python
docket(self: FastMCP) -> Docket | None
diff --git a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx
index d3cf4daa2..efeae3661 100644
--- a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx
+++ b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx
@@ -14,7 +14,7 @@ registration functionality to LocalProvider.
## Classes
-### `ToolDecoratorMixin`
+### `ToolDecoratorMixin`
Mixin class providing tool decorator functionality for LocalProvider.
@@ -26,7 +26,7 @@ This mixin contains all methods related to:
**Methods:**
-#### `add_tool`
+#### `add_tool`
```python
add_tool(self: LocalProvider, tool: Tool | Callable[..., Any]) -> Tool
@@ -37,19 +37,19 @@ Add a tool to this provider's storage.
Accepts either a Tool object or a decorated function with __fastmcp__ metadata.
-#### `tool`
+#### `tool`
```python
tool(self: LocalProvider, name_or_fn: F) -> F
```
-#### `tool`
+#### `tool`
```python
tool(self: LocalProvider, name_or_fn: str | None = None) -> Callable[[F], F]
```
-#### `tool`
+#### `tool`
```python
tool(self: LocalProvider, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool]
diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx
index cc87fd9a7..94f0b7b6a 100644
--- a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx
+++ b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx
@@ -27,7 +27,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult
Execute the HTTP request using RequestDirector.
-### `OpenAPIResource`
+### `OpenAPIResource`
Resource implementation for OpenAPI endpoints.
@@ -35,7 +35,7 @@ Resource implementation for OpenAPI endpoints.
**Methods:**
-#### `read`
+#### `read`
```python
read(self) -> ResourceResult
@@ -44,7 +44,7 @@ read(self) -> ResourceResult
Fetch the resource data by making an HTTP request.
-### `OpenAPIResourceTemplate`
+### `OpenAPIResourceTemplate`
Resource template implementation for OpenAPI endpoints.
@@ -52,7 +52,7 @@ Resource template implementation for OpenAPI endpoints.
**Methods:**
-#### `create_resource`
+#### `create_resource`
```python
create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource
diff --git a/docs/python-sdk/fastmcp-server-providers-proxy.mdx b/docs/python-sdk/fastmcp-server-providers-proxy.mdx
index b661d2386..4c64d8566 100644
--- a/docs/python-sdk/fastmcp-server-providers-proxy.mdx
+++ b/docs/python-sdk/fastmcp-server-providers-proxy.mdx
@@ -15,7 +15,7 @@ classes that forward execution to remote servers.
## Functions
-### `default_proxy_roots_handler`
+### `default_proxy_roots_handler`
```python
default_proxy_roots_handler(context: RequestContext[ClientSession, LifespanContextT]) -> RootsList
@@ -25,7 +25,7 @@ default_proxy_roots_handler(context: RequestContext[ClientSession, LifespanConte
Forward list roots request from remote server to proxy's connected clients.
-### `default_proxy_sampling_handler`
+### `default_proxy_sampling_handler`
```python
default_proxy_sampling_handler(messages: list[mcp.types.SamplingMessage], params: mcp.types.CreateMessageRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> mcp.types.CreateMessageResult
@@ -35,7 +35,7 @@ default_proxy_sampling_handler(messages: list[mcp.types.SamplingMessage], params
Forward sampling request from remote server to proxy's connected clients.
-### `default_proxy_elicitation_handler`
+### `default_proxy_elicitation_handler`
```python
default_proxy_elicitation_handler(message: str, response_type: type, params: mcp.types.ElicitRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> ElicitResult
@@ -45,7 +45,7 @@ default_proxy_elicitation_handler(message: str, response_type: type, params: mcp
Forward elicitation request from remote server to proxy's connected clients.
-### `default_proxy_log_handler`
+### `default_proxy_log_handler`
```python
default_proxy_log_handler(message: LogMessage) -> None
@@ -55,7 +55,7 @@ default_proxy_log_handler(message: LogMessage) -> None
Forward log notification from remote server to proxy's connected clients.
-### `default_proxy_progress_handler`
+### `default_proxy_progress_handler`
```python
default_proxy_progress_handler(progress: float, total: float | None, message: str | None) -> None
@@ -102,13 +102,13 @@ run(self, arguments: dict[str, Any], context: Context | None = None) -> ToolResu
Executes the tool by making a call through the client.
-#### `get_span_attributes`
+#### `get_span_attributes`
```python
get_span_attributes(self) -> dict[str, Any]
```
-### `ProxyResource`
+### `ProxyResource`
A Resource that represents and reads a resource from a remote server.
@@ -116,7 +116,7 @@ A Resource that represents and reads a resource from a remote server.
**Methods:**
-#### `model_copy`
+#### `model_copy`
```python
model_copy(self, **kwargs: Any) -> ProxyResource
@@ -125,7 +125,7 @@ model_copy(self, **kwargs: Any) -> ProxyResource
Override to preserve _backend_uri when uri changes.
-#### `from_mcp_resource`
+#### `from_mcp_resource`
```python
from_mcp_resource(cls, client_factory: ClientFactoryT, mcp_resource: mcp.types.Resource) -> ProxyResource
@@ -134,7 +134,7 @@ from_mcp_resource(cls, client_factory: ClientFactoryT, mcp_resource: mcp.types.R
Factory method to create a ProxyResource from a raw MCP resource schema.
-#### `read`
+#### `read`
```python
read(self) -> ResourceResult
@@ -143,13 +143,13 @@ read(self) -> ResourceResult
Read the resource content from the remote server.
-#### `get_span_attributes`
+#### `get_span_attributes`
```python
get_span_attributes(self) -> dict[str, Any]
```
-### `ProxyTemplate`
+### `ProxyTemplate`
A ResourceTemplate that represents and creates resources from a remote server template.
@@ -157,7 +157,7 @@ A ResourceTemplate that represents and creates resources from a remote server te
**Methods:**
-#### `model_copy`
+#### `model_copy`
```python
model_copy(self, **kwargs: Any) -> ProxyTemplate
@@ -166,7 +166,7 @@ model_copy(self, **kwargs: Any) -> ProxyTemplate
Override to preserve _backend_uri_template when uri_template changes.
-#### `from_mcp_template`
+#### `from_mcp_template`
```python
from_mcp_template(cls, client_factory: ClientFactoryT, mcp_template: mcp.types.ResourceTemplate) -> ProxyTemplate
@@ -175,7 +175,7 @@ from_mcp_template(cls, client_factory: ClientFactoryT, mcp_template: mcp.types.R
Factory method to create a ProxyTemplate from a raw MCP template schema.
-#### `create_resource`
+#### `create_resource`
```python
create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> ProxyResource
@@ -184,13 +184,13 @@ create_resource(self, uri: str, params: dict[str, Any], context: Context | None
Create a resource from the template by calling the remote server.
-#### `get_span_attributes`
+#### `get_span_attributes`
```python
get_span_attributes(self) -> dict[str, Any]
```
-### `ProxyPrompt`
+### `ProxyPrompt`
A Prompt that represents and renders a prompt from a remote server.
@@ -198,7 +198,7 @@ A Prompt that represents and renders a prompt from a remote server.
**Methods:**
-#### `model_copy`
+#### `model_copy`
```python
model_copy(self, **kwargs: Any) -> ProxyPrompt
@@ -207,7 +207,7 @@ model_copy(self, **kwargs: Any) -> ProxyPrompt
Override to preserve _backend_name when name changes.
-#### `from_mcp_prompt`
+#### `from_mcp_prompt`
```python
from_mcp_prompt(cls, client_factory: ClientFactoryT, mcp_prompt: mcp.types.Prompt) -> ProxyPrompt
@@ -216,7 +216,7 @@ from_mcp_prompt(cls, client_factory: ClientFactoryT, mcp_prompt: mcp.types.Promp
Factory method to create a ProxyPrompt from a raw MCP prompt schema.
-#### `render`
+#### `render`
```python
render(self, arguments: dict[str, Any]) -> PromptResult
@@ -225,13 +225,13 @@ render(self, arguments: dict[str, Any]) -> PromptResult
Render the prompt by making a call through the client.
-#### `get_span_attributes`
+#### `get_span_attributes`
```python
get_span_attributes(self) -> dict[str, Any]
```
-### `ProxyProvider`
+### `ProxyProvider`
Provider that proxies to a remote MCP server via a client factory.
@@ -245,7 +245,7 @@ because tasks cannot be executed through a proxy.
**Methods:**
-#### `get_tasks`
+#### `get_tasks`
```python
get_tasks(self) -> Sequence[FastMCPComponent]
@@ -258,7 +258,7 @@ server lifespan initialization, which would open the client before any
context is set. All Proxy* components have task_config.mode="forbidden".
-### `FastMCPProxy`
+### `FastMCPProxy`
A FastMCP server that acts as a proxy to a remote MCP-compliant server.
@@ -267,7 +267,7 @@ This is a convenience wrapper that creates a FastMCP server with a
ProxyProvider. For more control, use FastMCP with add_provider(ProxyProvider(...)).
-### `ProxyClient`
+### `ProxyClient`
A proxy client that forwards advanced interactions between a remote MCP server and the proxy's connected clients.
@@ -275,7 +275,7 @@ A proxy client that forwards advanced interactions between a remote MCP server a
Supports forwarding roots, sampling, elicitation, logging, and progress.
-### `StatefulProxyClient`
+### `StatefulProxyClient`
A proxy client that provides a stateful client factory for the proxy server.
@@ -296,7 +296,7 @@ it to detect (and correct) staleness.
**Methods:**
-#### `clear`
+#### `clear`
```python
clear(self)
@@ -305,7 +305,7 @@ clear(self)
Clear all cached clients and force disconnect them.
-#### `new_stateful`
+#### `new_stateful`
```python
new_stateful(self) -> Client[ClientTransportT]
diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx
index 267f66d13..dbd0ad15a 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 dictionary as the lifespan result.
-### `create_proxy`
+### `create_proxy`
```python
create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy
@@ -54,53 +54,53 @@ use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.pr
## Classes
-### `StateValue`
+### `StateValue`
Wrapper for stored context state values.
-### `FastMCP`
+### `FastMCP`
**Methods:**
-#### `name`
+#### `name`
```python
name(self) -> str
```
-#### `instructions`
+#### `instructions`
```python
instructions(self) -> str | None
```
-#### `instructions`
+#### `instructions`
```python
instructions(self, value: str | None) -> None
```
-#### `version`
+#### `version`
```python
version(self) -> str | None
```
-#### `website_url`
+#### `website_url`
```python
website_url(self) -> str | None
```
-#### `icons`
+#### `icons`
```python
icons(self) -> list[mcp.types.Icon]
```
-#### `local_provider`
+#### `local_provider`
```python
local_provider(self) -> LocalProvider
@@ -115,13 +115,13 @@ Use this to remove components:
mcp.local_provider.remove_prompt("my_prompt")
-#### `add_middleware`
+#### `add_middleware`
```python
add_middleware(self, middleware: Middleware) -> None
```
-#### `add_provider`
+#### `add_provider`
```python
add_provider(self, provider: Provider) -> None
@@ -141,7 +141,7 @@ always take precedence over providers.
- Prompts become "namespace_promptname"
-#### `get_tasks`
+#### `get_tasks`
```python
get_tasks(self) -> Sequence[FastMCPComponent]
@@ -153,7 +153,7 @@ Overrides AggregateProvider.get_tasks() to apply server-level transforms
after aggregation. AggregateProvider handles provider-level namespacing.
-#### `add_transform`
+#### `add_transform`
```python
add_transform(self, transform: Transform) -> None
@@ -168,7 +168,7 @@ They transform tools, resources, and prompts from ALL providers.
- `transform`: The transform to add.
-#### `add_tool_transformation`
+#### `add_tool_transformation`
```python
add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None
@@ -180,7 +180,7 @@ Add a tool transformation.
Use ``add_transform(ToolTransform({...}))`` instead.
-#### `remove_tool_transformation`
+#### `remove_tool_transformation`
```python
remove_tool_transformation(self, _tool_name: str) -> None
@@ -192,7 +192,7 @@ Remove a tool transformation.
Tool transformations are now immutable. Use enable/disable controls instead.
-#### `list_tools`
+#### `list_tools`
```python
list_tools(self) -> Sequence[Tool]
@@ -205,7 +205,7 @@ and middleware execution. Returns all versions (no deduplication).
Protocol handlers deduplicate for MCP wire format.
-#### `get_tool`
+#### `get_tool`
```python
get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None
@@ -225,7 +225,7 @@ session transforms can override provider-level disables.
- The tool if found and enabled, None otherwise.
-#### `list_resources`
+#### `list_resources`
```python
list_resources(self) -> Sequence[Resource]
@@ -238,7 +238,7 @@ and middleware execution. Returns all versions (no deduplication).
Protocol handlers deduplicate for MCP wire format.
-#### `get_resource`
+#### `get_resource`
```python
get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None
@@ -257,7 +257,7 @@ transforms (including session-level) have been applied.
- The resource if found and enabled, None otherwise.
-#### `list_resource_templates`
+#### `list_resource_templates`
```python
list_resource_templates(self) -> Sequence[ResourceTemplate]
@@ -270,7 +270,7 @@ auth filtering, and middleware execution. Returns all versions (no deduplication
Protocol handlers deduplicate for MCP wire format.
-#### `get_resource_template`
+#### `get_resource_template`
```python
get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None
@@ -289,7 +289,7 @@ all transforms (including session-level) have been applied.
- The template if found and enabled, None otherwise.
-#### `list_prompts`
+#### `list_prompts`
```python
list_prompts(self) -> Sequence[Prompt]
@@ -302,7 +302,7 @@ and middleware execution. Returns all versions (no deduplication).
Protocol handlers deduplicate for MCP wire format.
-#### `get_prompt`
+#### `get_prompt`
```python
get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None
@@ -321,19 +321,19 @@ transforms (including session-level) have been applied.
- The prompt if found and enabled, None otherwise.
-#### `call_tool`
+#### `call_tool`
```python
call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult
```
-#### `call_tool`
+#### `call_tool`
```python
call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult
```
-#### `call_tool`
+#### `call_tool`
```python
call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult | mcp.types.CreateTaskResult
@@ -363,19 +363,19 @@ return ToolResult.
- `ValidationError`: If arguments fail validation
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self, uri: str) -> ResourceResult
```
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self, uri: str) -> mcp.types.CreateTaskResult
```
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult
@@ -404,19 +404,19 @@ return ResourceResult.
- `ResourceError`: If resource read fails
-#### `render_prompt`
+#### `render_prompt`
```python
render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult
```
-#### `render_prompt`
+#### `render_prompt`
```python
render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult
```
-#### `render_prompt`
+#### `render_prompt`
```python
render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult
@@ -446,7 +446,7 @@ return PromptResult.
- `PromptError`: If prompt rendering fails
-#### `add_tool`
+#### `add_tool`
```python
add_tool(self, tool: Tool | Callable[..., Any]) -> Tool
@@ -464,7 +464,7 @@ with the Context type annotation. See the @tool decorator for examples.
- The tool instance that was added to the server.
-#### `remove_tool`
+#### `remove_tool`
```python
remove_tool(self, name: str, version: str | None = None) -> None
@@ -483,19 +483,19 @@ Remove tool(s) from the server.
- `NotFoundError`: If no matching tool is found.
-#### `tool`
+#### `tool`
```python
tool(self, name_or_fn: F) -> F
```
-#### `tool`
+#### `tool`
```python
tool(self, name_or_fn: str | None = None) -> Callable[[F], F]
```
-#### `tool`
+#### `tool`
```python
tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool]
@@ -551,7 +551,7 @@ server.tool(my_function, name="custom_name")
```
-#### `add_resource`
+#### `add_resource`
```python
add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate
@@ -566,7 +566,7 @@ Add a resource to the server.
- The resource instance that was added to the server.
-#### `add_template`
+#### `add_template`
```python
add_template(self, template: ResourceTemplate) -> ResourceTemplate
@@ -581,7 +581,7 @@ Add a resource template to the server.
- The template instance that was added to the server.
-#### `resource`
+#### `resource`
```python
resource(self, uri: str) -> Callable[[F], F]
@@ -640,7 +640,7 @@ async def get_weather(city: str) -> str:
```
-#### `add_prompt`
+#### `add_prompt`
```python
add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt
@@ -655,19 +655,19 @@ Add a prompt to the server.
- The prompt instance that was added to the server.
-#### `prompt`
+#### `prompt`
```python
prompt(self, name_or_fn: F) -> F
```
-#### `prompt`
+#### `prompt`
```python
prompt(self, name_or_fn: str | None = None) -> Callable[[F], F]
```
-#### `prompt`
+#### `prompt`
```python
prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt]
@@ -744,7 +744,7 @@ Decorator to register a prompt.
```
-#### `mount`
+#### `mount`
```python
mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None
@@ -791,7 +791,7 @@ mounted server.
- `prefix`: Deprecated. Use namespace instead.
-#### `import_server`
+#### `import_server`
```python
import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None
@@ -832,7 +832,7 @@ templates, and prompts are imported with their original names.
objects are imported with their original names.
-#### `from_openapi`
+#### `from_openapi`
```python
from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self
@@ -861,7 +861,7 @@ response structure while still returning structured JSON.
- A FastMCP server with an OpenAPIProvider attached.
-#### `from_fastapi`
+#### `from_fastapi`
```python
from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self
@@ -885,7 +885,7 @@ Use this to configure timeout and other client settings.
- A FastMCP server with an OpenAPIProvider attached.
-#### `as_proxy`
+#### `as_proxy`
```python
as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy
@@ -903,7 +903,7 @@ instance or any value accepted as the `transport` argument of
`fastmcp.client.Client` constructor.
-#### `generate_name`
+#### `generate_name`
```python
generate_name(cls, name: str | None = None) -> str
diff --git a/docs/python-sdk/fastmcp-server-tasks-subscriptions.mdx b/docs/python-sdk/fastmcp-server-tasks-subscriptions.mdx
index da18a6d2d..2fd2e3cd4 100644
--- a/docs/python-sdk/fastmcp-server-tasks-subscriptions.mdx
+++ b/docs/python-sdk/fastmcp-server-tasks-subscriptions.mdx
@@ -16,7 +16,7 @@ This module requires fastmcp[tasks] (pydocket). It is only imported when docket
## Functions
-### `subscribe_to_task_updates`
+### `subscribe_to_task_updates`
```python
subscribe_to_task_updates(task_id: str, task_key: str, session: ServerSession, docket: Docket, poll_interval_ms: int = 5000) -> None
diff --git a/docs/python-sdk/fastmcp-server-transforms-catalog.mdx b/docs/python-sdk/fastmcp-server-transforms-catalog.mdx
new file mode 100644
index 000000000..1dd2cfe4a
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-transforms-catalog.mdx
@@ -0,0 +1,220 @@
+---
+title: catalog
+sidebarTitle: catalog
+---
+
+# `fastmcp.server.transforms.catalog`
+
+
+Base class for transforms that need to read the real component catalog.
+
+Some transforms replace ``list_tools()`` output with synthetic components
+(e.g. a search interface) while still needing access to the *real*
+(auth-filtered) catalog at call time. ``CatalogTransform`` provides the
+bypass machinery so subclasses can call ``get_tool_catalog()`` without
+triggering their own replacement logic.
+
+Re-entrancy problem
+-------------------
+
+When a synthetic tool handler calls ``get_tool_catalog()``, that calls
+``ctx.fastmcp.list_tools()`` which re-enters the transform pipeline β
+including *this* transform's ``list_tools()``. If the subclass overrides
+``list_tools()`` directly, the re-entrant call would hit the subclass's
+replacement logic again (returning synthetic tools instead of the real
+catalog). A ``super()`` call can't prevent this because Python can't
+short-circuit a method after ``super()`` returns.
+
+Solution: ``CatalogTransform`` owns ``list_tools()`` and uses a
+per-instance ``ContextVar`` to detect re-entrant calls. During bypass,
+it passes through to the base ``Transform.list_tools()`` (a no-op).
+Otherwise, it delegates to ``transform_tools()`` β the subclass hook
+where replacement logic lives. Same pattern for resources, prompts,
+and resource templates.
+
+This is *not* the same as the ``Provider._list_tools()`` convention
+(which produces raw components with no arguments). ``transform_tools()``
+receives the current catalog and returns a transformed version. The
+distinct name avoids confusion between the two patterns.
+
+Usage::
+
+ class MyTransform(CatalogTransform):
+ async def transform_tools(self, tools):
+ return [self._make_search_tool()]
+
+ def _make_search_tool(self):
+ async def search(ctx: Context = None):
+ real_tools = await self.get_tool_catalog(ctx)
+ ...
+ return Tool.from_function(fn=search, name="search")
+
+
+## Classes
+
+### `CatalogTransform`
+
+
+Transform that needs access to the real component catalog.
+
+Subclasses override ``transform_tools()`` / ``transform_resources()``
+/ ``transform_prompts()`` / ``transform_resource_templates()``
+instead of the ``list_*()`` methods. The base class owns
+``list_*()`` and handles re-entrant bypass automatically β subclasses
+never see re-entrant calls from ``get_*_catalog()``.
+
+The ``get_*_catalog()`` methods fetch the real (auth-filtered) catalog
+by temporarily setting a bypass flag so that this transform's
+``list_*()`` passes through without calling the subclass hook.
+
+
+**Methods:**
+
+#### `list_tools`
+
+```python
+list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]
+```
+
+#### `list_resources`
+
+```python
+list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]
+```
+
+#### `list_resource_templates`
+
+```python
+list_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate]
+```
+
+#### `list_prompts`
+
+```python
+list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]
+```
+
+#### `transform_tools`
+
+```python
+transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]
+```
+
+Transform the tool catalog.
+
+Override this method to replace, filter, or augment the tool listing.
+The default implementation passes through unchanged.
+
+Do NOT override ``list_tools()`` directly β the base class uses it
+to handle re-entrant bypass when ``get_tool_catalog()`` reads the
+real catalog.
+
+
+#### `transform_resources`
+
+```python
+transform_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]
+```
+
+Transform the resource catalog.
+
+Override this method to replace, filter, or augment the resource listing.
+The default implementation passes through unchanged.
+
+Do NOT override ``list_resources()`` directly β the base class uses it
+to handle re-entrant bypass when ``get_resource_catalog()`` reads the
+real catalog.
+
+
+#### `transform_resource_templates`
+
+```python
+transform_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate]
+```
+
+Transform the resource template catalog.
+
+Override this method to replace, filter, or augment the template listing.
+The default implementation passes through unchanged.
+
+Do NOT override ``list_resource_templates()`` directly β the base class
+uses it to handle re-entrant bypass when
+``get_resource_template_catalog()`` reads the real catalog.
+
+
+#### `transform_prompts`
+
+```python
+transform_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]
+```
+
+Transform the prompt catalog.
+
+Override this method to replace, filter, or augment the prompt listing.
+The default implementation passes through unchanged.
+
+Do NOT override ``list_prompts()`` directly β the base class uses it
+to handle re-entrant bypass when ``get_prompt_catalog()`` reads the
+real catalog.
+
+
+#### `get_tool_catalog`
+
+```python
+get_tool_catalog(self, ctx: Context) -> Sequence[Tool]
+```
+
+Fetch the real tool catalog, bypassing this transform.
+
+**Args:**
+- `ctx`: The current request context.
+- `run_middleware`: Whether to run middleware on the inner call.
+Defaults to True because this is typically called from a
+tool handler where list_tools middleware has not yet run.
+
+
+#### `get_resource_catalog`
+
+```python
+get_resource_catalog(self, ctx: Context) -> Sequence[Resource]
+```
+
+Fetch the real resource catalog, bypassing this transform.
+
+**Args:**
+- `ctx`: The current request context.
+- `run_middleware`: Whether to run middleware on the inner call.
+Defaults to True because this is typically called from a
+tool handler where list_resources middleware has not yet run.
+
+
+#### `get_prompt_catalog`
+
+```python
+get_prompt_catalog(self, ctx: Context) -> Sequence[Prompt]
+```
+
+Fetch the real prompt catalog, bypassing this transform.
+
+**Args:**
+- `ctx`: The current request context.
+- `run_middleware`: Whether to run middleware on the inner call.
+Defaults to True because this is typically called from a
+tool handler where list_prompts middleware has not yet run.
+
+
+#### `get_resource_template_catalog`
+
+```python
+get_resource_template_catalog(self, ctx: Context) -> Sequence[ResourceTemplate]
+```
+
+Fetch the real resource template catalog, bypassing this transform.
+
+**Args:**
+- `ctx`: The current request context.
+- `run_middleware`: Whether to run middleware on the inner call.
+Defaults to True because this is typically called from a
+tool handler where list_resource_templates middleware has
+not yet run.
+
diff --git a/docs/python-sdk/fastmcp-server-transforms-search-__init__.mdx b/docs/python-sdk/fastmcp-server-transforms-search-__init__.mdx
new file mode 100644
index 000000000..80b71d226
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-transforms-search-__init__.mdx
@@ -0,0 +1,23 @@
+---
+title: __init__
+sidebarTitle: __init__
+---
+
+# `fastmcp.server.transforms.search`
+
+
+Search transforms for tool discovery.
+
+Search transforms collapse a large tool catalog into a search interface,
+letting LLMs discover tools on demand instead of seeing the full list.
+
+Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.server.transforms.search import RegexSearchTransform
+
+ mcp = FastMCP("Server")
+ mcp.add_transform(RegexSearchTransform())
+ # list_tools now returns only search_tools + call_tool
+ ```
+
diff --git a/docs/python-sdk/fastmcp-server-transforms-search-base.mdx b/docs/python-sdk/fastmcp-server-transforms-search-base.mdx
new file mode 100644
index 000000000..7ecd3b5f2
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-transforms-search-base.mdx
@@ -0,0 +1,105 @@
+---
+title: base
+sidebarTitle: base
+---
+
+# `fastmcp.server.transforms.search.base`
+
+
+Base class for search transforms.
+
+Search transforms replace ``list_tools()`` output with a small set of
+synthetic tools β a search tool and a call-tool proxy β so LLMs can
+discover tools on demand instead of receiving the full catalog.
+
+All concrete search transforms (``RegexSearchTransform``,
+``BM25SearchTransform``, etc.) inherit from ``BaseSearchTransform`` and
+implement ``_make_search_tool()`` and ``_search()`` to provide their
+specific search strategy.
+
+Example::
+
+ from fastmcp import FastMCP
+ from fastmcp.server.transforms.search import RegexSearchTransform
+
+ mcp = FastMCP("Server")
+
+ @mcp.tool
+ def add(a: int, b: int) -> int: ...
+
+ @mcp.tool
+ def multiply(x: float, y: float) -> float: ...
+
+ # Clients now see only ``search_tools`` and ``call_tool``.
+ # The original tools are discoverable via search.
+ mcp.add_transform(RegexSearchTransform())
+
+
+## Functions
+
+### `serialize_tools_for_output_json`
+
+```python
+serialize_tools_for_output_json(tools: Sequence[Tool]) -> list[dict[str, Any]]
+```
+
+
+Serialize tools to the same dict format as ``list_tools`` output.
+
+
+### `serialize_tools_for_output_markdown`
+
+```python
+serialize_tools_for_output_markdown(tools: Sequence[Tool]) -> str
+```
+
+
+Serialize tools to compact markdown, using ~65-70% fewer tokens than JSON.
+
+
+## Classes
+
+### `BaseSearchTransform`
+
+
+Replace the tool listing with a search interface.
+
+When this transform is active, ``list_tools()`` returns only:
+
+* Any tools listed in ``always_visible`` (pinned).
+* A **search tool** that finds tools matching a query.
+* A **call_tool** proxy that executes tools discovered via search.
+
+Hidden tools remain callable β ``get_tool()`` delegates unknown
+names downstream, so direct calls and the call-tool proxy both work.
+
+Search results respect the full auth pipeline: middleware, visibility
+transforms, and component-level auth checks all apply.
+
+**Args:**
+- `max_results`: Maximum number of tools returned per search.
+- `always_visible`: Tool names that stay in the ``list_tools``
+output alongside the synthetic search/call tools.
+- `search_tool_name`: Name of the generated search tool.
+- `call_tool_name`: Name of the generated call-tool proxy.
+
+
+**Methods:**
+
+#### `transform_tools`
+
+```python
+transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]
+```
+
+Replace the catalog with pinned + synthetic search/call tools.
+
+
+#### `get_tool`
+
+```python
+get_tool(self, name: str, call_next: GetToolNext) -> Tool | None
+```
+
+Intercept synthetic tool names; delegate everything else.
+
diff --git a/docs/python-sdk/fastmcp-server-transforms-search-bm25.mdx b/docs/python-sdk/fastmcp-server-transforms-search-bm25.mdx
new file mode 100644
index 000000000..d5264f46a
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-transforms-search-bm25.mdx
@@ -0,0 +1,20 @@
+---
+title: bm25
+sidebarTitle: bm25
+---
+
+# `fastmcp.server.transforms.search.bm25`
+
+
+BM25-based search transform.
+
+## Classes
+
+### `BM25SearchTransform`
+
+
+Search transform using BM25 Okapi relevance ranking.
+
+Maintains an in-memory index that is lazily rebuilt when the tool
+catalog changes (detected via a hash of tool names).
+
diff --git a/docs/python-sdk/fastmcp-server-transforms-search-regex.mdx b/docs/python-sdk/fastmcp-server-transforms-search-regex.mdx
new file mode 100644
index 000000000..e36c8d25e
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-transforms-search-regex.mdx
@@ -0,0 +1,20 @@
+---
+title: regex
+sidebarTitle: regex
+---
+
+# `fastmcp.server.transforms.search.regex`
+
+
+Regex-based search transform.
+
+## Classes
+
+### `RegexSearchTransform`
+
+
+Search transform using regex pattern matching.
+
+Tools are matched against their name, description, and parameter
+information using ``re.search`` with ``re.IGNORECASE``.
+
diff --git a/docs/python-sdk/fastmcp-tools-function_parsing.mdx b/docs/python-sdk/fastmcp-tools-function_parsing.mdx
index 284b6cdbe..f9cd7f28e 100644
--- a/docs/python-sdk/fastmcp-tools-function_parsing.mdx
+++ b/docs/python-sdk/fastmcp-tools-function_parsing.mdx
@@ -10,11 +10,11 @@ Function introspection and schema generation for FastMCP tools.
## Classes
-### `ParsedFunction`
+### `ParsedFunction`
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., Any], exclude_args: list[str] | None = None, validate: bool = True, wrap_non_object_output_schema: bool = True) -> ParsedFunction
diff --git a/docs/python-sdk/fastmcp-tools-function_tool.mdx b/docs/python-sdk/fastmcp-tools-function_tool.mdx
index 97d0d967f..d25c6ecf8 100644
--- a/docs/python-sdk/fastmcp-tools-function_tool.mdx
+++ b/docs/python-sdk/fastmcp-tools-function_tool.mdx
@@ -10,7 +10,7 @@ Standalone @tool decorator for FastMCP.
## Functions
-### `tool`
+### `tool`
```python
tool(name_or_fn: str | Callable[..., Any] | None = None) -> Any
@@ -25,23 +25,23 @@ using mcp.add_tool().
## Classes
-### `DecoratedTool`
+### `DecoratedTool`
Protocol for functions decorated with @tool.
-### `ToolMeta`
+### `ToolMeta`
Metadata attached to functions by the @tool decorator.
-### `FunctionTool`
+### `FunctionTool`
**Methods:**
-#### `to_mcp_tool`
+#### `to_mcp_tool`
```python
to_mcp_tool(self, **overrides: Any) -> mcp.types.Tool
@@ -52,7 +52,7 @@ Convert the FastMCP tool to an MCP tool.
Extends the base implementation to add task execution mode if enabled.
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., Any]) -> FunctionTool
@@ -68,7 +68,7 @@ individual parameters must not be passed.
Cannot be used together with metadata parameter.
-#### `run`
+#### `run`
```python
run(self, arguments: dict[str, Any]) -> ToolResult
@@ -77,7 +77,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult
Run the tool with arguments.
-#### `register_with_docket`
+#### `register_with_docket`
```python
register_with_docket(self, docket: Docket) -> None
@@ -89,7 +89,7 @@ FunctionTool registers the underlying function, which has the user's
Depends parameters for docket to resolve.
-#### `add_to_docket`
+#### `add_to_docket`
```python
add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution
diff --git a/docs/python-sdk/fastmcp-tools-tool.mdx b/docs/python-sdk/fastmcp-tools-tool.mdx
index 1fe8e4c9d..0394bf4a5 100644
--- a/docs/python-sdk/fastmcp-tools-tool.mdx
+++ b/docs/python-sdk/fastmcp-tools-tool.mdx
@@ -7,7 +7,7 @@ sidebarTitle: tool
## Functions
-### `default_serializer`
+### `default_serializer`
```python
default_serializer(data: Any) -> str
@@ -15,17 +15,17 @@ default_serializer(data: Any) -> str
## Classes
-### `ToolResult`
+### `ToolResult`
**Methods:**
-#### `to_mcp_result`
+#### `to_mcp_result`
```python
to_mcp_result(self) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult
```
-### `Tool`
+### `Tool`
Internal tool registration info.
@@ -33,7 +33,7 @@ Internal tool registration info.
**Methods:**
-#### `to_mcp_tool`
+#### `to_mcp_tool`
```python
to_mcp_tool(self, **overrides: Any) -> MCPTool
@@ -42,7 +42,7 @@ to_mcp_tool(self, **overrides: Any) -> MCPTool
Convert the FastMCP tool to an MCP tool.
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., Any]) -> FunctionTool
@@ -51,7 +51,7 @@ from_function(cls, fn: Callable[..., Any]) -> FunctionTool
Create a Tool from a function.
-#### `run`
+#### `run`
```python
run(self, arguments: dict[str, Any]) -> ToolResult
@@ -66,7 +66,7 @@ implemented by subclasses.
(list of ContentBlocks, dict of structured output).
-#### `convert_result`
+#### `convert_result`
```python
convert_result(self, raw_value: Any) -> ToolResult
@@ -78,7 +78,7 @@ Handles ToolResult passthrough and converts raw values using the tool's
attributes (serializer, output_schema) for proper conversion.
-#### `register_with_docket`
+#### `register_with_docket`
```python
register_with_docket(self, docket: Docket) -> None
@@ -87,7 +87,7 @@ register_with_docket(self, docket: Docket) -> None
Register this tool with docket for background execution.
-#### `add_to_docket`
+#### `add_to_docket`
```python
add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution
@@ -103,13 +103,13 @@ Schedule this tool for background execution via docket.
- `**kwargs`: Additional kwargs passed to docket.add()
-#### `from_tool`
+#### `from_tool`
```python
from_tool(cls, tool: Tool | Callable[..., Any]) -> TransformedTool
```
-#### `get_span_attributes`
+#### `get_span_attributes`
```python
get_span_attributes(self) -> dict[str, Any]
diff --git a/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx b/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx
index 406f1fac0..fad88ee5f 100644
--- a/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx
+++ b/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx
@@ -20,7 +20,7 @@ clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None
Clean up a schema dictionary for display by removing internal/complex fields.
-### `extract_output_schema_from_responses`
+### `extract_output_schema_from_responses`
```python
extract_output_schema_from_responses(responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None, openapi_version: str | None = None) -> dict[str, Any] | None
diff --git a/docs/servers/auth/authentication.mdx b/docs/servers/auth/authentication.mdx
index f2c3d1efc..9a26df138 100644
--- a/docs/servers/auth/authentication.mdx
+++ b/docs/servers/auth/authentication.mdx
@@ -178,6 +178,40 @@ This example shows the basic structure of a custom OAuth provider. The actual im
β **Complete guide**: [Full OAuth Server](/servers/auth/full-oauth-server)
+### MultiAuth
+
+
+
+`MultiAuth` composes multiple authentication sources into a single `auth` provider. When a server needs to accept tokens from different issuers β for example, an OAuth proxy for interactive clients alongside JWT verification for machine-to-machine tokens β `MultiAuth` tries each source in order and accepts the first successful verification.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.auth import MultiAuth, OAuthProxy
+from fastmcp.server.auth.providers.jwt import JWTVerifier
+
+auth = MultiAuth(
+ server=OAuthProxy(
+ issuer_url="https://login.example.com/...",
+ client_id="my-app",
+ client_secret="secret",
+ base_url="https://my-server.com",
+ ),
+ verifiers=[
+ JWTVerifier(
+ jwks_uri="https://internal-issuer.example.com/.well-known/jwks.json",
+ issuer="https://internal-issuer.example.com",
+ audience="my-mcp-server",
+ ),
+ ],
+)
+
+mcp = FastMCP("My Server", auth=auth)
+```
+
+The server (if provided) owns all OAuth routes and metadata. Verifiers contribute only token verification logic. This keeps the MCP discovery surface clean while supporting multiple token sources.
+
+β **Complete guide**: [Multiple Auth Sources](/servers/auth/multi-auth)
+
## Configuration
Authentication providers are configured programmatically by instantiating them directly in your code with their required parameters. This makes dependencies explicit and allows your IDE to provide helpful autocompletion and type checking.
@@ -211,6 +245,8 @@ The authentication approach you choose depends on your existing infrastructure,
**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.
+**When you need tokens from multiple sources, use MultiAuth.** This is common in hybrid architectures where interactive clients authenticate through an OAuth proxy while backend services send JWT tokens directly. `MultiAuth` composes an optional auth server with additional token verifiers, trying each source in order until one succeeds.
+
**Full OAuth implementation should be avoided unless you have compelling reasons that external providers cannot address.** Air-gapped environments, specialized compliance requirements, or unique organizational constraints might justify this approach, but it requires significant security expertise and ongoing maintenance commitment. The complexity extends far beyond initial implementation to include threat monitoring, security updates, and keeping pace with evolving attack vectors.
FastMCP's architecture supports migration between these approaches as your requirements evolve. You can integrate with existing token systems initially and migrate to external identity providers as your application scales, or implement custom solutions when your requirements outgrow standard patterns.
\ No newline at end of file
diff --git a/docs/servers/auth/multi-auth.mdx b/docs/servers/auth/multi-auth.mdx
new file mode 100644
index 000000000..ba54d25ab
--- /dev/null
+++ b/docs/servers/auth/multi-auth.mdx
@@ -0,0 +1,95 @@
+---
+title: Multiple Auth Sources
+sidebarTitle: Multiple Auth Sources
+description: Accept tokens from multiple authentication sources with a single server.
+icon: layer-group
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+Production servers often need to accept tokens from multiple authentication sources. An interactive application might authenticate through an OAuth proxy, while a backend service sends machine-to-machine JWT tokens directly. `MultiAuth` composes these sources into a single `auth` provider so every valid token is accepted regardless of where it was issued.
+
+## Understanding MultiAuth
+
+`MultiAuth` wraps an optional auth server (like `OAuthProxy`) together with one or more token verifiers (like `JWTVerifier`). When a request arrives with a bearer token, `MultiAuth` tries each source in order and accepts the first successful verification.
+
+The auth server, if provided, is tried first. It owns all OAuth routes and metadata β the verifiers contribute only token verification logic. This keeps the MCP discovery surface clean: one set of routes, one set of metadata, multiple verification paths.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.auth import MultiAuth, OAuthProxy
+from fastmcp.server.auth.providers.jwt import JWTVerifier
+
+auth = MultiAuth(
+ server=OAuthProxy(
+ issuer_url="https://login.example.com/...",
+ client_id="my-app",
+ client_secret="secret",
+ base_url="https://my-server.com",
+ ),
+ verifiers=[
+ JWTVerifier(
+ jwks_uri="https://internal-issuer.example.com/.well-known/jwks.json",
+ issuer="https://internal-issuer.example.com",
+ audience="my-mcp-server",
+ ),
+ ],
+)
+
+mcp = FastMCP("My Server", auth=auth)
+```
+
+Interactive MCP clients authenticate through the OAuth proxy as usual. Backend services skip OAuth entirely and send a JWT signed by the internal issuer. Both paths are validated, and the first match wins.
+
+## Verification Order
+
+`MultiAuth` checks sources in a deterministic order:
+
+1. **Server** (if provided) β the full auth provider's `verify_token` runs first
+2. **Verifiers** β each `TokenVerifier` is tried in list order
+
+The first source that returns a valid `AccessToken` wins. If every source returns `None`, the request receives a 401 response.
+
+This ordering means the server acts as the "primary" authentication path, with verifiers as fallbacks for tokens the server doesn't recognize.
+
+## Verifiers Only
+
+You don't always need a full OAuth server. If your server only needs to accept tokens from multiple issuers, pass verifiers without a server:
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.auth import MultiAuth
+from fastmcp.server.auth.providers.jwt import JWTVerifier, StaticTokenVerifier
+
+auth = MultiAuth(
+ verifiers=[
+ JWTVerifier(
+ jwks_uri="https://issuer-a.example.com/.well-known/jwks.json",
+ issuer="https://issuer-a.example.com",
+ audience="my-server",
+ ),
+ JWTVerifier(
+ jwks_uri="https://issuer-b.example.com/.well-known/jwks.json",
+ issuer="https://issuer-b.example.com",
+ audience="my-server",
+ ),
+ ],
+)
+
+mcp = FastMCP("Multi-Issuer Server", auth=auth)
+```
+
+Without a server, no OAuth routes or metadata are served. This is appropriate for internal systems where clients already know how to obtain tokens.
+
+## API Reference
+
+### MultiAuth
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| `server` | `AuthProvider \| None` | Optional auth provider that owns routes and OAuth metadata. Also tried first for token verification. |
+| `verifiers` | `list[TokenVerifier] \| TokenVerifier` | One or more token verifiers tried after the server. |
+| `base_url` | `str \| None` | Override the base URL. Defaults to the server's `base_url`. |
+| `required_scopes` | `list[str] \| None` | Override required scopes. Defaults to the server's scopes. |
diff --git a/docs/servers/auth/token-verification.mdx b/docs/servers/auth/token-verification.mdx
index 890214c6d..a9146135f 100644
--- a/docs/servers/auth/token-verification.mdx
+++ b/docs/servers/auth/token-verification.mdx
@@ -321,6 +321,81 @@ print(f"Test token: {test_token}")
This pattern enables comprehensive testing of JWT validation logic without depending on external token issuers. The generated tokens are cryptographically valid and will pass all standard JWT validation checks.
+## HTTP Client Customization
+
+
+
+All token verifiers that make HTTP calls accept an optional `http_client` parameter. This lets you provide your own `httpx.AsyncClient` for connection pooling, custom TLS configuration, or proxy settings.
+
+### Connection Pooling
+
+By default, each token verification call creates a fresh HTTP client. Under high load, this means repeated TCP connections and TLS handshakes. Providing a shared client enables connection pooling across calls:
+
+```python
+import httpx
+from fastmcp import FastMCP
+from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
+
+# Create a shared client with connection pooling
+http_client = httpx.AsyncClient(
+ timeout=10,
+ limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+)
+
+verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.yourcompany.com/oauth/introspect",
+ client_id="mcp-resource-server",
+ client_secret="your-client-secret",
+ http_client=http_client,
+)
+
+mcp = FastMCP(name="Protected API", auth=verifier)
+```
+
+The same pattern works for `JWTVerifier` when using JWKS endpoints:
+
+```python
+from fastmcp.server.auth.providers.jwt import JWTVerifier
+
+verifier = JWTVerifier(
+ jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json",
+ issuer="https://auth.yourcompany.com",
+ http_client=http_client,
+)
+```
+
+
+`JWTVerifier` does not support `http_client` when `ssrf_safe=True`. SSRF-safe mode requires a hardened transport that validates DNS resolution and connection targets, which cannot be guaranteed with a user-provided client. Attempting to use both will raise a `ValueError`.
+
+
+
+When you provide an `http_client`, you are responsible for its lifecycle. The verifier will not close it. Use the server's `lifespan` to manage client cleanup:
+
+```python
+from contextlib import asynccontextmanager
+from fastmcp import FastMCP
+from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
+
+http_client = httpx.AsyncClient(timeout=10)
+
+verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/introspect",
+ client_id="my-service",
+ client_secret="secret",
+ http_client=http_client,
+)
+
+@asynccontextmanager
+async def lifespan(app):
+ yield
+ await http_client.aclose()
+
+mcp = FastMCP(name="My API", auth=verifier, lifespan=lifespan)
+```
+
+
+The convenience providers (`GitHubProvider`, `GoogleProvider`, `DiscordProvider`, `WorkOSProvider`, `AzureProvider`) also accept `http_client` and pass it through to their internal token verifier.
+
## Production Configuration
For production deployments, load sensitive configuration from environment variables:
diff --git a/docs/servers/providers/mounting.mdx b/docs/servers/composition.mdx
similarity index 60%
rename from docs/servers/providers/mounting.mdx
rename to docs/servers/composition.mdx
index 93d1acd06..42523a5cd 100644
--- a/docs/servers/providers/mounting.mdx
+++ b/docs/servers/composition.mdx
@@ -1,7 +1,7 @@
---
-title: Mounting Servers
-sidebarTitle: Mounting
-description: Compose servers by mounting one inside another
+title: Composing Servers
+sidebarTitle: Composition
+description: Combine multiple servers into one
icon: puzzle-piece
---
@@ -9,42 +9,29 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
-Mounting lets you combine multiple FastMCP servers into one. When you mount a server, all its components become available through the parent. Under the hood, FastMCP uses `FastMCPProvider` (v3.0.0+) to source components from the mounted server.
+As your application grows, you'll want to split it into focused servers β one for weather, one for calendar, one for admin β and combine them into a single server that clients connect to. That's what `mount()` does.
-## Why Mount Servers
-
-Large applications benefit from modular organization. Rather than defining all components in one massive file, create focused servers for specific domains and combine them:
-
-- **Modularity**: Break down applications into smaller, focused servers
-- **Reusability**: Create utility servers and mount them wherever needed
-- **Teamwork**: Different teams can work on separate servers
-- **Organization**: Keep related functionality grouped together
-
-## Basic Mounting
-
-Use `mount()` to add another server's components to your server:
+When you mount a server, all its tools, resources, and prompts become available through the parent. The connection is live: add a tool to the child after mounting, and it's immediately visible through the parent.
```python
from fastmcp import FastMCP
-# Create focused subservers
-weather_server = FastMCP("Weather")
+weather = FastMCP("Weather")
-@weather_server.tool
+@weather.tool
def get_forecast(city: str) -> str:
"""Get weather forecast for a city."""
return f"Sunny in {city}"
-@weather_server.resource("data://cities")
+@weather.resource("data://cities")
def list_cities() -> list[str]:
"""List supported cities."""
return ["London", "Paris", "Tokyo"]
-# Create main server and mount the subserver
main = FastMCP("MainApp")
-main.mount(weather_server)
+main.mount(weather)
-# Now main has access to get_forecast and data://cities
+# main now serves get_forecast and data://cities
```
## Mounting External Servers
@@ -156,20 +143,9 @@ main.mount(calendar, namespace="calendar")
Namespacing uses [transforms](/servers/transforms/transforms) under the hood.
-## Mounting vs Importing
+## Dynamic Composition
-FastMCP offers two ways to combine servers:
-
-| Feature | `mount()` | `import_server()` |
-|---------|-----------|-------------------|
-| **Link Type** | Live (dynamic) | One-time copy (static) |
-| **Updates** | Changes reflected immediately | Changes not reflected |
-| **Performance** | Runtime delegation | Faster - no delegation |
-| **Use Case** | Modular runtime composition | Bundling finalized components |
-
-### Live Mounting
-
-With `mount()`, changes to the subserver are immediately reflected:
+Because `mount()` creates a live link, you can add components to a child server after mounting and they'll be immediately available through the parent:
```python
main = FastMCP("Main")
@@ -179,53 +155,8 @@ main.mount(dynamic_server, namespace="dynamic")
@dynamic_server.tool
def added_later() -> str:
return "Added after mounting!"
-
-# This works because mount() creates a live link
```
-### Static Importing
-
-With `import_server()`, components are copied once at import time:
-
-```python
-main = FastMCP("Main")
-
-async def setup():
- await main.import_server(static_server, namespace="static")
-
-# Changes to static_server after this point are NOT reflected in main
-```
-
-## Direct vs Proxy Mounting
-
-
-
-FastMCP supports two mounting modes:
-
-### Direct Mounting (Default)
-
-The parent server directly accesses the mounted server's objects in memory:
-
-```python
-main.mount(subserver, namespace="api")
-```
-
-- No client lifecycle events on mounted server
-- Mounted server's lifespan is not executed
-- Communication via direct method calls
-
-### Proxy Mounting
-
-
-The `as_proxy` parameter is deprecated. Mounted servers now always have their lifespan and middleware invoked. To create a proxy server explicitly, use `create_proxy()` from `fastmcp.server`.
-
-
-Previously, the parent server could treat the mounted server as a separate entity with its own lifecycle. This behavior is now the default for all mounted servers:
-
-- Full client lifecycle events on mounted server
-- Mounted server's lifespan is executed
-- Communication via in-memory Client transport
-
## Tag Filtering
@@ -253,16 +184,13 @@ prod_app.enable(tags={"production"}, only=True)
## Performance Considerations
-When using live mounting, operations like `list_tools()` on the parent server are affected by the performance of all mounted servers. This is particularly noticeable with:
+Operations like `list_tools()` on the parent are affected by the performance of all mounted servers. This is particularly noticeable with:
- HTTP-based mounted servers (300-400ms vs 1-2ms for local tools)
- Mounted servers with slow initialization
- Deep mounting hierarchies
-If low latency is critical, consider:
-- Using `import_server()` for static composition
-- Implementing caching strategies
-- Limiting mounting depth
+If low latency is critical, consider implementing caching strategies or limiting mounting depth.
## Custom Routes
diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx
index 27dd3fd0c..d13f43952 100644
--- a/docs/servers/dependency-injection.mdx
+++ b/docs/servers/dependency-injection.mdx
@@ -10,7 +10,7 @@ import { VersionBadge } from "/snippets/version-badge.mdx";
FastMCP uses dependency injection to provide runtime values to your tools, resources, and prompts. Instead of passing context through every layer of your code, you declare what you need as parameter defaultsβFastMCP resolves them automatically when your function runs.
-The dependency injection system is powered by [Docket](https://github.com/chrisguidry/docket). Core DI features like `Depends()` and `CurrentContext()` work without installing Docket. For background tasks and advanced task-related dependencies, install `fastmcp[tasks]`. For comprehensive coverage of dependency patterns, see the [Docket dependency documentation](https://chrisguidry.github.io/docket/dependencies/).
+The dependency injection system is powered by [Docket](https://github.com/chrisguidry/docket) and its dependency system [uncalled-for](https://github.com/chrisguidry/uncalled-for). Core DI features like `Depends()` and `CurrentContext()` work without installing Docket. For background tasks and advanced task-related dependencies, install `fastmcp[tasks]`. For comprehensive coverage of dependency patterns, see the [Docket dependency documentation](https://docket.lol/en/latest/dependency-injection/).
Dependency parameters are automatically excluded from the MCP schemaβclients never see them as callable parameters. This separation keeps your function signatures clean while giving you access to the runtime context you need.
diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx
index 1c66fea33..40449ae10 100644
--- a/docs/servers/middleware.mdx
+++ b/docs/servers/middleware.mdx
@@ -64,7 +64,7 @@ This ordering matters. Place error handling early so it catches exceptions from
### Server Composition
-When using [mounted servers](/servers/providers/mounting), middleware behavior follows a clear hierarchy:
+When using [mounted servers](/servers/composition), middleware behavior follows a clear hierarchy:
- **Parent middleware** runs for all requests, including those routed to mounted servers
- **Mounted server middleware** only runs for requests handled by that specific server
diff --git a/docs/servers/providers/overview.mdx b/docs/servers/providers/overview.mdx
index 178a393ef..d3e3e4e5f 100644
--- a/docs/servers/providers/overview.mdx
+++ b/docs/servers/providers/overview.mdx
@@ -66,7 +66,7 @@ When a client requests a tool, FastMCP queries providers in registration order.
**You can ignore providers entirely** if you're building a simple server with decorators. Just use `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` - FastMCP handles the rest.
**Learn about providers when** you want to:
-- [Mount another server](/servers/providers/mounting) into yours
+- [Mount another server](/servers/composition) into yours
- [Proxy a remote server](/servers/providers/proxy) through yours
- [Control visibility state](/servers/visibility) of components
- [Build dynamic sources](/servers/providers/custom) like database-backed tools
@@ -74,7 +74,7 @@ When a client requests a tool, FastMCP queries providers in registration order.
## Next Steps
- [Local](/servers/providers/local) - How decorators work
-- [Mounting](/servers/providers/mounting) - Compose servers together
+- [Mounting](/servers/composition) - Compose servers together
- [Proxying](/servers/providers/proxy) - Connect to remote servers
- [Transforms](/servers/transforms/transforms) - Namespace, rename, and modify components
- [Visibility](/servers/visibility) - Control which components clients can access
diff --git a/docs/servers/providers/proxy.mdx b/docs/servers/providers/proxy.mdx
index 805cb34be..a2def6892 100644
--- a/docs/servers/providers/proxy.mdx
+++ b/docs/servers/providers/proxy.mdx
@@ -54,7 +54,7 @@ This gives you:
- Session isolation to prevent context mixing
-To mount a proxy inside another FastMCP server, see [Mounting External Servers](/servers/providers/mounting#mounting-external-servers).
+To mount a proxy inside another FastMCP server, see [Mounting External Servers](/servers/composition#mounting-external-servers).
## Transport Bridging
@@ -258,7 +258,7 @@ Proxying introduces network latency:
When mounting proxy servers, this latency affects all operations on the parent server.
-For low-latency requirements, consider using [`import_server()`](/servers/providers/mounting#static-importing) to copy tools at startup.
+For low-latency requirements, consider caching strategies or limiting mounting depth.
## Advanced Usage
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index 79510db69..0a8a772aa 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -332,15 +332,7 @@ notice_resource = TextResource(
)
mcp.add_resource(notice_resource)
-# 3. Using a custom key different from the URI
-special_resource = TextResource(
- uri="resource://common-notice",
- name="Special Notice",
- text="This is a special notice with a custom storage key.",
-)
-mcp.add_resource(special_resource, key="resource://custom-key")
-
-# 4. Exposing a directory listing
+# 3. Exposing a directory listing
data_dir_path = Path("./app_data").resolve()
if data_dir_path.is_dir():
data_listing_resource = DirectoryResource(
@@ -364,24 +356,6 @@ if data_dir_path.is_dir():
Use these when the content is static or sourced directly from a file/URL, bypassing the need for a dedicated Python function.
-#### Custom Resource Keys
-
-
-
-When adding resources directly with `mcp.add_resource()`, you can optionally provide a custom storage key:
-
-```python
-# Creating a resource with standard URI as the key
-resource = TextResource(uri="resource://data")
-mcp.add_resource(resource) # Will be stored and accessed using "resource://data"
-
-# Creating a resource with a custom key
-special_resource = TextResource(uri="resource://special-data")
-mcp.add_resource(special_resource, key="internal://data-v2") # Will be stored and accessed using "internal://data-v2"
-```
-
-Note that this parameter is only available when using `add_resource()` directly and not through the `@resource` decorator, as URIs are provided explicitly when using the decorator.
-
### Notifications
diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx
index e3624ea4f..ed84b5e48 100644
--- a/docs/servers/server.mdx
+++ b/docs/servers/server.mdx
@@ -67,25 +67,14 @@ The `FastMCP` constructor accepts several configuration options. The most common
A list of tools (or functions to convert to tools) to add to the server. In some cases, providing tools programmatically may be more convenient than using the `@mcp.tool` decorator
+
+
-
- Only expose components with at least one matching tag
+ Server-level [transforms](/servers/transforms/transforms) to apply to all components. Transforms modify how tools, resources, and prompts are presented to clients β for example, [search transforms](/servers/transforms/tool-search) replace large catalogs with on-demand discovery, and [CodeMode](/servers/transforms/code-mode) lets LLMs write scripts that chain tool calls in a sandbox
-
- Hide components with any matching tag
-
-
-
- How to handle duplicate tool registrations
-
-
-
- How to handle duplicate resource registrations
-
-
-
- How to handle duplicate prompt registrations
+
+ How to handle duplicate component registrations
@@ -177,25 +166,28 @@ def admin_tool() -> str:
```
The filtering logic works as follows:
-- **Include tags**: If specified, only components with at least one matching tag are exposed
-- **Exclude tags**: Components with any matching tag are filtered out
-- **Precedence**: Exclude tags always take priority over include tags
+- **Enable with `only=True`**: Switches to allowlist mode β only components with at least one matching tag are exposed
+- **Disable**: Components with any matching tag are hidden
+- **Precedence**: Later calls override earlier ones, so call `disable` after `enable` to exclude from an allowlist
To ensure a component is never exposed, you can set `enabled=False` on the component itself. See the component-specific documentation for details.
-Configure tag-based filtering when creating your server.
+Configure tag-based filtering after creating your server.
```python
# Only expose components tagged with "public"
-mcp = FastMCP(include_tags={"public"})
+mcp = FastMCP()
+mcp.enable(tags={"public"}, only=True)
# Hide components tagged as "internal" or "deprecated"
-mcp = FastMCP(exclude_tags={"internal", "deprecated"})
+mcp = FastMCP()
+mcp.disable(tags={"internal", "deprecated"})
# Combine both: show admin tools but hide deprecated ones
-mcp = FastMCP(include_tags={"admin"}, exclude_tags={"deprecated"})
+mcp = FastMCP()
+mcp.enable(tags={"admin"}, only=True).disable(tags={"deprecated"})
```
This filtering applies to all component types (tools, resources, resource templates, and prompts) and affects both listing and access.
diff --git a/docs/servers/testing.mdx b/docs/servers/testing.mdx
new file mode 100644
index 000000000..7bd8600c5
--- /dev/null
+++ b/docs/servers/testing.mdx
@@ -0,0 +1,104 @@
+---
+title: Testing your FastMCP Server
+sidebarTitle: Testing
+description: How to test your FastMCP server.
+icon: vial
+---
+
+The best way to ensure a reliable and maintainable FastMCP Server is to test it! The FastMCP Client combined with Pytest provides a simple and powerful way to test your FastMCP servers.
+
+## Prerequisites
+
+Testing FastMCP servers requires `pytest-asyncio` to handle async test functions and fixtures. Install it as a development dependency:
+
+```bash
+pip install pytest-asyncio
+```
+
+We recommend configuring pytest to automatically handle async tests by setting the asyncio mode to `auto` in your `pyproject.toml`:
+
+```toml
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+```
+
+This eliminates the need to decorate every async test with `@pytest.mark.asyncio`.
+
+## Testing with Pytest Fixtures
+
+Using Pytest Fixtures, you can wrap your FastMCP Server in a Client instance that makes interacting with your server fast and easy. This is especially useful when building your own MCP Servers and enables a tight development loop by allowing you to avoid using a separate tool like MCP Inspector during development:
+
+```python
+import pytest
+from fastmcp.client import Client
+from fastmcp.client.transports import FastMCPTransport
+
+from my_project.main import mcp
+
+@pytest.fixture
+async def main_mcp_client():
+ async with Client(transport=mcp) as mcp_client:
+ yield mcp_client
+
+async def test_list_tools(main_mcp_client: Client[FastMCPTransport]):
+ list_tools = await main_mcp_client.list_tools()
+
+ assert len(list_tools) == 5
+```
+
+We recommend the [inline-snapshot library](https://github.com/15r10nk/inline-snapshot) for asserting complex data structures coming from your MCP Server. This library allows you to write tests that are easy to read and understand, and are also easy to update when the data structure changes.
+
+```python
+from inline_snapshot import snapshot
+
+async def test_list_tools(main_mcp_client: Client[FastMCPTransport]):
+ list_tools = await main_mcp_client.list_tools()
+
+ assert list_tools == snapshot()
+```
+
+Simply run `pytest --inline-snapshot=fix,create` to fill in the `snapshot()` with actual data.
+
+
+For values that change you can leverage the [dirty-equals](https://github.com/samuelcolvin/dirty-equals) library to perform flexible equality assertions on dynamic or non-deterministic values.
+
+
+Using the pytest `parametrize` decorator, you can easily test your tools with a wide variety of inputs.
+
+```python
+import pytest
+from my_project.main import mcp
+
+from fastmcp.client import Client
+from fastmcp.client.transports import FastMCPTransport
+@pytest.fixture
+async def main_mcp_client():
+ async with Client(mcp) as client:
+ yield client
+
+
+@pytest.mark.parametrize(
+ "first_number, second_number, expected",
+ [
+ (1, 2, 3),
+ (2, 3, 5),
+ (3, 4, 7),
+ ],
+)
+async def test_add(
+ first_number: int,
+ second_number: int,
+ expected: int,
+ main_mcp_client: Client[FastMCPTransport],
+):
+ result = await main_mcp_client.call_tool(
+ name="add", arguments={"x": first_number, "y": second_number}
+ )
+ assert result.data is not None
+ assert isinstance(result.data, int)
+ assert result.data == expected
+```
+
+
+The [FastMCP Repository contains thousands of tests](https://github.com/PrefectHQ/fastmcp/tree/main/tests) for the FastMCP Client and Server. Everything from connecting to remote MCP servers, to testing tools, resources, and prompts is covered, take a look for inspiration!
+
\ No newline at end of file
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index 8357c1357..91d96d1be 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -126,6 +126,12 @@ def search_products_implementation(query: str, category: str | None = None) -> l
Optional version identifier for this tool. See [Versioning](/servers/versioning) for details.
+
+
+
+
+ Optional JSON schema for the tool's output. When provided, the tool must return structured output matching this schema. If not provided, FastMCP automatically generates a schema from the function's return type annotation. See [Output Schemas](#output-schemas) for details.
+
### Using with Methods
diff --git a/docs/servers/transforms/code-mode.mdx b/docs/servers/transforms/code-mode.mdx
new file mode 100644
index 000000000..f09c22b6f
--- /dev/null
+++ b/docs/servers/transforms/code-mode.mdx
@@ -0,0 +1,339 @@
+---
+title: Code Mode
+sidebarTitle: Code Mode
+description: Let LLMs write Python to orchestrate tools in a sandbox
+icon: flask
+tag: NEW
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+
+CodeMode is experimental. The core interface is stable, but the specific discovery tools and their parameters may evolve as we learn more about what works best in practice.
+
+
+Standard MCP tool usage has two scaling problems. First, every tool in the catalog is loaded into the LLM's context upfront β with hundreds of tools, that's tens of thousands of tokens spent before the LLM even reads the user's request. Second, every tool call is a round-trip: the LLM calls a tool, the result passes back through the context window, the LLM reasons about it, calls another tool, and so on. Intermediate results that only exist to feed the next step still burn tokens flowing through the model.
+
+CodeMode solves both problems. Instead of seeing your entire tool catalog, the LLM gets meta-tools for discovering what's available and for writing and executing code that calls the tools it needs. It discovers on demand, writes a script that chains tool calls in a sandbox, and gets back only the final answer.
+
+The approach was introduced by Cloudflare in [Code Mode](https://blog.cloudflare.com/code-mode/) and explored further by Anthropic in [Code Execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp).
+
+## Getting Started
+
+
+CodeMode requires the `code-mode` extra for sandbox support. Install it with `pip install "fastmcp[code-mode]"`.
+
+
+You take a normal server with normally registered tools and add a `CodeMode` transform. The transform wraps your existing tools in the code mode machinery β your tool functions don't change at all:
+
+```python
+from fastmcp import FastMCP
+from fastmcp.experimental.transforms.code_mode import CodeMode
+
+mcp = FastMCP("Server", transforms=[CodeMode()])
+
+@mcp.tool
+def add(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+@mcp.tool
+def multiply(x: int, y: int) -> int:
+ """Multiply two numbers."""
+ return x * y
+```
+
+Clients connecting to this server no longer see `add` and `multiply` directly. Instead, they see the meta-tools that CodeMode provides β tools for discovering what's available and executing code against it. The original tools are still there, but they're accessed through the CodeMode layer.
+
+## Discovery
+
+Before the LLM can write code that calls your tools, it needs to know what tools exist and how to call them. This is the **discovery** process β the LLM uses meta-tools to learn about your tool catalog, then writes code against what it finds.
+
+The fundamental tradeoff is **tokens vs. round-trips**. Each discovery step is an LLM round-trip: the model calls a tool, waits for the response, reasons about it, then decides what to do next. More steps mean less wasted context (each step is targeted) but more latency and API calls. Fewer steps mean the LLM gets information upfront but pays for detail it might not need.
+
+By default, CodeMode gives the LLM three tools β `search`, `get_schema`, and `execute` β creating a three-stage discovery flow:
+
+
+
+First, the LLM uses the `search` meta-tool to find tools by keyword.
+
+For example, it might do `search(query="math numbers")` and receive the following response:
+
+```
+- add: Add two numbers.
+- multiply: Multiply two numbers.
+```
+
+This lets the LLM know which tools are available and what they do, significantly reducing the surface area it needs to consider.
+
+
+
+Next, the LLM calls `get_schema` to get parameter details for the tools it found in the previous step.
+
+For example, it might do `get_schema(tools=["add", "multiply"])` and receive the following response:
+
+```
+### add
+
+Add two numbers.
+
+**Parameters**
+- `x` (integer, required)
+- `y` (integer, required)
+
+### multiply
+
+Multiply two numbers.
+
+**Parameters**
+- `x` (integer, required)
+- `y` (integer, required)
+```
+
+Now the LLM knows the parameters for the tools it found, and can write code that chains the tool calls. If it needed more detail, it could have called `get_schema` with `detail="full"` to get the complete JSON schema.
+
+
+
+Finally, the LLM writes and executes code that chains the tool calls in a Python sandbox. Inside the sandbox, `call_tool(name, params)` is the only function available. The LLM uses this to compose tools into a workflow and return a final result.
+
+For example, it might write the following code and call the `execute` tool with it:
+
+```python
+a = await call_tool("add", {"x": 3, "y": 4})
+b = await call_tool("multiply", {"x": a, "y": 2})
+return b
+```
+
+The result is returned to the LLM.
+
+
+
+This three-stage flow works well for most servers β each step pulls in only the information needed for the next one, keeping context usage minimal. But CodeMode's discovery surface is fully configurable. The sections below explain each built-in discovery tool and how to combine them into different patterns.
+
+## Discovery Tools
+
+CodeMode ships with four built-in discovery tools: `Search`, `GetSchemas`, `GetTags`, and `ListTools`. By default, only `Search` and `GetSchemas` are enabled. Each tool supports a `default_detail` parameter that sets the default verbosity level, and the LLM can override the detail level on any individual call.
+
+### Detail Levels
+
+`Search` and `GetSchemas` share the same three detail levels, so the same `detail` value produces the same output format regardless of which tool the LLM calls:
+
+| Level | Output | Token cost |
+|---|---|---|
+| `"brief"` | Tool names and one-line descriptions | Cheapest β good for scanning |
+| `"detailed"` | Compact markdown with parameter names, types, and required markers | Medium β often enough to write code |
+| `"full"` | Complete JSON schema | Most expensive β everything |
+
+`Search` defaults to `"brief"` and `GetSchemas` defaults to `"detailed"`.
+
+### Search
+
+`Search` finds tools by natural-language query using BM25 ranking. At its default `"brief"` detail, results include just tool names and descriptions β enough to decide which tools are worth inspecting further. The LLM can request `"detailed"` to get parameter schemas inline, or `"full"` for the complete JSON.
+
+Search results include an annotation like `"2 of 10 tools:"` when the result set is smaller than the full catalog, so the LLM knows there are more tools to discover with different queries.
+
+You can cap result count with `default_limit`. The LLM can also override the limit per call. This is useful for large catalogs where you want to keep search results focused:
+
+```python
+Search(default_limit=5) # return at most 5 results per search
+```
+
+If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` parameter so the LLM can narrow results to specific categories before searching.
+
+### GetSchemas
+
+`GetSchemas` returns parameter details for specific tools by name. At its default `"detailed"` level, it renders compact markdown with parameter names, types, and required markers. At `"full"`, it returns the complete JSON schema β useful when tools have deeply nested parameters that the compact format doesn't capture.
+
+### GetTags
+
+`GetTags` lets the LLM browse tools by category using [tag](/servers/tools#tags) metadata. At brief detail, the LLM sees tag names with counts. At full detail, it sees tools listed under each tag:
+
+```
+- math (3 tools)
+- text (2 tools)
+- untagged (1 tool)
+```
+
+`GetTags` isn't included in the defaults β add it when browsing by category would help the LLM orient itself in a large catalog. The LLM can browse tags first, then pass specific tags into Search to narrow results.
+
+### ListTools
+
+`ListTools` dumps the entire catalog at whatever detail level the LLM requests. It supports the same three detail levels as `Search` and `GetSchemas`, defaulting to `"brief"`.
+
+`ListTools` isn't included in the defaults β for large catalogs, search-based discovery is more token-efficient. But for smaller catalogs (under ~20 tools), letting the LLM see everything upfront can be faster than multiple search round-trips:
+
+```python
+from fastmcp.experimental.transforms.code_mode import CodeMode, ListTools, GetSchemas
+
+code_mode = CodeMode(
+ discovery_tools=[ListTools(), GetSchemas()],
+)
+```
+
+## Discovery Patterns
+
+The right discovery configuration depends on your server β how many tools you have and how complex their parameters are. It may be tempting to minimize round-trips by collapsing everything into fewer steps, but for the complex servers that benefit most from CodeMode, our experience is that staged discovery leads to better results. Flooding the LLM with detailed schemas for tools it doesn't end up using can hurt more than the extra round-trip costs. Each pattern below is a complete, copyable configuration.
+
+### Three-Stage
+
+The default. The LLM searches for candidates, inspects schemas for the ones it wants, then writes code. Best for **large or complex tool sets** where you want to minimize context usage β the LLM only pays for schemas it actually needs.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.experimental.transforms.code_mode import CodeMode
+
+mcp = FastMCP("Server", transforms=[CodeMode()])
+```
+
+If your tools use [tags](/servers/tools#tags), add `GetTags` so the LLM can browse by category before searching β giving it four stages of progressive disclosure:
+
+```python
+from fastmcp import FastMCP
+from fastmcp.experimental.transforms.code_mode import CodeMode
+from fastmcp.experimental.transforms.code_mode import GetTags, Search, GetSchemas
+
+code_mode = CodeMode(
+ discovery_tools=[GetTags(), Search(), GetSchemas()],
+)
+
+mcp = FastMCP("Server", transforms=[code_mode])
+```
+
+### Two-Stage
+
+Search returns parameter schemas inline, so the LLM can go straight from search to execute. Best for **smaller catalogs** where the extra tokens per search result are a reasonable price for one fewer round-trip.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.experimental.transforms.code_mode import CodeMode
+from fastmcp.experimental.transforms.code_mode import Search, GetSchemas
+
+code_mode = CodeMode(
+ discovery_tools=[Search(default_detail="detailed"), GetSchemas()],
+)
+
+mcp = FastMCP("Server", transforms=[code_mode])
+```
+
+`GetSchemas` is still available as a fallback β the LLM can call it with `detail="full"` if it encounters a tool with complex nested parameters where the compact markdown isn't enough.
+
+### Single-Stage
+
+Skip discovery entirely and bake tool instructions into the execute tool's description. Best for **very simple servers** where the LLM already knows what tools are available β maybe there are only a few, or they're described in the system prompt.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.experimental.transforms.code_mode import CodeMode
+
+code_mode = CodeMode(
+ discovery_tools=[],
+ execute_description=(
+ "Available tools:\n"
+ "- add(x: int, y: int) -> int: Add two numbers\n"
+ "- multiply(x: int, y: int) -> int: Multiply two numbers\n\n"
+ "Write Python using `await call_tool(name, params)` and `return` the result."
+ ),
+)
+
+mcp = FastMCP("Server", transforms=[code_mode])
+```
+
+## Custom Discovery Tools
+
+Discovery tools are composable β you can mix the built-ins with your own. Each discovery tool is a callable that receives catalog access and returns a `Tool`. The catalog accessor is a function (not the catalog itself) because the catalog is request-scoped β different users may see different tools based on auth.
+
+Here's a minimal example:
+
+```python
+from fastmcp.experimental.transforms.code_mode import CodeMode
+from fastmcp.experimental.transforms.code_mode import GetToolCatalog, GetSchemas
+from fastmcp.server.context import Context
+from fastmcp.tools.tool import Tool
+
+def list_all_tools(get_catalog: GetToolCatalog) -> Tool:
+ async def list_tools(ctx: Context) -> str:
+ """List all available tool names."""
+ tools = await get_catalog(ctx)
+ return ", ".join(t.name for t in tools)
+
+ return Tool.from_function(fn=list_tools, name="list_tools")
+
+code_mode = CodeMode(discovery_tools=[list_all_tools, GetSchemas()])
+```
+
+The LLM sees the docstring of each discovery tool's inner function as its description β that's how it learns what each tool does and when to use it. Write docstrings that explain what the tool returns and when the LLM should call it.
+
+Discovery tools and the execute tool can also have custom names:
+
+```python
+from fastmcp.experimental.transforms.code_mode import Search, GetSchemas
+
+code_mode = CodeMode(
+ discovery_tools=[
+ Search(name="find_tools"),
+ GetSchemas(name="describe"),
+ ],
+ execute_tool_name="run_workflow",
+)
+
+mcp = FastMCP("Server", transforms=[code_mode])
+```
+
+## Sandbox Configuration
+
+### Resource Limits
+
+The default `MontySandboxProvider` can enforce execution limits β timeouts, memory caps, recursion depth, and more. Without limits, LLM-generated scripts can run indefinitely.
+
+```python
+from fastmcp.experimental.transforms.code_mode import CodeMode
+from fastmcp.experimental.transforms.code_mode import MontySandboxProvider
+
+sandbox = MontySandboxProvider(
+ limits={"max_duration_secs": 10, "max_memory": 50_000_000},
+)
+
+mcp = FastMCP("Server", transforms=[CodeMode(sandbox_provider=sandbox)])
+```
+
+All keys are optional β omit any to leave that dimension uncapped:
+
+| Key | Type | Description |
+|---|---|---|
+| `max_duration_secs` | `float` | Maximum wall-clock execution time |
+| `max_memory` | `int` | Memory ceiling in bytes |
+| `max_allocations` | `int` | Cap on total object allocations |
+| `max_recursion_depth` | `int` | Maximum recursion depth |
+| `gc_interval` | `int` | Garbage collection frequency |
+
+### Custom Sandbox Providers
+
+You can replace the default sandbox with any object implementing the `SandboxProvider` protocol:
+
+```python
+from collections.abc import Callable
+from typing import Any
+
+from fastmcp.experimental.transforms.code_mode import CodeMode
+from fastmcp.experimental.transforms.code_mode import SandboxProvider
+
+class RemoteSandboxProvider:
+ async def run(
+ self,
+ code: str,
+ *,
+ inputs: dict[str, Any] | None = None,
+ external_functions: dict[str, Callable[..., Any]] | None = None,
+ ) -> Any:
+ # Send code to your remote sandbox runtime
+ ...
+
+mcp = FastMCP(
+ "Server",
+ transforms=[CodeMode(sandbox_provider=RemoteSandboxProvider())],
+)
+```
+
+The `external_functions` dict contains async callables injected into the sandbox scope β `execute` uses this to provide `call_tool`.
diff --git a/docs/servers/transforms/prompts-as-tools.mdx b/docs/servers/transforms/prompts-as-tools.mdx
index f0b499fc8..b68c0891d 100644
--- a/docs/servers/transforms/prompts-as-tools.mdx
+++ b/docs/servers/transforms/prompts-as-tools.mdx
@@ -116,7 +116,7 @@ result = await client.call_tool(
## Message Format
Rendered prompts return a messages array following the standard MCP format. Each message includes:
-- `role`: The message role (typically "user", "assistant", or "system")
+- `role`: The message role ("user" or "assistant")
- `content`: The message text content
Multi-message prompts are supported - the array will contain all messages in order.
diff --git a/docs/servers/transforms/tool-search.mdx b/docs/servers/transforms/tool-search.mdx
new file mode 100644
index 000000000..204004f5c
--- /dev/null
+++ b/docs/servers/transforms/tool-search.mdx
@@ -0,0 +1,173 @@
+---
+title: Tool Search
+sidebarTitle: Tool Search
+description: Replace large tool catalogs with on-demand search
+icon: magnifying-glass
+tag: NEW
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+When a server exposes hundreds or thousands of tools, sending the full catalog to an LLM wastes tokens and degrades tool selection accuracy. Search transforms solve this by replacing the tool listing with a search interface β the LLM discovers tools on demand instead of receiving everything upfront.
+
+## How It Works
+
+When you add a search transform, `list_tools()` returns just two synthetic tools instead of the full catalog:
+
+- **`search_tools`** finds tools matching a query and returns their full definitions
+- **`call_tool`** executes a discovered tool by name
+
+The original tools are still callable. They're hidden from the listing but remain fully functional β the search transform controls *discovery*, not *access*.
+
+Both synthetic tools search across tool names, descriptions, parameter names, and parameter descriptions. A search for `"email"` would match a tool named `send_email`, a tool with "email" in its description, or a tool with an `email_address` parameter.
+
+Search results are returned in the same JSON format as `list_tools`, including the full input schema, so the LLM can construct valid calls immediately without a second round-trip.
+
+## Search Strategies
+
+FastMCP provides two search transforms. They share the same interface β two synthetic tools, same configuration options β but differ in how they match queries to tools.
+
+### Regex Search
+
+`RegexSearchTransform` matches tools against a regex pattern using case-insensitive `re.search`. It has zero overhead and no index to build, making it a good default when the LLM knows roughly what it's looking for.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.transforms.search import RegexSearchTransform
+
+mcp = FastMCP("My Server", transforms=[RegexSearchTransform()])
+
+@mcp.tool
+def search_database(query: str, limit: int = 10) -> list[dict]:
+ """Search the database for records matching the query."""
+ ...
+
+@mcp.tool
+def delete_record(record_id: str) -> bool:
+ """Delete a record from the database by its ID."""
+ ...
+
+@mcp.tool
+def send_email(to: str, subject: str, body: str) -> bool:
+ """Send an email to the given recipient."""
+ ...
+```
+
+The LLM's `search_tools` call takes a `pattern` parameter β a regex string:
+
+```python
+# Exact substring match
+result = await client.call_tool("search_tools", {"pattern": "database"})
+# Returns: search_database, delete_record
+
+# Regex pattern
+result = await client.call_tool("search_tools", {"pattern": "send.*email|notify"})
+# Returns: send_email
+```
+
+Results are returned in catalog order. If the pattern is invalid regex, the search returns an empty list rather than raising an error.
+
+### BM25 Search
+
+`BM25SearchTransform` ranks tools by relevance using the [BM25 Okapi](https://en.wikipedia.org/wiki/Okapi_BM25) algorithm. It's better for natural language queries because it scores each tool based on term frequency and document rarity, returning results ranked by relevance rather than filtering by match/no-match.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.transforms.search import BM25SearchTransform
+
+mcp = FastMCP("My Server", transforms=[BM25SearchTransform()])
+
+# ... define tools ...
+```
+
+The LLM's `search_tools` call takes a `query` parameter β natural language:
+
+```python
+result = await client.call_tool("search_tools", {
+ "query": "tools for deleting things from the database"
+})
+# Returns: delete_record ranked first, search_database second
+```
+
+BM25 builds an in-memory index from the searchable text of all tools. The index is created lazily on the first search and automatically rebuilt whenever the tool catalog changes β for example, when tools are added, removed, or have their descriptions updated. The staleness check is based on a hash of all searchable text, so description changes are detected even when tool names stay the same.
+
+### Which to Choose
+
+Use **regex** when your LLM is good at constructing targeted patterns and you want deterministic, predictable results. Regex is also simpler to debug β you can see exactly what pattern was sent.
+
+Use **BM25** when your LLM tends to describe what it needs in natural language, or when your tool catalog has nuanced descriptions where relevance ranking adds value. BM25 handles partial matches and synonyms better because it scores on individual terms rather than requiring a single pattern to match.
+
+## Configuration
+
+Both search transforms accept the same configuration options.
+
+### Limiting Results
+
+By default, search returns at most 5 tools. Adjust `max_results` based on your catalog size and how much context you want the LLM to receive per search:
+
+```python
+mcp.add_transform(RegexSearchTransform(max_results=10))
+mcp.add_transform(BM25SearchTransform(max_results=3))
+```
+
+With regex, results stop as soon as the limit is reached (first N matches in catalog order). With BM25, all tools are scored and the top N by relevance are returned.
+
+### Pinning Tools
+
+Some tools should always be visible regardless of search. Use `always_visible` to pin them in the listing alongside the synthetic tools:
+
+```python
+mcp.add_transform(RegexSearchTransform(
+ always_visible=["help", "status"],
+))
+
+# list_tools returns: help, status, search_tools, call_tool
+```
+
+Pinned tools appear directly in `list_tools` so the LLM can call them without searching. They're excluded from search results to avoid duplication.
+
+### Custom Tool Names
+
+The default names `search_tools` and `call_tool` can be changed to avoid conflicts with real tools:
+
+```python
+mcp.add_transform(RegexSearchTransform(
+ search_tool_name="find_tools",
+ call_tool_name="run_tool",
+))
+```
+
+## The `call_tool` Proxy
+
+The `call_tool` proxy forwards calls to the real tool. When a client calls `call_tool(name="search_database", arguments={...})`, the proxy resolves `search_database` through the server's normal tool pipeline β including transforms and middleware β and executes it.
+
+The proxy rejects attempts to call the synthetic tools themselves. `call_tool(name="call_tool")` raises an error rather than recursing.
+
+
+Tools discovered through search can also be called directly via `client.call_tool("search_database", {...})` without going through the proxy. The proxy exists for LLMs that only know about the tools returned by `list_tools` and need a way to invoke discovered tools through a tool they can see.
+
+
+## Auth and Visibility
+
+Search results respect the full authorization pipeline. Tools filtered by middleware, visibility transforms, or component-level auth checks won't appear in search results.
+
+The search tool queries `list_tools()` through the complete pipeline at search time, so the same filtering that controls what a client sees in the listing also controls what they can discover through search.
+
+```python
+from fastmcp.server.transforms import Visibility
+from fastmcp.server.transforms.search import RegexSearchTransform
+
+mcp = FastMCP("My Server")
+
+# ... define tools ...
+
+# Disable admin tools globally
+mcp.add_transform(Visibility(False, tags={"admin"}))
+
+# Add search β admin tools won't appear in results
+mcp.add_transform(RegexSearchTransform())
+```
+
+Session-level visibility changes (via `ctx.disable_components()`) are also reflected immediately in search results.
diff --git a/docs/servers/transforms/transforms.mdx b/docs/servers/transforms/transforms.mdx
index 5c64c9b00..4008f86b4 100644
--- a/docs/servers/transforms/transforms.mdx
+++ b/docs/servers/transforms/transforms.mdx
@@ -29,8 +29,10 @@ FastMCP provides several transforms for common use cases:
- **[Namespace](/servers/transforms/namespace)** - Prefix component names to prevent conflicts when composing servers
- **[Tool Transformation](/servers/transforms/tool-transformation)** - Rename tools, modify descriptions, reshape arguments
- **[Enabled](/servers/visibility)** - Control which components are visible at runtime
+- **[Tool Search](/servers/transforms/tool-search)** - Replace large tool catalogs with on-demand search
- **[Resources as Tools](/servers/transforms/resources-as-tools)** - Expose resources to tool-only clients
- **[Prompts as Tools](/servers/transforms/prompts-as-tools)** - Expose prompts to tool-only clients
+- **[Code Mode (Experimental)](/servers/transforms/code-mode)** - Replace many tools with programmable `search` + `execute`
## Server vs Provider Transforms
@@ -79,14 +81,12 @@ Server transforms apply to all components from all providers. They run after pro
from fastmcp import FastMCP
from fastmcp.server.transforms import Namespace
-mcp = FastMCP("Server")
+mcp = FastMCP("Server", transforms=[Namespace("v1")])
@mcp.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
-mcp.add_transform(Namespace("v1"))
-
# All tools become v1_toolname
```
diff --git a/docs/servers/versioning.mdx b/docs/servers/versioning.mdx
index 2d180a851..4c44a73bd 100644
--- a/docs/servers/versioning.mdx
+++ b/docs/servers/versioning.mdx
@@ -60,7 +60,7 @@ VersionFilter(version_gte="2.0", version_lt="3.0")
```
-**Unversioned components are exempt from version filtering.** A `VersionFilter` only affects versioned components - unversioned components always pass through regardless of the filter's constraints. This ensures that adding version filtering to a server with mixed versioned and unversioned tools doesn't accidentally hide the unversioned ones. To prevent confusion, FastMCP forbids mixing versioned and unversioned components with the same name.
+**Unversioned components are exempt from version filtering by default.** Set `include_unversioned=False` to exclude them. Including them by default ensures that adding version filtering to a server with mixed versioned and unversioned components doesn't accidentally hide the unversioned ones. To prevent confusion, FastMCP forbids mixing versioned and unversioned components with the same name.
### Filtering Mounted Servers
@@ -138,7 +138,7 @@ def calculate(x: int, y: int, z: int = 0) -> int:
The error message explains the conflict: "Cannot add versioned tool 'calculate' (version='2.0'): an unversioned tool with this name already exists. Either version all components or none."
-This restriction exists because unversioned components always pass through version filters. If you could mix versioned and unversioned components, you'd have no way to filter out the unversioned one using `VersionFilter`. By enforcing consistency at registration, FastMCP ensures version filtering behaves predictably.
+This restriction helps keep version filtering behavior predictable.
Resources and prompts follow the same pattern.
diff --git a/docs/v2/clients/prompts.mdx b/docs/v2/clients/prompts.mdx
index a6d6e155b..291503f7b 100644
--- a/docs/v2/clients/prompts.mdx
+++ b/docs/v2/clients/prompts.mdx
@@ -190,9 +190,9 @@ async with client:
"expertise": "python programming"
})
- # Typically returns messages with role="system"
- system_message = result.messages[0]
- print(f"System prompt: {system_message.content}")
+ # Access the returned messages
+ message = result.messages[0]
+ print(f"Prompt: {message.content}")
```
### Conversation Templates
diff --git a/docs/v2/deployment/http.mdx b/docs/v2/deployment/http.mdx
index 1cc0f40b4..eafa773b3 100644
--- a/docs/v2/deployment/http.mdx
+++ b/docs/v2/deployment/http.mdx
@@ -719,6 +719,134 @@ Both parameters are required for production. Without an explicit signing key, ke
For more details on the token architecture and key management, see [OAuth Proxy Key and Storage Management](/v2/servers/auth/oauth-proxy#key-and-storage-management).
+## Reverse Proxy (nginx)
+
+In production, you'll typically run your FastMCP server behind a reverse proxy like nginx. A reverse proxy provides TLS termination, domain-based routing, static file serving, and an additional layer of security between the internet and your application.
+
+### Running FastMCP as a Linux Service
+
+Before configuring nginx, you need your FastMCP server running as a background service. A systemd unit file ensures your server starts automatically and restarts on failure.
+
+Create a file at `/etc/systemd/system/fastmcp.service`:
+
+```ini
+[Unit]
+Description=FastMCP Server
+After=network.target
+
+[Service]
+User=www-data
+Group=www-data
+WorkingDirectory=/opt/fastmcp
+ExecStart=/opt/fastmcp/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000
+Restart=always
+RestartSec=5
+Environment="PATH=/opt/fastmcp/.venv/bin"
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Enable and start the service:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable fastmcp
+sudo systemctl start fastmcp
+```
+
+This assumes your ASGI application is in `/opt/fastmcp/app.py` with a virtual environment at `/opt/fastmcp/.venv`. Adjust paths to match your deployment layout.
+
+### nginx Configuration
+
+FastMCP's Streamable HTTP transport uses Server-Sent Events (SSE) for streaming responses. This requires specific nginx settings to prevent buffering from breaking the event stream.
+
+Create a site configuration at `/etc/nginx/sites-available/fastmcp`:
+
+```nginx
+server {
+ listen 80;
+ server_name mcp.example.com;
+
+ # Redirect HTTP to HTTPS
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ server_name mcp.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
+
+ location / {
+ proxy_pass http://127.0.0.1:8000;
+ proxy_http_version 1.1;
+ proxy_set_header Connection '';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Required for SSE (Server-Sent Events) streaming
+ proxy_buffering off;
+ proxy_cache off;
+
+ # Allow long-lived connections for streaming responses
+ proxy_read_timeout 300s;
+ proxy_send_timeout 300s;
+ }
+}
+```
+
+Enable the site and reload nginx:
+
+```bash
+sudo ln -s /etc/nginx/sites-available/fastmcp /etc/nginx/sites-enabled/
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+Your FastMCP server is now accessible at `https://mcp.example.com/mcp`.
+
+
+**SSE buffering is the most common issue.** If clients connect but never receive streaming responses (progress updates, tool results), verify that `proxy_buffering off` is set. Without it, nginx buffers the entire SSE stream and delivers it only when the connection closes, which breaks real-time communication.
+
+
+### Key Considerations
+
+When deploying FastMCP behind a reverse proxy, keep these points in mind:
+
+- **Disable buffering**: SSE requires `proxy_buffering off` so events reach clients immediately. This is the single most important setting.
+- **Increase timeouts**: The default nginx `proxy_read_timeout` is 60 seconds. Long-running MCP tools will cause the connection to drop. Set timeouts to at least 300 seconds, or higher if your tools run longer. For tools that may exceed any timeout, use [SSE Polling](#sse-polling-for-long-running-operations) to gracefully handle proxy disconnections.
+- **Use HTTP/1.1**: Set `proxy_http_version 1.1` and `proxy_set_header Connection ''` to enable keep-alive connections between nginx and your server. Clearing the `Connection` header prevents clients from sending `Connection: close` to your upstream, which would break SSE streams. Both settings are required for proper SSE support.
+- **Forward headers**: Pass `X-Forwarded-For` and `X-Forwarded-Proto` so your FastMCP server can determine the real client IP and protocol. This is important for logging and for OAuth redirect URLs.
+- **TLS termination**: Let nginx handle TLS certificates (e.g., via Let's Encrypt with Certbot). Your FastMCP server can then run on plain HTTP internally.
+
+### Mounting Under a Path Prefix
+
+If you want your MCP server available at a subpath like `https://example.com/api/mcp` instead of at the root domain, adjust the nginx `location` block:
+
+```nginx
+location /api/ {
+ proxy_pass http://127.0.0.1:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Connection '';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Required for SSE streaming
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 300s;
+ proxy_send_timeout 300s;
+}
+```
+
+Note the trailing `/` on both `location /api/` and `proxy_pass http://127.0.0.1:8000/` β this ensures nginx strips the `/api` prefix before forwarding to your server. If you're using OAuth authentication with a mount prefix, see [Mounting Authenticated Servers](#mounting-authenticated-servers) for additional configuration.
+
## Testing Your Deployment
Once your server is deployed, you'll need to verify it's accessible and functioning correctly. For comprehensive testing strategies including connectivity tests, client testing, and authentication testing, see the [Testing Your Server](/v2/development/tests) guide.
diff --git a/docs/v2/servers/resources.mdx b/docs/v2/servers/resources.mdx
index 3cba5e071..a76734f12 100644
--- a/docs/v2/servers/resources.mdx
+++ b/docs/v2/servers/resources.mdx
@@ -254,15 +254,7 @@ notice_resource = TextResource(
)
mcp.add_resource(notice_resource)
-# 3. Using a custom key different from the URI
-special_resource = TextResource(
- uri="resource://common-notice",
- name="Special Notice",
- text="This is a special notice with a custom storage key.",
-)
-mcp.add_resource(special_resource, key="resource://custom-key")
-
-# 4. Exposing a directory listing
+# 3. Exposing a directory listing
data_dir_path = Path("./app_data").resolve()
if data_dir_path.is_dir():
data_listing_resource = DirectoryResource(
@@ -286,24 +278,6 @@ if data_dir_path.is_dir():
Use these when the content is static or sourced directly from a file/URL, bypassing the need for a dedicated Python function.
-#### Custom Resource Keys
-
-
-
-When adding resources directly with `mcp.add_resource()`, you can optionally provide a custom storage key:
-
-```python
-# Creating a resource with standard URI as the key
-resource = TextResource(uri="resource://data")
-mcp.add_resource(resource) # Will be stored and accessed using "resource://data"
-
-# Creating a resource with a custom key
-special_resource = TextResource(uri="resource://special-data")
-mcp.add_resource(special_resource, key="internal://data-v2") # Will be stored and accessed using "internal://data-v2"
-```
-
-Note that this parameter is only available when using `add_resource()` directly and not through the `@resource` decorator, as URIs are provided explicitly when using the decorator.
-
### Notifications
diff --git a/examples/apps/chart_server.py b/examples/apps/chart_server.py
new file mode 100644
index 000000000..94130f777
--- /dev/null
+++ b/examples/apps/chart_server.py
@@ -0,0 +1,102 @@
+"""Chart MCP App β interactive data visualizations with Prefab.
+
+Demonstrates `fastmcp[apps]` with Prefab chart components:
+- `BarChart` and `LineChart` for categorical and trend data
+- Multiple series, stacking, and curve styles
+- Layout composition with `Column`, `Heading`, and `Muted`
+- Custom text fallback via `ToolResult`
+
+Usage:
+ uv run python chart_server.py # HTTP (port 8000)
+ uv run python chart_server.py --stdio # stdio for MCP clients
+"""
+
+from __future__ import annotations
+
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import (
+ BarChart,
+ ChartSeries,
+ Column,
+ Heading,
+ LineChart,
+ Muted,
+)
+
+from fastmcp import FastMCP
+
+mcp = FastMCP("Sales Dashboard")
+
+MONTHLY_SALES = [
+ {"month": "Jan", "online": 4200, "retail": 2400},
+ {"month": "Feb", "online": 3800, "retail": 2100},
+ {"month": "Mar", "online": 5100, "retail": 2800},
+ {"month": "Apr", "online": 4600, "retail": 3200},
+ {"month": "May", "online": 5800, "retail": 3100},
+ {"month": "Jun", "online": 6200, "retail": 3500},
+]
+
+
+@mcp.tool(app=True)
+def sales_overview(stacked: bool = False) -> PrefabApp:
+ """View monthly sales broken down by channel.
+
+ Args:
+ stacked: Stack bars to show total revenue per month.
+ """
+ total = sum(row["online"] + row["retail"] for row in MONTHLY_SALES)
+
+ with Column(gap=6, css_class="p-6") as view:
+ with Column(gap=1):
+ Heading("Monthly Sales")
+ Muted(f"${total:,} total revenue")
+
+ BarChart(
+ data=MONTHLY_SALES,
+ series=[
+ ChartSeries(data_key="online", label="Online"),
+ ChartSeries(data_key="retail", label="Retail"),
+ ],
+ x_axis="month",
+ stacked=stacked,
+ show_legend=True,
+ )
+
+ return PrefabApp(
+ title="Sales Dashboard",
+ view=view,
+ )
+
+
+@mcp.tool(app=True)
+def sales_trend(curve: str = "linear") -> PrefabApp:
+ """View sales trends over time as a line chart.
+
+ Args:
+ curve: Line style β "linear", "smooth", or "step".
+ """
+ with Column(gap=6, css_class="p-6") as view:
+ with Column(gap=1):
+ Heading("Sales Trend")
+ Muted("Online vs. retail over 6 months")
+
+ LineChart(
+ data=MONTHLY_SALES,
+ series=[
+ ChartSeries(data_key="online", label="Online"),
+ ChartSeries(data_key="retail", label="Retail"),
+ ],
+ x_axis="month",
+ curve=curve,
+ show_dots=True,
+ show_legend=True,
+ )
+
+ return PrefabApp(
+ title="Sales Trend",
+ view=view,
+ )
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/examples/apps/datatable_server.py b/examples/apps/datatable_server.py
new file mode 100644
index 000000000..1f79c51dd
--- /dev/null
+++ b/examples/apps/datatable_server.py
@@ -0,0 +1,165 @@
+"""DataTable MCP App β interactive, sortable data views with Prefab.
+
+Demonstrates `fastmcp[apps]` with Prefab UI components:
+- `app=True` for automatic renderer wiring
+- `PrefabApp` with `DataTable` for rich tabular views
+- Searchable, sortable, paginated tables
+- Layout composition with `Column`, `Heading`, `Text`, and `Badge`
+
+Usage:
+ uv run python datatable_server.py # HTTP (port 8000)
+ uv run python datatable_server.py --stdio # stdio for MCP clients
+"""
+
+from __future__ import annotations
+
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import (
+ Badge,
+ Column,
+ DataTable,
+ DataTableColumn,
+ Heading,
+ Muted,
+ Row,
+)
+
+from fastmcp import FastMCP
+
+mcp = FastMCP("Team Directory")
+
+EMPLOYEES = [
+ {
+ "name": "Alice Chen",
+ "role": "Engineering",
+ "level": "Senior",
+ "location": "San Francisco",
+ "status": "active",
+ },
+ {
+ "name": "Bob Martinez",
+ "role": "Design",
+ "level": "Lead",
+ "location": "New York",
+ "status": "active",
+ },
+ {
+ "name": "Carol Johnson",
+ "role": "Engineering",
+ "level": "Staff",
+ "location": "London",
+ "status": "active",
+ },
+ {
+ "name": "David Kim",
+ "role": "Product",
+ "level": "Senior",
+ "location": "San Francisco",
+ "status": "away",
+ },
+ {
+ "name": "Eva MΓΌller",
+ "role": "Engineering",
+ "level": "Mid",
+ "location": "Berlin",
+ "status": "active",
+ },
+ {
+ "name": "Frank Okafor",
+ "role": "Data Science",
+ "level": "Senior",
+ "location": "Lagos",
+ "status": "active",
+ },
+ {
+ "name": "Grace Liu",
+ "role": "Engineering",
+ "level": "Junior",
+ "location": "Singapore",
+ "status": "active",
+ },
+ {
+ "name": "Hassan Ali",
+ "role": "Design",
+ "level": "Senior",
+ "location": "Dubai",
+ "status": "away",
+ },
+ {
+ "name": "Iris Tanaka",
+ "role": "Product",
+ "level": "Lead",
+ "location": "Tokyo",
+ "status": "active",
+ },
+ {
+ "name": "James Wright",
+ "role": "Engineering",
+ "level": "Senior",
+ "location": "London",
+ "status": "inactive",
+ },
+ {
+ "name": "Karen Petrov",
+ "role": "Data Science",
+ "level": "Lead",
+ "location": "Berlin",
+ "status": "active",
+ },
+ {
+ "name": "Liam O'Brien",
+ "role": "Engineering",
+ "level": "Mid",
+ "location": "Dublin",
+ "status": "active",
+ },
+]
+
+
+@mcp.tool(app=True)
+def list_team(department: str | None = None) -> PrefabApp:
+ """Browse the team directory with sorting and search.
+
+ Args:
+ department: Filter by department (e.g. "Engineering", "Design").
+ Leave empty to show everyone.
+ """
+ if department:
+ rows = [e for e in EMPLOYEES if e["role"].lower() == department.lower()]
+ else:
+ rows = EMPLOYEES
+
+ active = sum(1 for e in rows if e["status"] == "active")
+
+ with Column(gap=6, css_class="p-6") as view:
+ with Column(gap=1):
+ Heading("Team Directory")
+ with Row(gap=2):
+ Muted(f"{len(rows)} members")
+ Muted(f"{active} active", css_class="text-success")
+ if department:
+ Badge(department, variant="outline")
+
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Department", sortable=True),
+ DataTableColumn(key="level", header="Level", sortable=True),
+ DataTableColumn(key="location", header="Location", sortable=True),
+ DataTableColumn(key="status", header="Status", sortable=True),
+ ],
+ rows=rows,
+ searchable=True,
+ paginated=True,
+ page_size=10,
+ )
+
+ return PrefabApp(
+ title="Team Directory",
+ view=view,
+ state={"total": len(rows), "active": active},
+ )
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/examples/apps/patterns_server.py b/examples/apps/patterns_server.py
new file mode 100644
index 000000000..9b5b8ac79
--- /dev/null
+++ b/examples/apps/patterns_server.py
@@ -0,0 +1,489 @@
+"""Patterns showcase β every Prefab pattern from the docs in one server.
+
+A runnable collection of the patterns from https://gofastmcp.com/apps/patterns.
+Each tool demonstrates a different Prefab UI pattern: charts, tables, forms,
+status displays, conditional content, tabs, and accordions.
+
+Usage:
+ uv run python patterns_server.py # HTTP (port 8000)
+ uv run python patterns_server.py --stdio # stdio for MCP clients
+"""
+
+from __future__ import annotations
+
+from prefab_ui.actions import ShowToast
+from prefab_ui.actions.mcp import CallTool
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import (
+ Accordion,
+ AccordionItem,
+ Alert,
+ AreaChart,
+ Badge,
+ BarChart,
+ Button,
+ Card,
+ CardContent,
+ ChartSeries,
+ Column,
+ DataTable,
+ DataTableColumn,
+ ForEach,
+ Form,
+ Grid,
+ Heading,
+ If,
+ Input,
+ Muted,
+ PieChart,
+ Progress,
+ Row,
+ Select,
+ Separator,
+ Switch,
+ Tab,
+ Tabs,
+ Text,
+ Textarea,
+)
+
+from fastmcp import FastMCP
+
+mcp = FastMCP("Patterns Showcase")
+
+
+# ---------------------------------------------------------------------------
+# Data
+# ---------------------------------------------------------------------------
+
+QUARTERLY_DATA = [
+ {"quarter": "Q1", "revenue": 42000, "costs": 28000},
+ {"quarter": "Q2", "revenue": 51000, "costs": 31000},
+ {"quarter": "Q3", "revenue": 47000, "costs": 29000},
+ {"quarter": "Q4", "revenue": 63000, "costs": 35000},
+]
+
+DAILY_USAGE = [
+ {"date": f"Feb {d}", "requests": v}
+ for d, v in zip(
+ range(1, 11),
+ [1200, 1350, 980, 1500, 1420, 1680, 1550, 1700, 1450, 1600],
+ )
+]
+
+TICKETS = [
+ {"category": "Bug", "count": 23},
+ {"category": "Feature", "count": 15},
+ {"category": "Docs", "count": 8},
+ {"category": "Infra", "count": 12},
+]
+
+EMPLOYEES = [
+ {
+ "name": "Alice Chen",
+ "department": "Engineering",
+ "role": "Staff Engineer",
+ "location": "San Francisco",
+ },
+ {
+ "name": "Bob Martinez",
+ "department": "Design",
+ "role": "Lead Designer",
+ "location": "New York",
+ },
+ {
+ "name": "Carol Johnson",
+ "department": "Engineering",
+ "role": "Senior Engineer",
+ "location": "London",
+ },
+ {
+ "name": "David Kim",
+ "department": "Product",
+ "role": "Product Manager",
+ "location": "San Francisco",
+ },
+ {
+ "name": "Eva MΓΌller",
+ "department": "Engineering",
+ "role": "Engineer",
+ "location": "Berlin",
+ },
+ {
+ "name": "Frank Okafor",
+ "department": "Data Science",
+ "role": "Senior Analyst",
+ "location": "Lagos",
+ },
+ {
+ "name": "Grace Liu",
+ "department": "Engineering",
+ "role": "Junior Engineer",
+ "location": "Singapore",
+ },
+ {
+ "name": "Hassan Ali",
+ "department": "Design",
+ "role": "Senior Designer",
+ "location": "Dubai",
+ },
+]
+
+SERVICES = [
+ {
+ "name": "API Gateway",
+ "status": "healthy",
+ "ok": True,
+ "latency_ms": 12,
+ "uptime_pct": 99.9,
+ },
+ {
+ "name": "Database",
+ "status": "healthy",
+ "ok": True,
+ "latency_ms": 3,
+ "uptime_pct": 99.99,
+ },
+ {
+ "name": "Cache",
+ "status": "degraded",
+ "ok": False,
+ "latency_ms": 45,
+ "uptime_pct": 98.2,
+ },
+ {
+ "name": "Queue",
+ "status": "healthy",
+ "ok": True,
+ "latency_ms": 8,
+ "uptime_pct": 99.8,
+ },
+]
+
+ENDPOINTS = [
+ {
+ "path": "/api/users",
+ "status": 200,
+ "healthy": True,
+ "avg_ms": 45,
+ "p99_ms": 120,
+ "uptime_pct": 99.9,
+ },
+ {
+ "path": "/api/orders",
+ "status": 200,
+ "healthy": True,
+ "avg_ms": 82,
+ "p99_ms": 250,
+ "uptime_pct": 99.7,
+ },
+ {
+ "path": "/api/search",
+ "status": 200,
+ "healthy": True,
+ "avg_ms": 150,
+ "p99_ms": 500,
+ "uptime_pct": 99.5,
+ },
+ {
+ "path": "/api/webhooks",
+ "status": 503,
+ "healthy": False,
+ "avg_ms": 2000,
+ "p99_ms": 5000,
+ "uptime_pct": 95.1,
+ },
+]
+
+PROJECT = {
+ "name": "FastMCP v3",
+ "description": "Next generation MCP framework with Apps support.",
+ "status": "Active",
+ "created_at": "2025-01-15",
+ "members": [
+ {"name": "Alice Chen", "role": "Lead"},
+ {"name": "Bob Martinez", "role": "Design"},
+ {"name": "Carol Johnson", "role": "Backend"},
+ ],
+ "activity": [
+ {
+ "timestamp": "2 hours ago",
+ "message": "Merged PR #342: Add Prefab UI integration",
+ },
+ {
+ "timestamp": "5 hours ago",
+ "message": "Opened issue #345: CORS convenience API",
+ },
+ {"timestamp": "1 day ago", "message": "Released v3.0.1"},
+ ],
+}
+
+# In-memory contact store for the form demo
+_contacts: list[dict] = [
+ {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Partner"},
+]
+
+
+# ---------------------------------------------------------------------------
+# Charts
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def quarterly_revenue(year: int = 2025) -> PrefabApp:
+ """Show quarterly revenue as a bar chart."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(f"{year} Revenue vs Costs")
+ BarChart(
+ data=QUARTERLY_DATA,
+ series=[
+ ChartSeries(data_key="revenue", label="Revenue"),
+ ChartSeries(data_key="costs", label="Costs"),
+ ],
+ x_axis="quarter",
+ show_legend=True,
+ )
+
+ return PrefabApp(view=view)
+
+
+@mcp.tool(app=True)
+def usage_trend() -> PrefabApp:
+ """Show API usage over time as an area chart."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("API Usage (10 Days)")
+ AreaChart(
+ data=DAILY_USAGE,
+ series=[ChartSeries(data_key="requests", label="Requests")],
+ x_axis="date",
+ curve="smooth",
+ height=250,
+ )
+
+ return PrefabApp(view=view)
+
+
+@mcp.tool(app=True)
+def ticket_breakdown() -> PrefabApp:
+ """Show open tickets by category as a donut chart."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Open Tickets")
+ PieChart(
+ data=TICKETS,
+ data_key="count",
+ name_key="category",
+ show_legend=True,
+ inner_radius=60,
+ )
+
+ return PrefabApp(view=view)
+
+
+# ---------------------------------------------------------------------------
+# Data Tables
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def employee_directory() -> PrefabApp:
+ """Show a searchable, sortable employee directory."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Employee Directory")
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="department", header="Department", sortable=True),
+ DataTableColumn(key="role", header="Role"),
+ DataTableColumn(key="location", header="Office", sortable=True),
+ ],
+ rows=EMPLOYEES,
+ searchable=True,
+ paginated=True,
+ page_size=15,
+ )
+
+ return PrefabApp(view=view)
+
+
+# ---------------------------------------------------------------------------
+# Forms
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def contact_form() -> PrefabApp:
+ """Show a form to create a new contact, with a live contact list below."""
+ with Column(gap=6, css_class="p-6") as view:
+ Heading("Contacts")
+
+ with ForEach("contacts"):
+ with Row(gap=2, align="center"):
+ Text("{{ name }}", css_class="font-medium")
+ Muted("{{ email }}")
+ Badge("{{ category }}")
+
+ Separator()
+
+ Heading("Add Contact", level=3)
+ with Form(
+ on_submit=CallTool(
+ "save_contact",
+ result_key="contacts",
+ on_success=ShowToast("Contact saved!", variant="success"),
+ on_error=ShowToast("{{ $error }}", variant="error"),
+ )
+ ):
+ Input(name="name", label="Full Name", required=True)
+ Input(name="email", label="Email", input_type="email", required=True)
+ Select(
+ name="category",
+ label="Category",
+ options=["Customer", "Vendor", "Partner", "Other"],
+ )
+ Textarea(name="notes", label="Notes", placeholder="Optional notes...")
+ Button("Save Contact")
+
+ return PrefabApp(view=view, state={"contacts": list(_contacts)})
+
+
+@mcp.tool
+def save_contact(
+ name: str,
+ email: str,
+ category: str = "Other",
+ notes: str = "",
+) -> list[dict]:
+ """Save a new contact and return the updated list."""
+ contact = {"name": name, "email": email, "category": category, "notes": notes}
+ _contacts.append(contact)
+ return list(_contacts)
+
+
+# ---------------------------------------------------------------------------
+# Status Displays
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def system_status() -> PrefabApp:
+ """Show current system health."""
+ all_ok = all(s["ok"] for s in SERVICES)
+
+ with Column(gap=4, css_class="p-6") as view:
+ with Row(gap=2, align="center"):
+ Heading("System Status")
+ Badge(
+ "All Healthy" if all_ok else "Degraded",
+ variant="success" if all_ok else "destructive",
+ )
+
+ Separator()
+
+ with Grid(columns=2, gap=4):
+ for svc in SERVICES:
+ with Card():
+ with CardContent():
+ with Row(gap=2, align="center"):
+ Text(svc["name"], css_class="font-medium")
+ Badge(
+ svc["status"],
+ variant="success" if svc["ok"] else "destructive",
+ )
+ Muted(f"Response: {svc['latency_ms']}ms")
+ Progress(value=svc["uptime_pct"])
+
+ return PrefabApp(view=view)
+
+
+# ---------------------------------------------------------------------------
+# Conditional Content
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def feature_flags() -> PrefabApp:
+ """Toggle feature flags with live preview."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Feature Flags")
+
+ Switch(name="dark_mode", label="Dark Mode")
+ Switch(name="beta_features", label="Beta Features")
+
+ Separator()
+
+ with If("{{ dark_mode }}"):
+ Alert(title="Dark mode enabled", description="UI will use dark theme.")
+ with If("{{ beta_features }}"):
+ Alert(
+ title="Beta features active",
+ description="Experimental features are now visible.",
+ variant="warning",
+ )
+
+ return PrefabApp(view=view, state={"dark_mode": False, "beta_features": False})
+
+
+# ---------------------------------------------------------------------------
+# Tabs
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def project_overview() -> PrefabApp:
+ """Show project details organized in tabs."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(PROJECT["name"])
+
+ with Tabs():
+ with Tab("Overview"):
+ Text(PROJECT["description"])
+ with Row(gap=4):
+ Badge(PROJECT["status"])
+ Muted(f"Created: {PROJECT['created_at']}")
+
+ with Tab("Members"):
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Role"),
+ ],
+ rows=PROJECT["members"],
+ )
+
+ with Tab("Activity"):
+ with ForEach("activity"):
+ with Row(gap=2):
+ Muted("{{ timestamp }}")
+ Text("{{ message }}")
+
+ return PrefabApp(view=view, state={"activity": PROJECT["activity"]})
+
+
+# ---------------------------------------------------------------------------
+# Accordion
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def api_health() -> PrefabApp:
+ """Show health details for each API endpoint."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("API Health")
+
+ with Accordion(multiple=True):
+ for ep in ENDPOINTS:
+ with AccordionItem(ep["path"]):
+ with Row(gap=4):
+ Badge(
+ f"{ep['status']}",
+ variant="success" if ep["healthy"] else "destructive",
+ )
+ Text(f"Avg: {ep['avg_ms']}ms")
+ Text(f"P99: {ep['p99_ms']}ms")
+ Progress(value=ep["uptime_pct"])
+
+ return PrefabApp(view=view)
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/examples/auth/propelauth_oauth/README.md b/examples/auth/propelauth_oauth/README.md
new file mode 100644
index 000000000..575ea7314
--- /dev/null
+++ b/examples/auth/propelauth_oauth/README.md
@@ -0,0 +1,67 @@
+# PropelAuth OAuth Example
+
+Demonstrates FastMCP server protection with PropelAuth OAuth.
+
+## Setup
+
+### 1. Configure MCP Authentication in PropelAuth
+
+**Create a PropelAuth Account**:
+
+- Go to [PropelAuth Dashboard](https://www.propelauth.com)
+- Navigate to the **MCP** section and click **Enable MCP**
+
+**Configure Allowed MCP Clients**:
+
+- Under **MCP > Allowed MCP Clients**, add redirect URIs for each MCP client you want to allow
+- PropelAuth provides templates for popular clients like Claude, Cursor, and ChatGPT
+
+**Configure Scopes**:
+
+- Under **MCP > Scopes**, define the permissions available to MCP clients (e.g., `read:user_data`)
+
+**Generate Introspection Credentials**:
+
+- Go to **MCP > Request Validation** and click **Create Credentials**
+- Note the **Client ID** and **Client Secret**
+
+**Note Your Auth URL**:
+
+- Find your Auth URL in the **Backend Integration** section (e.g., `https://auth.yourdomain.com`)
+
+Create a `.env` file:
+
+```bash
+# Required PropelAuth credentials
+PROPELAUTH_AUTH_URL=https://auth.yourdomain.com
+PROPELAUTH_INTROSPECTION_CLIENT_ID=your-client-id
+PROPELAUTH_INTROSPECTION_CLIENT_SECRET=your-client-secret
+BASE_URL=http://localhost:8000/
+# Optional: additional scopes tokens must include (comma-separated)
+# PROPELAUTH_REQUIRED_SCOPES=read:user_data
+```
+
+### 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 PropelAuth 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 PropelAuth authentication
+4. Complete the OAuth flow and connect to the server
+5. Demonstrate calling authenticated tools (echo and whoami)
diff --git a/examples/auth/propelauth_oauth/client.py b/examples/auth/propelauth_oauth/client.py
new file mode 100644
index 000000000..fd6a84b4e
--- /dev/null
+++ b/examples/auth/propelauth_oauth/client.py
@@ -0,0 +1,43 @@
+"""OAuth client example for connecting to PropelAuth-protected FastMCP servers.
+
+This example demonstrates how to connect to a PropelAuth 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 PropelAuth!")
+
+ 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 PropelAuth!"}
+ )
+ print(f"π― Echo result: {result}")
+
+ # Test calling whoami tool
+ whoami = await client.call_tool("whoami", {})
+ print(f"π€ Who am I: {whoami}")
+
+ except Exception as e:
+ print(f"β Authentication failed: {e}")
+ raise
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/auth/propelauth_oauth/server.py b/examples/auth/propelauth_oauth/server.py
new file mode 100644
index 000000000..8401882aa
--- /dev/null
+++ b/examples/auth/propelauth_oauth/server.py
@@ -0,0 +1,54 @@
+"""PropelAuth OAuth server example for FastMCP.
+
+This example demonstrates how to protect a FastMCP server with PropelAuth OAuth.
+
+Required environment variables:
+- PROPELAUTH_AUTH_URL: Your PropelAuth Auth URL (from Backend Integration page)
+- PROPELAUTH_INTROSPECTION_CLIENT_ID: Introspection Client ID (from MCP > Request Validation)
+- PROPELAUTH_INTROSPECTION_CLIENT_SECRET: Introspection Client Secret (from MCP > Request Validation)
+
+Optional:
+- PROPELAUTH_REQUIRED_SCOPES: Comma-separated scopes tokens must include
+- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://localhost:8000/`)
+
+To run:
+ python server.py
+"""
+
+import os
+
+from dotenv import load_dotenv
+
+from fastmcp import FastMCP
+from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
+from fastmcp.server.dependencies import get_access_token
+
+load_dotenv()
+
+auth = PropelAuthProvider(
+ auth_url=os.environ["PROPELAUTH_AUTH_URL"],
+ introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
+ introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
+ base_url=os.getenv("BASE_URL", "http://localhost:8000/"),
+)
+
+mcp = FastMCP("PropelAuth OAuth Example Server", auth=auth)
+
+
+@mcp.tool
+def echo(message: str) -> str:
+ """Echo the provided message."""
+ return message
+
+
+@mcp.tool
+def whoami() -> dict:
+ """Return the authenticated user's ID."""
+ token = get_access_token()
+ if token is None:
+ return {"error": "Not authenticated"}
+ return {"user_id": token.claims.get("sub")}
+
+
+if __name__ == "__main__":
+ mcp.run(transport="http", port=8000)
diff --git a/examples/code_mode/README.md b/examples/code_mode/README.md
new file mode 100644
index 000000000..28834f350
--- /dev/null
+++ b/examples/code_mode/README.md
@@ -0,0 +1,37 @@
+# Code Mode
+
+CodeMode collapses an entire tool catalog into two meta-tools: `search` (keyword-based discovery) and `execute` (run Python scripts that chain tool calls in a sandbox). Instead of burning context tokens on every intermediate result, the LLM writes a script that runs server-side and returns only the final answer.
+
+## Run
+
+```bash
+uv run python server.py # in one terminal
+uv run python client.py # in another
+```
+
+## Example Output
+
+```
+ββββββββββββββββββ CodeMode Transform ββββββββββββββββββ
+
+βββββββββββββββ list_tools() βββββββββββββββ
+β Tool Description β
+β search Search for available tools ... β
+β execute Chain `await call_tool(...)` ... β
+βββ 8 backend tools collapsed into 2 βββββββ
+
+βββββ search(query="math arithmetic") ββββββ
+β # Tool Description β
+β 1 add Add two numbers together. β
+β 2 multiply Multiply two numbers. β
+β 3 fibonacci Generate the first n ... β
+βββ 3 results ββββββββββββββββββββββββββββββ
+
+βββββββββββββββ execute ββββββββββββββββββββ
+β a = await call_tool("add", {"a": 3 ... β
+β b = await call_tool("multiply", ... β
+β return b β
+βββ result: 14.0 βββββββββββββββββββββββββββ
+```
+
+The key insight: with standard MCP, each `call_tool` is a round-trip through the LLM. With CodeMode, the LLM writes one script and all the tool calls happen server-side. Intermediate data never touches the context window.
diff --git a/examples/code_mode/client.py b/examples/code_mode/client.py
new file mode 100644
index 000000000..3757becf6
--- /dev/null
+++ b/examples/code_mode/client.py
@@ -0,0 +1,142 @@
+"""Example: Client using CodeMode to discover and chain tools.
+
+CodeMode exposes just two tools: `search` (keyword query) and `execute`
+(run Python code with `call_tool` available). This client demonstrates
+both: searching for tools, then chaining multiple calls in a single
+execute block β one round-trip instead of many.
+
+Run with:
+ uv run python examples/code_mode/client.py
+"""
+
+import asyncio
+import json
+from typing import Any
+
+from rich.console import Console
+from rich.panel import Panel
+from rich.syntax import Syntax
+from rich.table import Table
+
+from fastmcp.client import Client
+
+console = Console()
+
+
+def _get_result(result) -> Any:
+ """Extract the value from a CallToolResult (structured or text)."""
+ if result.structured_content is not None:
+ data = result.structured_content
+ if isinstance(data, dict) and set(data) == {"result"}:
+ return data["result"]
+ return data
+ return result.content[0].text
+
+
+def _format_params(tool: dict) -> str:
+ """Format inputSchema properties as a compact signature."""
+ schema = tool.get("inputSchema", {})
+ props = schema.get("properties", {})
+ if not props:
+ return "()"
+ parts = []
+ for name, info in props.items():
+ typ = info.get("type", "")
+ parts.append(f"{name}: {typ}" if typ else name)
+ return f"({', '.join(parts)})"
+
+
+def _tool_table(
+ tools: list[dict], *, ranked: bool = False, show_params: bool = False
+) -> Table:
+ table = Table(show_header=True, show_edge=False, pad_edge=False, expand=True)
+ if ranked:
+ table.add_column("#", style="dim", width=3, justify="right")
+ table.add_column("Tool", style="cyan", no_wrap=True)
+ if show_params:
+ table.add_column("Parameters", style="dim", no_wrap=True)
+ table.add_column("Description", style="dim")
+ for i, tool in enumerate(tools, 1):
+ row = [tool["name"]]
+ if show_params:
+ row.append(_format_params(tool))
+ row.append(tool.get("description", ""))
+ if ranked:
+ row.insert(0, str(i))
+ table.add_row(*row)
+ return table
+
+
+async def main():
+ async with Client("examples/code_mode/server.py") as client:
+ console.print()
+ console.rule("[bold]CodeMode[/bold]")
+ console.print()
+
+ # Step 1: list_tools only returns two synthetic meta-tools
+ console.print(
+ "The server has 8 tools. CodeMode replaces them with "
+ "two synthetic tools β [bold]search[/bold] and [bold]execute[/bold]:"
+ )
+ console.print()
+ tools = await client.list_tools()
+ visible = [{"name": t.name, "description": t.description} for t in tools]
+ console.print(
+ Panel(
+ _tool_table(visible),
+ title="[bold]list_tools()[/bold]",
+ title_align="left",
+ border_style="blue",
+ )
+ )
+ console.print()
+
+ # Step 2: search discovers available tools
+ console.print("The LLM calls [bold]search[/bold] to discover available tools:")
+ console.print()
+ result = await client.call_tool("search", {"query": "add multiply numbers"})
+ found = _get_result(result)
+ if isinstance(found, str):
+ found = json.loads(found)
+ console.print(
+ Panel(
+ _tool_table(found, ranked=True, show_params=True),
+ title='[bold]search[/bold] [dim]query="add multiply numbers"[/dim]',
+ title_align="left",
+ border_style="green",
+ )
+ )
+ console.print()
+
+ # Step 3: execute chains tool calls in one round-trip
+ console.print(
+ "Now the LLM writes a Python script that chains "
+ "the tools it found. All of it runs server-side in a "
+ "sandbox β [bold]one round-trip[/bold], intermediate "
+ "data never hits the context window:"
+ )
+ console.print()
+ code = """\
+a = await call_tool("add", {"a": 3, "b": 4})
+b = await call_tool("multiply", {"x": a["result"], "y": 2})
+fib = await call_tool("fibonacci", {"n": b["result"]})
+return {"sum": a["result"], "product": b["result"], "fibonacci": fib["result"]}
+"""
+ result = await client.call_tool("execute", {"code": code})
+ console.print(
+ Panel(
+ Syntax(code.strip(), "python", theme="monokai"),
+ title="[bold]execute[/bold]",
+ title_align="left",
+ border_style="yellow",
+ )
+ )
+ console.print()
+
+ # Final result
+ console.print(f" Result: [bold green]{_get_result(result)}[/bold green]")
+ console.print()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/code_mode/server.py b/examples/code_mode/server.py
new file mode 100644
index 000000000..70aca8bfa
--- /dev/null
+++ b/examples/code_mode/server.py
@@ -0,0 +1,84 @@
+"""Example: CodeMode transform β search and execute tools via code.
+
+CodeMode replaces the entire tool catalog with two meta-tools: `search`
+(keyword-based tool discovery) and `execute` (run Python code that chains
+tool calls in a sandbox). This dramatically reduces round-trips and
+context window usage when an LLM needs to orchestrate many tools.
+
+Requires pydantic-monty for the sandbox:
+ pip install "fastmcp[code-mode]"
+
+Run with:
+ uv run python examples/code_mode/server.py
+"""
+
+from fastmcp import FastMCP
+from fastmcp.experimental.transforms.code_mode import CodeMode
+
+mcp = FastMCP("CodeMode Demo")
+
+
+@mcp.tool
+def add(a: int, b: int) -> int:
+ """Add two numbers together."""
+ return a + b
+
+
+@mcp.tool
+def multiply(x: float, y: float) -> float:
+ """Multiply two numbers."""
+ return x * y
+
+
+@mcp.tool
+def fibonacci(n: int) -> list[int]:
+ """Generate the first n Fibonacci numbers."""
+ if n <= 0:
+ return []
+ seq = [0, 1]
+ while len(seq) < n:
+ seq.append(seq[-1] + seq[-2])
+ return seq[:n]
+
+
+@mcp.tool
+def reverse_string(text: str) -> str:
+ """Reverse a string."""
+ return text[::-1]
+
+
+@mcp.tool
+def word_count(text: str) -> int:
+ """Count the number of words in a text."""
+ return len(text.split())
+
+
+@mcp.tool
+def to_uppercase(text: str) -> str:
+ """Convert text to uppercase."""
+ return text.upper()
+
+
+@mcp.tool
+def list_files(directory: str) -> list[str]:
+ """List files in a directory."""
+ import os
+
+ return os.listdir(directory)
+
+
+@mcp.tool
+def read_file(path: str) -> str:
+ """Read the contents of a file."""
+ with open(path) as f:
+ return f.read()
+
+
+# CodeMode collapses all 8 tools into just `search` + `execute`.
+# The LLM discovers tools via keyword search, then writes Python
+# scripts that chain multiple tool calls in a single round-trip.
+mcp.add_transform(CodeMode())
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/examples/search/README.md b/examples/search/README.md
new file mode 100644
index 000000000..32a26390d
--- /dev/null
+++ b/examples/search/README.md
@@ -0,0 +1,21 @@
+# Search Transforms
+
+When a server exposes many tools, listing them all at once can overwhelm an LLM's context window. Search transforms collapse the full tool catalog behind a search interface β clients see only `search_tools` and `call_tool`, and discover the real tools on demand.
+
+## Two search strategies
+
+**Regex** (`RegexSearchTransform`) β clients search with regex patterns like `add|multiply` or `text.*`. Fast and precise when you know what you're looking for.
+
+**BM25** (`BM25SearchTransform`) β clients search with natural language like `"work with numbers"`. Results are ranked by relevance using BM25 scoring. The index rebuilds automatically when tools change.
+
+Both strategies respect the full auth pipeline: middleware, visibility transforms, and component-level auth checks all apply to search results.
+
+## Run
+
+```bash
+# Regex
+uv run python examples/search/client_regex.py
+
+# BM25
+uv run python examples/search/client_bm25.py
+```
diff --git a/examples/search/client_bm25.py b/examples/search/client_bm25.py
new file mode 100644
index 000000000..b33e395a3
--- /dev/null
+++ b/examples/search/client_bm25.py
@@ -0,0 +1,152 @@
+"""Example: Client using BM25 search to discover and call tools.
+
+BM25 search accepts natural language queries instead of regex patterns.
+This client shows how relevance ranking surfaces the best matches.
+
+Run with:
+ uv run python examples/search/client_bm25.py
+"""
+
+import asyncio
+import json
+from typing import Any
+
+from rich.console import Console
+from rich.panel import Panel
+from rich.table import Table
+
+from fastmcp.client import Client
+
+console = Console()
+
+
+def _get_result(result) -> Any:
+ """Extract the value from a CallToolResult (structured or text)."""
+ if result.structured_content is not None:
+ data = result.structured_content
+ if isinstance(data, dict) and set(data) == {"result"}:
+ return data["result"]
+ return data
+ return result.content[0].text
+
+
+def _format_params(tool: dict) -> str:
+ """Format inputSchema properties as a compact signature."""
+ schema = tool.get("inputSchema", {})
+ props = schema.get("properties", {})
+ if not props:
+ return "()"
+ parts = []
+ for name, info in props.items():
+ typ = info.get("type", "")
+ parts.append(f"{name}: {typ}" if typ else name)
+ return f"({', '.join(parts)})"
+
+
+def _tool_table(
+ tools: list[dict], *, ranked: bool = False, show_params: bool = False
+) -> Table:
+ table = Table(show_header=True, show_edge=False, pad_edge=False, expand=True)
+ if ranked:
+ table.add_column("#", style="dim", width=3, justify="right")
+ table.add_column("Tool", style="cyan", no_wrap=True)
+ if show_params:
+ table.add_column("Parameters", style="dim", no_wrap=True)
+ table.add_column("Description", style="dim")
+ for i, tool in enumerate(tools, 1):
+ row = [tool["name"]]
+ if show_params:
+ row.append(_format_params(tool))
+ row.append(tool.get("description", ""))
+ if ranked:
+ row.insert(0, str(i))
+ table.add_row(*row)
+ return table
+
+
+async def main():
+ async with Client("examples/search/server_bm25.py") as client:
+ console.print()
+ console.rule("[bold]BM25 Search Transform[/bold]")
+ console.print()
+
+ # Step 1: list_tools shows only synthetic tools + pinned tools
+ console.print(
+ "The server has 8 tools. BM25SearchTransform replaces them with "
+ "just [bold]search_tools[/bold] and [bold]call_tool[/bold]. "
+ "[bold]list_files[/bold] stays visible via [dim]always_visible[/dim]:"
+ )
+ console.print()
+ tools = await client.list_tools()
+ visible = [{"name": t.name, "description": t.description} for t in tools]
+ console.print(
+ Panel(
+ _tool_table(visible),
+ title="[bold]list_tools()[/bold]",
+ title_align="left",
+ border_style="blue",
+ )
+ )
+ console.print()
+
+ # Step 2: natural language search discovers tools by relevance
+ console.print(
+ "The LLM uses [bold]search_tools[/bold] with natural language "
+ "to discover tools ranked by relevance:"
+ )
+ console.print()
+ result = await client.call_tool("search_tools", {"query": "work with numbers"})
+ found = _get_result(result)
+ if isinstance(found, str):
+ found = json.loads(found)
+ console.print(
+ Panel(
+ _tool_table(found, ranked=True, show_params=True),
+ title='[bold]search_tools[/bold] [dim]query="work with numbers"[/dim]',
+ title_align="left",
+ border_style="green",
+ )
+ )
+ console.print()
+
+ result = await client.call_tool(
+ "search_tools", {"query": "manipulate text strings"}
+ )
+ found = _get_result(result)
+ if isinstance(found, str):
+ found = json.loads(found)
+ console.print(
+ Panel(
+ _tool_table(found, ranked=True, show_params=True),
+ title='[bold]search_tools[/bold] [dim]query="manipulate text strings"[/dim]',
+ title_align="left",
+ border_style="green",
+ )
+ )
+ console.print()
+
+ # Step 3: call a discovered tool
+ console.print(
+ "Then the LLM calls a discovered tool through [bold]call_tool[/bold]:"
+ )
+ console.print()
+ result = await client.call_tool(
+ "call_tool",
+ {
+ "name": "word_count",
+ "arguments": {"text": "BM25 search makes tool discovery easy"},
+ },
+ )
+ console.print(
+ Panel(
+ f'call_tool(name="word_count", arguments={{"text": "BM25 search makes tool discovery easy"}})\nβ [bold green]{_get_result(result)}[/bold green]',
+ title="[bold]call_tool()[/bold]",
+ title_align="left",
+ border_style="magenta",
+ )
+ )
+ console.print()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/search/client_regex.py b/examples/search/client_regex.py
new file mode 100644
index 000000000..ccccefbd2
--- /dev/null
+++ b/examples/search/client_regex.py
@@ -0,0 +1,161 @@
+"""Example: Client using regex search to discover and call tools.
+
+Regex search lets clients find tools by matching patterns against tool names
+and descriptions. Precise when you know what you're looking for.
+
+Run with:
+ uv run python examples/search/client_regex.py
+"""
+
+import asyncio
+import json
+from typing import Any
+
+from rich.console import Console
+from rich.panel import Panel
+from rich.table import Table
+
+from fastmcp.client import Client
+
+console = Console()
+
+
+def _get_result(result) -> Any:
+ """Extract the value from a CallToolResult (structured or text)."""
+ if result.structured_content is not None:
+ data = result.structured_content
+ if isinstance(data, dict) and set(data) == {"result"}:
+ return data["result"]
+ return data
+ return result.content[0].text
+
+
+def _format_params(tool: dict) -> str:
+ """Format inputSchema properties as a compact signature."""
+ schema = tool.get("inputSchema", {})
+ props = schema.get("properties", {})
+ if not props:
+ return "()"
+ parts = []
+ for name, info in props.items():
+ typ = info.get("type", "")
+ parts.append(f"{name}: {typ}" if typ else name)
+ return f"({', '.join(parts)})"
+
+
+def _tool_table(
+ tools: list[dict], *, ranked: bool = False, show_params: bool = False
+) -> Table:
+ table = Table(show_header=True, show_edge=False, pad_edge=False, expand=True)
+ if ranked:
+ table.add_column("#", style="dim", width=3, justify="right")
+ table.add_column("Tool", style="cyan", no_wrap=True)
+ if show_params:
+ table.add_column("Parameters", style="dim", no_wrap=True)
+ table.add_column("Description", style="dim")
+ for i, tool in enumerate(tools, 1):
+ row = [tool["name"]]
+ if show_params:
+ row.append(_format_params(tool))
+ row.append(tool.get("description", ""))
+ if ranked:
+ row.insert(0, str(i))
+ table.add_row(*row)
+ return table
+
+
+async def main():
+ async with Client("examples/search/server_regex.py") as client:
+ console.print()
+ console.rule("[bold]Regex Search Transform[/bold]")
+ console.print()
+
+ # Step 1: list_tools shows only synthetic tools
+ console.print(
+ "The server has 6 tools. RegexSearchTransform replaces them with "
+ "just [bold]search_tools[/bold] and [bold]call_tool[/bold]:"
+ )
+ console.print()
+ tools = await client.list_tools()
+ visible = [{"name": t.name, "description": t.description} for t in tools]
+ console.print(
+ Panel(
+ _tool_table(visible),
+ title="[bold]list_tools()[/bold]",
+ title_align="left",
+ border_style="blue",
+ )
+ )
+ console.print()
+
+ # Step 2: regex patterns discover tools
+ console.print(
+ "The LLM uses [bold]search_tools[/bold] with regex patterns "
+ "to find tools by name:"
+ )
+ console.print()
+ result = await client.call_tool(
+ "search_tools", {"pattern": "add|multiply|fibonacci"}
+ )
+ found = _get_result(result)
+ if isinstance(found, str):
+ found = json.loads(found)
+ console.print(
+ Panel(
+ _tool_table(found, show_params=True),
+ title='[bold]search_tools[/bold] [dim]pattern="add|multiply|fibonacci"[/dim]',
+ title_align="left",
+ border_style="green",
+ )
+ )
+ console.print()
+
+ result = await client.call_tool("search_tools", {"pattern": "text|string|word"})
+ found = _get_result(result)
+ if isinstance(found, str):
+ found = json.loads(found)
+ console.print(
+ Panel(
+ _tool_table(found, show_params=True),
+ title='[bold]search_tools[/bold] [dim]pattern="text|string|word"[/dim]',
+ title_align="left",
+ border_style="green",
+ )
+ )
+ console.print()
+
+ # Step 3: call discovered tools
+ console.print(
+ "Then the LLM calls discovered tools through [bold]call_tool[/bold]:"
+ )
+ console.print()
+ result = await client.call_tool(
+ "call_tool", {"name": "add", "arguments": {"a": 17, "b": 25}}
+ )
+ console.print(
+ Panel(
+ f'call_tool(name="add", arguments={{"a": 17, "b": 25}})\nβ [bold green]{_get_result(result)}[/bold green]',
+ title="[bold]call_tool()[/bold]",
+ title_align="left",
+ border_style="magenta",
+ )
+ )
+ console.print()
+
+ result = await client.call_tool(
+ "call_tool",
+ {"name": "reverse_string", "arguments": {"text": "hello world"}},
+ )
+ console.print(
+ Panel(
+ f'call_tool(name="reverse_string", arguments={{"text": "hello world"}})\nβ [bold green]{_get_result(result)}[/bold green]',
+ title="[bold]call_tool()[/bold]",
+ title_align="left",
+ border_style="magenta",
+ )
+ )
+ console.print()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/search/server_bm25.py b/examples/search/server_bm25.py
new file mode 100644
index 000000000..63cdf8048
--- /dev/null
+++ b/examples/search/server_bm25.py
@@ -0,0 +1,85 @@
+"""Example: Search transforms with BM25 relevance ranking.
+
+BM25SearchTransform uses term-frequency/inverse-document-frequency scoring
+to rank tools by relevance to a natural language query. Unlike regex search
+(which requires the user to construct a pattern), BM25 handles queries like
+"work with text" or "do math" and returns the most relevant matches.
+
+The index is built lazily and rebuilt automatically when the tool catalog
+changes (e.g. tools added or removed between requests).
+
+Run with:
+ uv run python examples/search/server_bm25.py
+"""
+
+import os
+
+from fastmcp import FastMCP
+from fastmcp.server.transforms.search import BM25SearchTransform
+
+mcp = FastMCP("BM25 Search Demo")
+
+
+@mcp.tool
+def add(a: int, b: int) -> int:
+ """Add two numbers together."""
+ return a + b
+
+
+@mcp.tool
+def multiply(x: float, y: float) -> float:
+ """Multiply two numbers."""
+ return x * y
+
+
+@mcp.tool
+def fibonacci(n: int) -> list[int]:
+ """Generate the first n Fibonacci numbers."""
+ if n <= 0:
+ return []
+ seq = [0, 1]
+ while len(seq) < n:
+ seq.append(seq[-1] + seq[-2])
+ return seq[:n]
+
+
+@mcp.tool
+def reverse_string(text: str) -> str:
+ """Reverse a string."""
+ return text[::-1]
+
+
+@mcp.tool
+def word_count(text: str) -> int:
+ """Count the number of words in a text."""
+ return len(text.split())
+
+
+@mcp.tool
+def to_uppercase(text: str) -> str:
+ """Convert text to uppercase."""
+ return text.upper()
+
+
+@mcp.tool
+def list_files(directory: str) -> list[str]:
+ """List files in a directory."""
+ return os.listdir(directory)
+
+
+@mcp.tool
+def read_file(path: str) -> str:
+ """Read the contents of a file."""
+ with open(path) as f:
+ return f.read()
+
+
+# BM25 search with a higher result limit for this larger catalog.
+# The `always_visible` option keeps specific tools in list_tools output
+# alongside the search/call tools β useful for tools the LLM should
+# always know about.
+mcp.add_transform(BM25SearchTransform(max_results=5, always_visible=["list_files"]))
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/examples/search/server_regex.py b/examples/search/server_regex.py
new file mode 100644
index 000000000..261ef6a55
--- /dev/null
+++ b/examples/search/server_regex.py
@@ -0,0 +1,74 @@
+"""Example: Search transforms with regex pattern matching.
+
+When a server has many tools, listing them all at once can overwhelm an LLM's
+context window. Search transforms collapse the full tool catalog behind a
+search interface β clients see only `search_tools` and `call_tool`, and
+discover the real tools on demand.
+
+This example registers a handful of tools and applies RegexSearchTransform.
+Clients use `search_tools` with a regex pattern to find relevant tools, then
+`call_tool` to execute them by name.
+
+Run with:
+ uv run python examples/search/server_regex.py
+"""
+
+from fastmcp import FastMCP
+from fastmcp.server.transforms.search import RegexSearchTransform
+
+mcp = FastMCP("Regex Search Demo")
+
+
+# Register a variety of tools across different domains.
+# With the search transform active, none of these appear in list_tools β
+# they're only discoverable via search.
+
+
+@mcp.tool
+def add(a: int, b: int) -> int:
+ """Add two numbers together."""
+ return a + b
+
+
+@mcp.tool
+def multiply(x: float, y: float) -> float:
+ """Multiply two numbers."""
+ return x * y
+
+
+@mcp.tool
+def fibonacci(n: int) -> list[int]:
+ """Generate the first n Fibonacci numbers."""
+ if n <= 0:
+ return []
+ seq = [0, 1]
+ while len(seq) < n:
+ seq.append(seq[-1] + seq[-2])
+ return seq[:n]
+
+
+@mcp.tool
+def reverse_string(text: str) -> str:
+ """Reverse a string."""
+ return text[::-1]
+
+
+@mcp.tool
+def word_count(text: str) -> int:
+ """Count the number of words in a text."""
+ return len(text.split())
+
+
+@mcp.tool
+def to_uppercase(text: str) -> str:
+ """Convert text to uppercase."""
+ return text.upper()
+
+
+# Apply the regex search transform.
+# max_results limits how many tools a single search returns.
+mcp.add_transform(RegexSearchTransform(max_results=3))
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/loq.toml b/loq.toml
index f11d21871..0fffe3d5c 100644
--- a/loq.toml
+++ b/loq.toml
@@ -10,90 +10,22 @@ exclude = ["**/uv.lock", ".git/**", "docs/**"]
path = "tests/**"
max_lines = 1000
-[[rules]]
-path = "tests/server/providers/test_local_provider_tools.py"
-max_lines = 1554
-
-[[rules]]
-path = "tests/client/test_client.py"
-max_lines = 1438
-
-[[rules]]
-path = "tests/server/test_auth_integration.py"
-max_lines = 1242
-
-[[rules]]
-path = "tests/server/auth/test_oauth_proxy.py"
-max_lines = 1899
-
-[[rules]]
-path = "tests/server/middleware/test_middleware.py"
-max_lines = 1070
-
[[rules]]
path = "src/fastmcp/server/context.py"
max_lines = 1272
-[[rules]]
-path = "tests/tools/test_tool_transform.py"
-max_lines = 1748
-
-[[rules]]
-path = "tests/server/test_mount.py"
-max_lines = 1545
-
-[[rules]]
-path = "tests/utilities/test_inspect.py"
-max_lines = 1111
-
-[[rules]]
-path = "tests/resources/test_resource_template.py"
-max_lines = 1009
-
-[[rules]]
-path = "tests/server/auth/test_oauth_consent_flow.py"
-max_lines = 1274
-
[[rules]]
path = "src/fastmcp/server/server.py"
max_lines = 3250
-[[rules]]
-path = "tests/tools/test_tool.py"
-max_lines = 2026
-
-[[rules]]
-path = "tests/client/test_elicitation.py"
-max_lines = 1132
-
[[rules]]
path = "src/fastmcp/client/client.py"
max_lines = 1885
-[[rules]]
-path = "tests/utilities/test_json_schema_type.py"
-max_lines = 1584
-
[[rules]]
path = "src/fastmcp/server/auth/oauth_proxy/proxy.py"
max_lines = 1796
-[[rules]]
-path = "tests/server/test_dependencies.py"
-max_lines = 1046
-
-[[rules]]
-path = "tests/client/test_sampling.py"
-max_lines = 1002
-
-[[rules]]
-path = "tests/server/auth/test_jwt_provider.py"
-max_lines = 1101
-
[[rules]]
path = "src/fastmcp/server/providers/local_provider.py"
max_lines = 1187
-
-[[rules]]
-path = "tests/server/test_versioning.py"
-max_lines = 1235
diff --git a/pyproject.toml b/pyproject.toml
index 9babf664a..3ada527fa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,6 +23,7 @@ dependencies = [
"websockets>=15.0.1",
"jsonschema-path>=0.3.4",
"jsonref>=1.1.0",
+ "uncalled-for>=0.2.0",
"watchfiles>=1.0.0",
]
@@ -52,14 +53,17 @@ classifiers = [
[project.optional-dependencies]
anthropic = ["anthropic>=0.40.0"]
+apps = ["prefab-ui>=0.6.0"]
azure = ["azure-identity>=1.16.0"]
+code-mode = ["pydantic-monty>=0.0.7"]
+gemini = ["google-genai>=1.18.0"]
openai = ["openai>=1.102.0"]
-tasks = ["pydocket>=0.17.2"]
+tasks = ["pydocket>=0.18.0"]
[dependency-groups]
dev = [
"dirty-equals>=0.9.0",
- "fastmcp[anthropic,azure,openai,tasks]",
+ "fastmcp[anthropic,apps,azure,code-mode,gemini,openai,tasks]",
# add optional dependencies for fastmcp dev
"fastapi>=0.115.12",
"opentelemetry-sdk>=1.20.0",
@@ -104,6 +108,7 @@ source = "uv-dynamic-versioning"
[tool.hatch.metadata]
allow-direct-references = true
+
[tool.uv-dynamic-versioning]
vcs = "git"
style = "pep440"
@@ -190,18 +195,6 @@ known-first-party = ["fastmcp"]
"SIM", # flake8-simplify
]
-[tool.basedpyright]
-pythonVersion = "3.10"
-typeCheckingMode = "standard"
-reportMissingTypeStubs = false
-reportUnknownParameterType = false
-reportUnknownArgumentType = false
-reportUnknownMemberType = false
-reportUnknownVariableType = false
-reportPrivateUsage = false
-reportUnnecessaryIsInstance = false
-reportUnnecessaryComparison = false
-reportConstantRedefinition = false
[tool.codespell]
ignore-words-list = "asend,shttp,te"
diff --git a/scripts/benchmark_imports.py b/scripts/benchmark_imports.py
new file mode 100644
index 000000000..6ad1dfe57
--- /dev/null
+++ b/scripts/benchmark_imports.py
@@ -0,0 +1,212 @@
+#!/usr/bin/env python
+"""Benchmark import times for fastmcp and its dependency chain.
+
+Each measurement runs in a fresh subprocess so there's no shared module cache.
+Incremental costs are measured by pre-importing dependencies, so we can see
+what each module truly adds.
+
+Usage:
+ uv run python scripts/benchmark_imports.py
+ uv run python scripts/benchmark_imports.py --runs 10
+ uv run python scripts/benchmark_imports.py --json
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+from dataclasses import dataclass
+
+
+@dataclass
+class BenchmarkCase:
+ label: str
+ stmt: str
+ prereqs: str = ""
+ group: str = ""
+
+
+CASES = [
+ # --- Floor ---
+ BenchmarkCase("pydantic", "import pydantic", group="floor"),
+ BenchmarkCase("mcp", "import mcp", group="floor"),
+ BenchmarkCase(
+ "mcp (server only)", "import mcp.server.lowlevel.server", group="floor"
+ ),
+ # --- Auth stack (incremental over mcp) ---
+ BenchmarkCase(
+ "authlib.jose", "import authlib.jose", prereqs="import mcp", group="auth"
+ ),
+ BenchmarkCase(
+ "cryptography.fernet",
+ "from cryptography.fernet import Fernet",
+ prereqs="import mcp",
+ group="auth",
+ ),
+ BenchmarkCase(
+ "authlib.integrations.httpx_client",
+ "from authlib.integrations.httpx_client import AsyncOAuth2Client",
+ prereqs="import mcp",
+ group="auth",
+ ),
+ BenchmarkCase(
+ "key_value.aio", "import key_value.aio", prereqs="import mcp", group="auth"
+ ),
+ BenchmarkCase(
+ "key_value.aio.stores.filetree",
+ "from key_value.aio.stores.filetree import FileTreeStore",
+ prereqs="import mcp",
+ group="auth",
+ ),
+ BenchmarkCase("beartype", "import beartype", prereqs="import mcp", group="auth"),
+ # --- Docket stack (incremental over mcp) ---
+ BenchmarkCase("redis", "import redis", prereqs="import mcp", group="docket"),
+ BenchmarkCase(
+ "opentelemetry.sdk.metrics",
+ "import opentelemetry.sdk.metrics",
+ prereqs="import mcp",
+ group="docket",
+ ),
+ BenchmarkCase("docket", "import docket", prereqs="import mcp", group="docket"),
+ BenchmarkCase("croniter", "import croniter", prereqs="import mcp", group="docket"),
+ # --- Other deps (incremental over mcp) ---
+ BenchmarkCase("httpx", "import httpx", prereqs="import mcp", group="other"),
+ BenchmarkCase(
+ "starlette",
+ "from starlette.applications import Starlette",
+ prereqs="import mcp",
+ group="other",
+ ),
+ BenchmarkCase(
+ "pydantic_settings",
+ "import pydantic_settings",
+ prereqs="import mcp",
+ group="other",
+ ),
+ BenchmarkCase(
+ "rich.console", "import rich.console", prereqs="import mcp", group="other"
+ ),
+ BenchmarkCase("jsonref", "import jsonref", prereqs="import mcp", group="other"),
+ BenchmarkCase("requests", "import requests", prereqs="import mcp", group="other"),
+ # --- FastMCP (total and incremental) ---
+ BenchmarkCase("fastmcp (total)", "from fastmcp import FastMCP", group="fastmcp"),
+ BenchmarkCase(
+ "fastmcp (over mcp)",
+ "from fastmcp import FastMCP",
+ prereqs="import mcp",
+ group="fastmcp",
+ ),
+ BenchmarkCase(
+ "fastmcp (over mcp+docket)",
+ "from fastmcp import FastMCP",
+ prereqs="import mcp; import docket",
+ group="fastmcp",
+ ),
+ BenchmarkCase(
+ "fastmcp (over mcp+docket+auth deps)",
+ "from fastmcp import FastMCP",
+ prereqs=(
+ "import mcp; import docket; import authlib.jose;"
+ " from cryptography.fernet import Fernet;"
+ " import key_value.aio"
+ ),
+ group="fastmcp",
+ ),
+]
+
+
+def measure_once(stmt: str, prereqs: str) -> float | None:
+ pre = prereqs + "; " if prereqs else ""
+ code = (
+ f"{pre}"
+ "import time as _t; _s=_t.perf_counter(); "
+ f"{stmt}; "
+ "print(f'{(_t.perf_counter()-_s)*1000:.2f}')"
+ )
+ r = subprocess.run(
+ [sys.executable, "-c", code],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if r.returncode == 0 and r.stdout.strip():
+ return float(r.stdout.strip())
+ return None
+
+
+def measure(case: BenchmarkCase, runs: int) -> dict[str, float | str | None]:
+ times: list[float] = []
+ for _ in range(runs):
+ t = measure_once(case.stmt, case.prereqs)
+ if t is not None:
+ times.append(t)
+
+ if not times:
+ return {"label": case.label, "group": case.group, "median_ms": None}
+
+ times.sort()
+ median = times[len(times) // 2]
+ return {
+ "label": case.label,
+ "group": case.group,
+ "median_ms": round(median, 1),
+ "min_ms": round(times[0], 1),
+ "max_ms": round(times[-1], 1),
+ "runs": len(times),
+ }
+
+
+def print_table(results: list[dict[str, float | str | None]]) -> None:
+ current_group = None
+ print(f"\n{'Module':<45} {'Median':>8} {'Min':>8} {'Max':>8}")
+ print("-" * 71)
+ for r in results:
+ if r["group"] != current_group:
+ current_group = r["group"]
+ group_labels = {
+ "floor": "--- Unavoidable floor ---",
+ "auth": "--- Auth stack (incremental over mcp) ---",
+ "docket": "--- Docket stack (incremental over mcp) ---",
+ "other": "--- Other deps (incremental over mcp) ---",
+ "fastmcp": "--- FastMCP totals ---",
+ }
+ print(f"\n{group_labels.get(current_group, current_group)}")
+ if r["median_ms"] is not None:
+ print(
+ f" {r['label']:<43} {r['median_ms']:>7.1f}ms"
+ f" {r['min_ms']:>7.1f}ms {r['max_ms']:>7.1f}ms"
+ )
+ else:
+ print(f" {r['label']:<43} error")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Benchmark fastmcp import times")
+ parser.add_argument(
+ "--runs", type=int, default=5, help="Number of runs per measurement (default 5)"
+ )
+ parser.add_argument("--json", action="store_true", help="Output results as JSON")
+ args = parser.parse_args()
+
+ print(f"Benchmarking import times ({args.runs} runs each)...")
+ print(f"Python: {sys.version.split()[0]}")
+ print(f"Executable: {sys.executable}")
+
+ results = []
+ for case in CASES:
+ r = measure(case, args.runs)
+ results.append(r)
+ if not args.json:
+ ms = f"{r['median_ms']:.1f}ms" if r["median_ms"] is not None else "error"
+ print(f" {case.label}: {ms}")
+
+ if args.json:
+ print(json.dumps(results, indent=2))
+ else:
+ print_table(results)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py
index 14c0abc5b..a524b402c 100644
--- a/src/fastmcp/__init__.py
+++ b/src/fastmcp/__init__.py
@@ -1,10 +1,16 @@
"""FastMCP - An ergonomic MCP interface."""
+import importlib
import warnings
from importlib.metadata import version as _version
+from typing import TYPE_CHECKING
+
from fastmcp.settings import Settings
from fastmcp.utilities.logging import configure_logging as _configure_logging
+if TYPE_CHECKING:
+ from fastmcp.client import Client as Client
+
settings = Settings()
if settings.log_enabled:
_configure_logging(
@@ -16,9 +22,6 @@ from fastmcp.server.server import FastMCP
from fastmcp.server.context import Context
import fastmcp.server
-from fastmcp.client import Client
-from . import client
-
__version__ = _version("fastmcp")
@@ -27,6 +30,21 @@ if settings.deprecation_warnings:
warnings.simplefilter("default", DeprecationWarning)
+# --- Lazy imports for performance (see #3292) ---
+# Client and the client submodule are deferred so that server-only users
+# don't pay for the client import chain. Do not convert back to top-level.
+
+
+def __getattr__(name: str) -> object:
+ if name == "Client":
+ from fastmcp.client import Client
+
+ return Client
+ if name == "client":
+ return importlib.import_module("fastmcp.client")
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+
__all__ = [
"Client",
"Context",
diff --git a/src/fastmcp/_vendor/__init__.py b/src/fastmcp/_vendor/__init__.py
deleted file mode 100644
index c6d8ad492..000000000
--- a/src/fastmcp/_vendor/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Vendored third-party code for FastMCP."""
diff --git a/src/fastmcp/_vendor/docket_di/README.md b/src/fastmcp/_vendor/docket_di/README.md
deleted file mode 100644
index 19e44daf8..000000000
--- a/src/fastmcp/_vendor/docket_di/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Vendored Docket DI
-
-This is a minimal vendored copy of the dependency injection engine from [Docket](https://github.com/chrisguidry/docket), pending its release as a standalone library.
-
-When `fastmcp[tasks]` is installed, FastMCP uses Docket's DI classes directly for `isinstance` compatibility in worker contexts. This vendored version is only used when Docket is not installed, allowing basic `Depends()` functionality without the full Docket dependency.
-
-Once the DI component is released separately, this vendored copy will be removed.
diff --git a/src/fastmcp/_vendor/docket_di/__init__.py b/src/fastmcp/_vendor/docket_di/__init__.py
deleted file mode 100644
index f005d7487..000000000
--- a/src/fastmcp/_vendor/docket_di/__init__.py
+++ /dev/null
@@ -1,163 +0,0 @@
-"""Vendored dependency injection engine from Docket.
-
-This is a minimal subset of docket.dependencies for FastMCP's DI system.
-When docket is installed, FastMCP uses docket's classes directly for
-isinstance compatibility. This vendored version is only used when docket
-is not installed.
-
-Original source: https://github.com/chrisguidry/docket
-License: MIT
-"""
-
-from __future__ import annotations
-
-import abc
-import inspect
-from contextlib import AsyncExitStack
-from contextvars import ContextVar
-from collections.abc import Awaitable, Callable
-from contextlib import AbstractAsyncContextManager, AbstractContextManager
-from typing import (
- Any,
- Generic,
- TypeVar,
- cast,
-)
-
-R = TypeVar("R")
-
-# Cached signature lookup (simplified from docket.execution.get_signature)
-_signature_cache: dict[Callable[..., Any], inspect.Signature] = {}
-
-
-def get_signature(function: Callable[..., Any]) -> inspect.Signature:
- """Get cached signature for a function."""
- if function in _signature_cache:
- return _signature_cache[function]
-
- signature_attr = getattr(function, "__signature__", None)
- if isinstance(signature_attr, inspect.Signature):
- _signature_cache[function] = signature_attr
- return signature_attr
-
- signature = inspect.signature(function)
- _signature_cache[function] = signature
- return signature
-
-
-class Dependency(abc.ABC):
- """Base class for all dependencies.
-
- Subclasses must implement __aenter__ to provide the dependency value.
- The __aexit__ method is optional for cleanup.
- """
-
- single: bool = False
-
- @abc.abstractmethod
- async def __aenter__(self) -> Any: ...
-
- async def __aexit__(self, *args: object) -> None: # noqa: B027
- pass
-
-
-DependencyFunction = Callable[..., Any]
-
-_parameter_cache: dict[Callable[..., Any], dict[str, Dependency]] = {}
-
-
-def get_dependency_parameters(
- function: Callable[..., Any],
-) -> dict[str, Dependency]:
- """Find parameters with Dependency defaults."""
- if function in _parameter_cache:
- return _parameter_cache[function]
-
- dependencies: dict[str, Dependency] = {}
- signature = get_signature(function)
-
- for parameter, param in signature.parameters.items():
- if not isinstance(param.default, Dependency):
- continue
- dependencies[parameter] = param.default
-
- _parameter_cache[function] = dependencies
- return dependencies
-
-
-class _Depends(Dependency, Generic[R]):
- """Wrapper for user-defined dependency functions."""
-
- dependency: DependencyFunction
-
- cache: ContextVar[dict[DependencyFunction, Any]] = ContextVar("cache")
- stack: ContextVar[AsyncExitStack] = ContextVar("stack")
-
- def __init__(self, dependency: DependencyFunction) -> None:
- self.dependency = dependency
-
- async def _resolve_parameters(self, function: DependencyFunction) -> dict[str, Any]:
- stack = self.stack.get()
- arguments: dict[str, Any] = {}
- parameters = get_dependency_parameters(function)
-
- for parameter, dependency in parameters.items():
- arguments[parameter] = await stack.enter_async_context(dependency)
-
- return arguments
-
- async def __aenter__(self) -> R:
- cache = self.cache.get()
-
- if self.dependency in cache:
- return cache[self.dependency]
-
- stack = self.stack.get()
- arguments = await self._resolve_parameters(self.dependency)
-
- raw_value = self.dependency(**arguments)
-
- # Handle different return types
- resolved_value: R
- if isinstance(raw_value, AbstractAsyncContextManager):
- resolved_value = await stack.enter_async_context(raw_value)
- elif isinstance(raw_value, AbstractContextManager):
- resolved_value = stack.enter_context(raw_value)
- elif inspect.iscoroutine(raw_value) or isinstance(raw_value, Awaitable):
- resolved_value = await cast(Awaitable[R], raw_value)
- else:
- resolved_value = cast(R, raw_value)
-
- cache[self.dependency] = resolved_value
- return resolved_value
-
-
-def Depends(dependency: DependencyFunction) -> Any:
- """Include a user-defined function as a dependency.
-
- Dependencies may be:
- - Synchronous functions returning a value
- - Asynchronous functions returning a value (awaitable)
- - Synchronous context managers (using @contextmanager)
- - Asynchronous context managers (using @asynccontextmanager)
-
- Example:
- ```python
- def get_config() -> dict:
- return {"api_url": "https://api.example.com"}
-
- @mcp.tool
- def my_tool(config: dict = Depends(get_config)) -> str:
- return config["api_url"]
- ```
- """
- return cast(Any, _Depends(dependency))
-
-
-__all__ = [
- "Dependency",
- "Depends",
- "_Depends",
- "get_dependency_parameters",
- "get_signature",
-]
diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py
index 07b2ddb5b..f6472c470 100644
--- a/src/fastmcp/cli/cli.py
+++ b/src/fastmcp/cli/cli.py
@@ -212,6 +212,13 @@ async def inspector(
help="Directories to watch for changes (default: current directory)",
),
] = None,
+ module: Annotated[
+ bool,
+ cyclopts.Parameter(
+ name=["--module", "-m"],
+ help="Run a Python module (python -m ) instead of importing a server object",
+ ),
+ ] = False,
) -> None:
"""Run an MCP server with the MCP Inspector for development.
@@ -254,7 +261,11 @@ async def inspector(
logger.error("No configuration available")
sys.exit(1)
assert config is not None # For type checker
- await config.source.load_server()
+
+ # Skip server-object validation in module mode β the module
+ # manages its own startup and may not expose an importable server.
+ if not module:
+ await config.source.load_server()
env_vars = {}
if ui_port:
@@ -278,6 +289,10 @@ async def inspector(
# Build the fastmcp run command
fastmcp_cmd = ["fastmcp", "run", server_spec, "--no-banner"]
+ # Forward module mode flag
+ if module:
+ fastmcp_cmd.append("--module")
+
# Add reload flags if enabled - the server will handle reloading
if reload:
fastmcp_cmd.append("--reload")
@@ -424,6 +439,13 @@ async def run(
help="Run in stateless mode (no session, used internally for reload)",
),
] = False,
+ module: Annotated[
+ bool,
+ cyclopts.Parameter(
+ name=["--module", "-m"],
+ help="Run a Python module (python -m ) instead of importing a server object",
+ ),
+ ] = False,
) -> None:
"""Run an MCP server or connect to a remote one.
@@ -434,6 +456,7 @@ async def run(
4. MCPConfig file: "mcp.json" - runs as a proxy server for the MCP Servers in the MCPConfig file
5. FastMCP config: "fastmcp.json" - runs server using FastMCP configuration
6. No argument: looks for fastmcp.json in current directory
+ 7. Module mode: "-m my_module" - runs the module directly via python -m
Server arguments can be passed after -- :
fastmcp run server.py -- --config config.json --debug
@@ -442,6 +465,71 @@ async def run(
server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect
"""
+ # --- Module mode: delegate to python -m and exit early ---
+ if module:
+ if server_spec is None:
+ logger.error("A module name is required when using --module / -m")
+ sys.exit(1)
+
+ # Warn about options that are ignored in module mode
+ ignored_options: list[str] = []
+ if transport:
+ ignored_options.append("--transport")
+ if host:
+ ignored_options.append("--host")
+ if port:
+ ignored_options.append("--port")
+ if path:
+ ignored_options.append("--path")
+ if ignored_options:
+ logger.warning(
+ f"Options {', '.join(ignored_options)} are ignored in module mode "
+ f"(-m). The module manages its own server startup."
+ )
+
+ # Build environment wrapper if needed
+ env_builder = None
+ if not skip_env and not is_already_in_uv_subprocess():
+ from fastmcp.utilities.mcp_server_config.v1.environments.uv import (
+ UVEnvironment,
+ )
+
+ env = UVEnvironment(
+ python=python,
+ dependencies=with_packages or None,
+ requirements=with_requirements,
+ project=project,
+ )
+ test_cmd = ["test"]
+ if env.build_command(test_cmd) != test_cmd:
+ env_builder = env.build_command
+
+ if reload:
+ # Build a fastmcp run command for the reload watcher to restart
+ reload_cmd = ["fastmcp", "run", server_spec, "--module", "--no-reload"]
+ if log_level:
+ reload_cmd.extend(["--log-level", log_level])
+ if no_banner:
+ reload_cmd.append("--no-banner")
+ if env_builder is not None:
+ reload_cmd.append("--skip-env")
+ if server_args:
+ reload_cmd.append("--")
+ reload_cmd.extend(server_args)
+ if env_builder is not None:
+ reload_cmd = env_builder(reload_cmd)
+ await run_module.run_with_reload(
+ reload_cmd, reload_dirs=reload_dir, is_stdio=True
+ )
+ return
+
+ run_module.run_module_command(
+ server_spec,
+ env_command_builder=env_builder,
+ extra_args=list(server_args) if server_args else None,
+ )
+ return
+
# Check if we were spawned by uv (or user explicitly set --skip-env)
if skip_env or is_already_in_uv_subprocess():
skip_env = True
diff --git a/src/fastmcp/cli/client.py b/src/fastmcp/cli/client.py
index 843a10a25..9a14e4f06 100644
--- a/src/fastmcp/cli/client.py
+++ b/src/fastmcp/cli/client.py
@@ -2,7 +2,6 @@
import difflib
import json
-import os
import shlex
import sys
from pathlib import Path
@@ -100,7 +99,6 @@ def resolve_server_spec(
return StdioTransport(
command="fastmcp",
args=["run", str(resolved_path), "--no-banner"],
- log_file=Path(os.devnull),
)
# .js β pass through for Client's infer_transport
return spec
@@ -125,7 +123,7 @@ def _build_stdio_from_command(command_str: str) -> StdioTransport:
console.print("[bold red]Error:[/bold red] Empty --command")
sys.exit(1)
- return StdioTransport(command=parts[0], args=parts[1:], log_file=Path(os.devnull))
+ return StdioTransport(command=parts[0], args=parts[1:])
def _resolve_json_spec(path: Path) -> str | dict[str, Any]:
diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py
index a662c79f3..36b029c38 100644
--- a/src/fastmcp/cli/run.py
+++ b/src/fastmcp/cli/run.py
@@ -6,7 +6,9 @@ import json
import os
import re
import signal
+import subprocess
import sys
+from collections.abc import Callable
from pathlib import Path
from typing import Any, Literal
@@ -255,6 +257,45 @@ async def run_command(
sys.exit(1)
+def run_module_command(
+ module_name: str,
+ *,
+ env_command_builder: Callable[[list[str]], list[str]] | None = None,
+ extra_args: list[str] | None = None,
+) -> None:
+ """Run a Python module directly using ``python -m ``.
+
+ When ``-m`` is used, the module manages its own server startup.
+ No server-object discovery or transport overrides are applied.
+
+ Args:
+ module_name: Dotted module name (e.g. ``my_package``).
+ env_command_builder: An optional callable that wraps a command list
+ with environment setup (e.g. ``UVEnvironment.build_command``).
+ extra_args: Extra arguments forwarded after the module name.
+ """
+ # Use bare "python" when an env wrapper (e.g. uv run) is active so that
+ # the wrapper can resolve the interpreter via --python / environment config.
+ # Fall back to sys.executable for direct execution without a wrapper.
+ python = "python" if env_command_builder is not None else sys.executable
+ cmd: list[str] = [python, "-m", module_name]
+ if extra_args:
+ cmd.extend(extra_args)
+
+ # Wrap with environment (e.g. uv run) if configured
+ if env_command_builder is not None:
+ cmd = env_command_builder(cmd)
+
+ logger.debug(f"Running module: {' '.join(cmd)}")
+
+ try:
+ process = subprocess.run(cmd, check=True)
+ sys.exit(process.returncode)
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Module {module_name} exited with code {e.returncode}")
+ sys.exit(e.returncode)
+
+
async def run_v1_server_async(
server: FastMCP1x,
host: str | None = None,
diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py
index d39a88bc8..efacf671e 100644
--- a/src/fastmcp/client/client.py
+++ b/src/fastmcp/client/client.py
@@ -299,13 +299,10 @@ class Client(
self._session_kwargs["sampling_callback"] = create_sampling_callback(
sampling_handler
)
- # Default to tools-enabled capabilities unless explicitly overridden
self._session_kwargs["sampling_capabilities"] = (
sampling_capabilities
if sampling_capabilities is not None
- else mcp.types.SamplingCapability(
- tools=mcp.types.SamplingToolsCapability()
- )
+ else mcp.types.SamplingCapability()
)
if elicitation_handler is not None:
@@ -367,11 +364,10 @@ class Client(
self._session_kwargs["sampling_callback"] = create_sampling_callback(
sampling_callback
)
- # Default to tools-enabled capabilities unless explicitly overridden
self._session_kwargs["sampling_capabilities"] = (
sampling_capabilities
if sampling_capabilities is not None
- else mcp.types.SamplingCapability(tools=mcp.types.SamplingToolsCapability())
+ else mcp.types.SamplingCapability()
)
def set_elicitation_callback(
diff --git a/src/fastmcp/client/sampling/__init__.py b/src/fastmcp/client/sampling/__init__.py
index 1cdb9ba1d..a0b259b10 100644
--- a/src/fastmcp/client/sampling/__init__.py
+++ b/src/fastmcp/client/sampling/__init__.py
@@ -1,6 +1,6 @@
import inspect
from collections.abc import Awaitable, Callable
-from typing import TypeAlias, TypeVar
+from typing import TypeAlias, TypeVar, cast
import mcp.types
from mcp import ClientSession, CreateMessageResult
diff --git a/src/fastmcp/client/sampling/handlers/google_genai.py b/src/fastmcp/client/sampling/handlers/google_genai.py
new file mode 100644
index 000000000..c072d5131
--- /dev/null
+++ b/src/fastmcp/client/sampling/handlers/google_genai.py
@@ -0,0 +1,368 @@
+"""Google GenAI sampling handler with tool support for FastMCP 3.0."""
+
+from collections.abc import Sequence
+from uuid import uuid4
+
+try:
+ from google.genai import Client as GoogleGenaiClient
+ from google.genai.types import (
+ Candidate,
+ Content,
+ FunctionCall,
+ FunctionCallingConfig,
+ FunctionCallingConfigMode,
+ FunctionDeclaration,
+ FunctionResponse,
+ GenerateContentConfig,
+ GenerateContentResponse,
+ ModelContent,
+ Part,
+ ThinkingConfig,
+ ToolConfig,
+ UserContent,
+ )
+ from google.genai.types import Tool as GoogleTool
+except ImportError as e:
+ raise ImportError(
+ "The `google-genai` package is not installed. "
+ "Install it with `pip install fastmcp[gemini]` or add `google-genai` "
+ "to your dependencies."
+ ) from e
+
+from mcp import ClientSession, ServerSession
+from mcp.shared.context import LifespanContextT, RequestContext
+from mcp.types import (
+ AudioContent,
+ CreateMessageResult,
+ CreateMessageResultWithTools,
+ ImageContent,
+ ModelPreferences,
+ SamplingMessage,
+ SamplingMessageContentBlock,
+ StopReason,
+ TextContent,
+ ToolChoice,
+ ToolResultContent,
+ ToolUseContent,
+)
+from mcp.types import CreateMessageRequestParams as SamplingParams
+from mcp.types import Tool as MCPTool
+
+__all__ = ["GoogleGenaiSamplingHandler"]
+
+
+class GoogleGenaiSamplingHandler:
+ """Sampling handler that uses the Google GenAI API with tool support.
+
+ Example:
+ ```python
+ from google.genai import Client
+ from fastmcp import FastMCP
+ from fastmcp.client.sampling.handlers.google_genai import (
+ GoogleGenaiSamplingHandler,
+ )
+
+ handler = GoogleGenaiSamplingHandler(
+ default_model="gemini-2.0-flash",
+ client=Client(),
+ )
+
+ server = FastMCP(sampling_handler=handler)
+ ```
+ """
+
+ def __init__(
+ self,
+ default_model: str,
+ client: GoogleGenaiClient | None = None,
+ thinking_budget: int | None = None,
+ ) -> None:
+ self.client: GoogleGenaiClient = client or GoogleGenaiClient()
+ self.default_model: str = default_model
+ self.thinking_budget: int | None = thinking_budget
+
+ async def __call__(
+ self,
+ messages: list[SamplingMessage],
+ params: SamplingParams,
+ context: RequestContext[ServerSession, LifespanContextT]
+ | RequestContext[ClientSession, LifespanContextT],
+ ) -> CreateMessageResult | CreateMessageResultWithTools:
+ contents: list[Content] = _convert_messages_to_google_genai_content(messages)
+
+ # Convert MCP tools to Google GenAI format
+ google_tools: list[GoogleTool] | None = None
+ tool_config: ToolConfig | None = None
+
+ if params.tools:
+ google_tools = [
+ _convert_tool_to_google_genai(tool) for tool in params.tools
+ ]
+ tool_config = _convert_tool_choice_to_google_genai(params.toolChoice)
+
+ # Select the model based on preferences
+ selected_model = self._get_model(model_preferences=params.modelPreferences)
+
+ # Configure thinking if a budget is specified
+ thinking_config = (
+ ThinkingConfig(thinking_budget=self.thinking_budget)
+ if self.thinking_budget is not None
+ else None
+ )
+
+ response: GenerateContentResponse = (
+ await self.client.aio.models.generate_content(
+ model=selected_model,
+ contents=contents,
+ config=GenerateContentConfig(
+ system_instruction=params.systemPrompt,
+ temperature=params.temperature,
+ max_output_tokens=params.maxTokens,
+ stop_sequences=params.stopSequences,
+ thinking_config=thinking_config,
+ tools=google_tools, # ty: ignore[invalid-argument-type]
+ tool_config=tool_config,
+ ),
+ )
+ )
+
+ # Return appropriate result type based on whether tools were provided
+ if params.tools:
+ return _response_to_result_with_tools(response, selected_model)
+ return _response_to_create_message_result(response, selected_model)
+
+ def _get_model(self, model_preferences: ModelPreferences | None) -> str:
+ if model_preferences and model_preferences.hints:
+ for hint in model_preferences.hints:
+ if hint.name and hint.name.startswith("gemini"):
+ return hint.name
+ return self.default_model
+
+
+def _convert_tool_to_google_genai(tool: MCPTool) -> GoogleTool:
+ """Convert an MCP Tool to Google GenAI format.
+
+ Google's parameters_json_schema accepts standard JSON Schema format,
+ so we pass tool.inputSchema directly without conversion.
+ """
+ return GoogleTool(
+ function_declarations=[
+ FunctionDeclaration(
+ name=tool.name,
+ description=tool.description or "",
+ parameters_json_schema=tool.inputSchema,
+ )
+ ]
+ )
+
+
+def _convert_tool_choice_to_google_genai(tool_choice: ToolChoice | None) -> ToolConfig:
+ """Convert MCP ToolChoice to Google GenAI ToolConfig."""
+ if tool_choice is None:
+ return ToolConfig(
+ function_calling_config=FunctionCallingConfig(
+ mode=FunctionCallingConfigMode.AUTO
+ )
+ )
+
+ if tool_choice.mode == "required":
+ return ToolConfig(
+ function_calling_config=FunctionCallingConfig(
+ mode=FunctionCallingConfigMode.ANY
+ )
+ )
+ if tool_choice.mode == "none":
+ return ToolConfig(
+ function_calling_config=FunctionCallingConfig(
+ mode=FunctionCallingConfigMode.NONE
+ )
+ )
+
+ # Default to AUTO for "auto" or any other value
+ return ToolConfig(
+ function_calling_config=FunctionCallingConfig(
+ mode=FunctionCallingConfigMode.AUTO
+ )
+ )
+
+
+def _sampling_content_to_google_genai_part(
+ content: TextContent
+ | ImageContent
+ | AudioContent
+ | ToolUseContent
+ | ToolResultContent,
+) -> Part:
+ """Convert MCP content to Google GenAI Part."""
+ if isinstance(content, TextContent):
+ return Part(text=content.text)
+
+ if isinstance(content, ToolUseContent):
+ # Note: thought_signature bypass is required for manually constructed tool calls.
+ # Google's Gemini 3+ models enforce thought signature validation for function calls.
+ # Since we're constructing these Parts from MCP protocol data (not from model responses),
+ # they lack legitimate signatures. The bypass value allows validation to pass.
+ # See: https://ai.google.dev/gemini-api/docs/thought-signatures
+ return Part(
+ function_call=FunctionCall(
+ name=content.name,
+ args=content.input,
+ ),
+ thought_signature=b"skip_thought_signature_validator",
+ )
+
+ if isinstance(content, ToolResultContent):
+ # Extract text from tool result content
+ result_parts: list[str] = []
+ if content.content:
+ for item in content.content:
+ if isinstance(item, TextContent):
+ result_parts.append(item.text)
+ else:
+ msg = f"Unsupported tool result content type: {type(item).__name__}"
+ raise ValueError(msg)
+ result_text = "".join(result_parts)
+
+ # Extract function name from toolUseId
+ # Our IDs are formatted as "{function_name}_{uuid8}", so extract the name.
+ # Note: This is a limitation of MCP's ToolResultContent which only carries
+ # toolUseId, while Google's FunctionResponse requires the function name.
+ tool_use_id = content.toolUseId
+ if "_" in tool_use_id:
+ # Split and rejoin all but the last part (the UUID suffix)
+ parts = tool_use_id.rsplit("_", 1)
+ function_name = parts[0]
+ else:
+ # Fallback: use the full ID as the name
+ function_name = tool_use_id
+
+ return Part(
+ function_response=FunctionResponse(
+ name=function_name,
+ response={"result": result_text},
+ )
+ )
+
+ msg = f"Unsupported content type: {type(content)}"
+ raise ValueError(msg)
+
+
+def _convert_messages_to_google_genai_content(
+ messages: Sequence[SamplingMessage],
+) -> list[Content]:
+ """Convert MCP messages to Google GenAI content."""
+ google_messages: list[Content] = []
+
+ for message in messages:
+ content = message.content
+
+ # Handle list content (tool calls + results)
+ if isinstance(content, list):
+ parts: list[Part] = []
+ for item in content:
+ parts.append(_sampling_content_to_google_genai_part(item))
+
+ if message.role == "user":
+ google_messages.append(UserContent(parts=parts))
+ elif message.role == "assistant":
+ google_messages.append(ModelContent(parts=parts))
+ else:
+ msg = f"Invalid message role: {message.role}"
+ raise ValueError(msg)
+ continue
+
+ # Handle single content item
+ part = _sampling_content_to_google_genai_part(content)
+
+ if message.role == "user":
+ google_messages.append(UserContent(parts=[part]))
+ elif message.role == "assistant":
+ google_messages.append(ModelContent(parts=[part]))
+ else:
+ msg = f"Invalid message role: {message.role}"
+ raise ValueError(msg)
+
+ return google_messages
+
+
+def _get_candidate_from_response(response: GenerateContentResponse) -> Candidate:
+ """Extract the first candidate from a response."""
+ if response.candidates and response.candidates[0]:
+ return response.candidates[0]
+ msg = "No candidate in response from completion."
+ raise ValueError(msg)
+
+
+def _response_to_create_message_result(
+ response: GenerateContentResponse,
+ model: str,
+) -> CreateMessageResult:
+ """Convert Google GenAI response to CreateMessageResult (no tools)."""
+ if not (text := response.text):
+ candidate = _get_candidate_from_response(response)
+ msg = f"No content in response: {candidate.finish_reason}"
+ raise ValueError(msg)
+
+ return CreateMessageResult(
+ content=TextContent(type="text", text=text),
+ role="assistant",
+ model=model,
+ )
+
+
+def _response_to_result_with_tools(
+ response: GenerateContentResponse,
+ model: str,
+) -> CreateMessageResultWithTools:
+ """Convert Google GenAI response to CreateMessageResultWithTools."""
+ candidate = _get_candidate_from_response(response)
+
+ # Determine stop reason and check for function calls
+ stop_reason: StopReason
+ finish_reason = candidate.finish_reason
+ has_function_calls = False
+
+ if candidate.content and candidate.content.parts:
+ for part in candidate.content.parts:
+ if part.function_call is not None:
+ has_function_calls = True
+ break
+
+ if has_function_calls:
+ stop_reason = "toolUse"
+ elif finish_reason == "STOP":
+ stop_reason = "endTurn"
+ elif finish_reason == "MAX_TOKENS":
+ stop_reason = "maxTokens"
+ else:
+ stop_reason = "endTurn"
+
+ # Build content list
+ content: list[SamplingMessageContentBlock] = []
+
+ if candidate.content and candidate.content.parts:
+ for part in candidate.content.parts:
+ # Note: Skip thought parts from thinking_config - not relevant for MCP responses
+ if part.text:
+ content.append(TextContent(type="text", text=part.text))
+ elif part.function_call is not None:
+ fc = part.function_call
+ fc_name: str = fc.name or "unknown"
+ content.append(
+ ToolUseContent(
+ type="tool_use",
+ id=f"{fc_name}_{uuid4().hex[:8]}", # Generate unique ID
+ name=fc_name,
+ input=dict(fc.args) if fc.args else {},
+ )
+ )
+
+ if not content:
+ raise ValueError("No content in response from completion")
+
+ return CreateMessageResultWithTools(
+ content=content,
+ role="assistant",
+ model=model,
+ stopReason=stop_reason,
+ )
diff --git a/src/fastmcp/client/sampling/handlers/openai.py b/src/fastmcp/client/sampling/handlers/openai.py
index 362e05905..38f5ff356 100644
--- a/src/fastmcp/client/sampling/handlers/openai.py
+++ b/src/fastmcp/client/sampling/handlers/openai.py
@@ -84,8 +84,9 @@ class OpenAISamplingHandler:
kwargs: dict[str, Any] = {
"model": model,
"messages": openai_messages,
- "max_completion_tokens": params.maxTokens,
}
+ if params.maxTokens is not None:
+ kwargs["max_completion_tokens"] = params.maxTokens
if params.temperature is not None:
kwargs["temperature"] = params.temperature
if params.stopSequences:
diff --git a/src/fastmcp/client/transports/config.py b/src/fastmcp/client/transports/config.py
index 5f303567d..c8d5a8a40 100644
--- a/src/fastmcp/client/transports/config.py
+++ b/src/fastmcp/client/transports/config.py
@@ -91,40 +91,57 @@ class MCPConfigTransport(ClientTransport):
yield session
return
- # Multiple servers - create composite with mounted proxies
- # Close any previous transports from prior connections to avoid leaking
- for t in self._transports:
- await t.close()
- self._transports = []
+ # Multiple servers - create composite with mounted proxies, connecting
+ # each ProxyClient so its underlying transport session stays alive for
+ # the duration of this context (fixes session persistence for
+ # streamable-http backends β see #2790).
timeout = session_kwargs.get("read_timeout_seconds")
composite = FastMCP[Any](name="MCPRouter")
- try:
- for name, server_config in self.config.mcpServers.items():
- transport, proxy = self._create_proxy(name, server_config, timeout)
- self._transports.append(transport)
- composite.mount(proxy, namespace=name if self.name_as_prefix else None)
- except Exception:
- # Clean up any transports created before the failure
+ async with contextlib.AsyncExitStack() as stack:
+ # Close any previous transports from prior connections to avoid leaking
for t in self._transports:
await t.close()
self._transports = []
- raise
- async with FastMCPTransport(mcp=composite).connect_session(
- **session_kwargs
- ) as session:
- yield session
+ try:
+ for name, server_config in self.config.mcpServers.items():
+ transport, _client, proxy = await self._create_proxy(
+ name, server_config, timeout, stack
+ )
+ self._transports.append(transport)
+ composite.mount(
+ proxy, namespace=name if self.name_as_prefix else None
+ )
+ except Exception:
+ # Clean up any transports created before the failure
+ for t in self._transports:
+ await t.close()
+ self._transports = []
+ raise
- def _create_proxy(
+ async with FastMCPTransport(mcp=composite).connect_session(
+ **session_kwargs
+ ) as session:
+ yield session
+
+ async def _create_proxy(
self,
name: str,
config: MCPServerTypes,
timeout: datetime.timedelta | None,
- ) -> tuple[ClientTransport, FastMCP[Any]]:
- """Create underlying transport and proxy server for a single backend."""
+ stack: contextlib.AsyncExitStack,
+ ) -> tuple[ClientTransport, Any, FastMCP[Any]]:
+ """Create underlying transport, proxy client, and proxy server for a single backend.
+
+ The ProxyClient is connected via the AsyncExitStack *before* being
+ passed to create_proxy so the factory sees it as connected and reuses
+ the same session for all tool calls (instead of creating fresh copies).
+
+ Returns a tuple of (transport, proxy_client, proxy_server).
+ """
# Import here to avoid circular dependency
- from fastmcp.server.providers.proxy import ProxyClient
+ from fastmcp.server.providers.proxy import StatefulProxyClient
tool_transforms = None
include_tags = None
@@ -144,7 +161,23 @@ class MCPConfigTransport(ClientTransport):
else:
transport = config.to_transport()
- client = ProxyClient(transport=transport, timeout=timeout)
+ client = StatefulProxyClient(transport=transport, timeout=timeout)
+ # Connect the client *before* create_proxy so _create_client_factory
+ # detects it as connected and reuses it for all tool calls, preserving
+ # the session ID across requests. StatefulProxyClient is used instead
+ # of ProxyClient because its context-restoring handler wrappers prevent
+ # stale ContextVars in the reused session's receive loop.
+ #
+ # StatefulProxyClient.__aexit__ is a no-op (by design, for the
+ # new_stateful() use case), so we cannot rely on enter_async_context
+ # alone to clean up. Instead we connect manually and push an
+ # explicit force-disconnect callback so the subprocess is terminated
+ # when the AsyncExitStack unwinds.
+ await client.__aenter__()
+ # Callbacks run LIFO: transport.close() must run *after*
+ # client._disconnect so push it first.
+ stack.push_async_callback(transport.close)
+ stack.push_async_callback(client._disconnect, force=True)
# Create proxy without include_tags/exclude_tags - we'll add them after tool transforms
proxy = create_proxy(
client,
@@ -160,7 +193,7 @@ class MCPConfigTransport(ClientTransport):
proxy.enable(tags=set(include_tags), only=True)
if exclude_tags:
proxy.disable(tags=set(exclude_tags))
- return transport, proxy
+ return transport, client, proxy
async def close(self):
for transport in self._transports:
diff --git a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py
index 7d40635c1..86f8c9aec 100644
--- a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py
+++ b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py
@@ -1,11 +1,10 @@
"""Provides a base mixin class and decorators for easy registration of class methods with FastMCP."""
+import inspect
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
-from mcp.types import Annotations, ToolAnnotations
-
import fastmcp
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
@@ -23,19 +22,57 @@ _DEFAULT_SEPARATOR_TOOL = "_"
_DEFAULT_SEPARATOR_RESOURCE = "+"
_DEFAULT_SEPARATOR_PROMPT = "_"
+# Sentinel key stored in registration dicts for the mixin-only `enabled` flag.
+# Prefixed with an underscore to avoid collisions with any from_function parameter.
+_MIXIN_ENABLED_KEY = "_mixin_enabled"
+
+# Valid keyword arguments for each from_function, derived once at import time
+# directly from the live signatures. They stay in sync automatically whenever
+# the underlying signatures gain or lose parameters β no manual updates needed.
+_TOOL_VALID_KWARGS: frozenset[str] = frozenset(
+ p for p in inspect.signature(Tool.from_function).parameters if p != "fn"
+)
+_RESOURCE_VALID_KWARGS: frozenset[str] = frozenset(
+ p
+ for p in inspect.signature(Resource.from_function).parameters
+ if p not in ("fn", "uri")
+)
+_PROMPT_VALID_KWARGS: frozenset[str] = frozenset(
+ p for p in inspect.signature(Prompt.from_function).parameters if p != "fn"
+)
+
def mcp_tool(
name: str | None = None,
- description: str | None = None,
- tags: set[str] | None = None,
- annotations: ToolAnnotations | dict[str, Any] | None = None,
- exclude_args: list[str] | None = None,
- serializer: Callable[[Any], str] | None = None, # Deprecated
- meta: dict[str, Any] | None = None,
+ *,
enabled: bool | None = None,
+ **kwargs: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
- """Decorator to mark a method as an MCP tool for later registration."""
- if serializer is not None and fastmcp.settings.deprecation_warnings:
+ """Decorator to mark a method as an MCP tool for later registration.
+
+ Accepts all parameters supported by ``Tool.from_function``. Any new
+ parameters added to ``Tool.from_function`` are automatically forwarded
+ without requiring changes here.
+
+ Args:
+ name: Tool name. Defaults to the decorated method name.
+ enabled: If ``False``, the tool is skipped during registration.
+ **kwargs: Additional keyword arguments forwarded verbatim to
+ ``Tool.from_function`` (e.g. ``description``, ``tags``,
+ ``annotations``, ``auth``, ``timeout``, ``version``, β¦).
+
+ Raises:
+ TypeError: If an unrecognised keyword argument is supplied. The error
+ is raised immediately at decoration time rather than later.
+ """
+ unknown = set(kwargs) - _TOOL_VALID_KWARGS
+ if unknown:
+ raise TypeError(
+ f"mcp_tool() got unexpected keyword argument(s): {sorted(unknown)!r}. "
+ f"Valid keyword arguments are: {sorted(_TOOL_VALID_KWARGS)}"
+ )
+
+ if "serializer" in kwargs and fastmcp.settings.deprecation_warnings:
warnings.warn(
"The `serializer` parameter is deprecated. "
"Return ToolResult from your tools for full control over serialization. "
@@ -45,17 +82,9 @@ def mcp_tool(
)
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
- call_args = {
- "name": name or get_fn_name(func),
- "description": description,
- "tags": tags,
- "annotations": annotations,
- "exclude_args": exclude_args,
- "serializer": serializer,
- "meta": meta,
- "enabled": enabled,
- }
- call_args = {k: v for k, v in call_args.items() if v is not None}
+ call_args: dict[str, Any] = {"name": name or get_fn_name(func), **kwargs}
+ if enabled is not None:
+ call_args[_MIXIN_ENABLED_KEY] = enabled
setattr(func, _MCP_REGISTRATION_TOOL_ATTR, call_args)
return func
@@ -66,32 +95,43 @@ def mcp_resource(
uri: str,
*,
name: str | None = None,
- title: str | None = None,
- description: str | None = None,
- mime_type: str | None = None,
- tags: set[str] | None = None,
- annotations: Annotations | None = None,
- meta: dict[str, Any] | None = None,
enabled: bool | None = None,
+ **kwargs: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
- """Decorator to mark a method as an MCP resource for later registration."""
+ """Decorator to mark a method as an MCP resource for later registration.
+
+ Accepts all parameters supported by ``Resource.from_function``. Any new
+ parameters added to ``Resource.from_function`` are automatically forwarded
+ without requiring changes here.
+
+ Args:
+ uri: Resource URI (required).
+ name: Resource name. Defaults to the decorated method name.
+ enabled: If ``False``, the resource is skipped during registration.
+ **kwargs: Additional keyword arguments forwarded verbatim to
+ ``Resource.from_function`` (e.g. ``description``, ``tags``,
+ ``mime_type``, ``auth``, ``version``, β¦).
+
+ Raises:
+ TypeError: If an unrecognised keyword argument is supplied. The error
+ is raised immediately at decoration time rather than later.
+ """
+ unknown = set(kwargs) - _RESOURCE_VALID_KWARGS
+ if unknown:
+ raise TypeError(
+ f"mcp_resource() got unexpected keyword argument(s): {sorted(unknown)!r}. "
+ f"Valid keyword arguments are: {sorted(_RESOURCE_VALID_KWARGS)}"
+ )
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
- call_args = {
+ call_args: dict[str, Any] = {
"uri": uri,
"name": name or get_fn_name(func),
- "title": title,
- "description": description,
- "mime_type": mime_type,
- "tags": tags,
- "annotations": annotations,
- "meta": meta,
- "enabled": enabled,
+ **kwargs,
}
- call_args = {k: v for k, v in call_args.items() if v is not None}
-
+ if enabled is not None:
+ call_args[_MIXIN_ENABLED_KEY] = enabled
setattr(func, _MCP_REGISTRATION_RESOURCE_ATTR, call_args)
-
return func
return decorator
@@ -99,26 +139,38 @@ def mcp_resource(
def mcp_prompt(
name: str | None = None,
- title: str | None = None,
- description: str | None = None,
- tags: set[str] | None = None,
- meta: dict[str, Any] | None = None,
+ *,
enabled: bool | None = None,
+ **kwargs: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
- """Decorator to mark a method as an MCP prompt for later registration."""
+ """Decorator to mark a method as an MCP prompt for later registration.
+
+ Accepts all parameters supported by ``Prompt.from_function``. Any new
+ parameters added to ``Prompt.from_function`` are automatically forwarded
+ without requiring changes here.
+
+ Args:
+ name: Prompt name. Defaults to the decorated method name.
+ enabled: If ``False``, the prompt is skipped during registration.
+ **kwargs: Additional keyword arguments forwarded verbatim to
+ ``Prompt.from_function`` (e.g. ``description``, ``tags``,
+ ``auth``, ``version``, β¦).
+
+ Raises:
+ TypeError: If an unrecognised keyword argument is supplied. The error
+ is raised immediately at decoration time rather than later.
+ """
+ unknown = set(kwargs) - _PROMPT_VALID_KWARGS
+ if unknown:
+ raise TypeError(
+ f"mcp_prompt() got unexpected keyword argument(s): {sorted(unknown)!r}. "
+ f"Valid keyword arguments are: {sorted(_PROMPT_VALID_KWARGS)}"
+ )
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
- call_args = {
- "name": name or get_fn_name(func),
- "title": title,
- "description": description,
- "tags": tags,
- "meta": meta,
- "enabled": enabled,
- }
-
- call_args = {k: v for k, v in call_args.items() if v is not None}
-
+ call_args: dict[str, Any] = {"name": name or get_fn_name(func), **kwargs}
+ if enabled is not None:
+ call_args[_MIXIN_ENABLED_KEY] = enabled
setattr(func, _MCP_REGISTRATION_PROMPT_ATTR, call_args)
return func
@@ -129,9 +181,9 @@ class MCPMixin:
"""Base mixin class for objects that can register tools, resources, and prompts
with a FastMCP server instance using decorators.
- This mixin provides methods like `register_all`, `register_tools`, etc.,
+ This mixin provides methods like ``register_all``, ``register_tools``, etc.,
which iterate over the methods of the inheriting class, find methods
- decorated with `@mcp_tool`, `@mcp_resource`, or `@mcp_prompt`, and
+ decorated with ``@mcp_tool``, ``@mcp_resource``, or ``@mcp_prompt``, and
register them with the provided FastMCP server instance.
"""
@@ -157,10 +209,10 @@ class MCPMixin:
Args:
mcp_server: The FastMCP server instance to register tools with.
- prefix: Optional prefix to prepend to tool names. If provided, the
- final name will be f"{prefix}{separator}{original_name}".
+ prefix: Optional prefix to prepend to tool names. If provided, the
+ final name will be ``f"{prefix}{separator}{original_name}"``.
separator: The separator string used between prefix and original name.
- Defaults to '_'.
+ Defaults to ``'_'``.
"""
for method, registration_info in self._get_methods_to_register(
_MCP_REGISTRATION_TOOL_ATTR
@@ -170,18 +222,11 @@ class MCPMixin:
f"{prefix}{separator}{registration_info['name']}"
)
- tool = Tool.from_function(
- fn=method,
- name=registration_info.get("name"),
- description=registration_info.get("description"),
- tags=registration_info.get("tags"),
- annotations=registration_info.get("annotations"),
- exclude_args=registration_info.get("exclude_args"),
- serializer=registration_info.get("serializer"),
- output_schema=registration_info.get("output_schema"),
- meta=registration_info.get("meta"),
- )
+ enabled = registration_info.pop(_MIXIN_ENABLED_KEY, True)
+ if enabled is False:
+ continue
+ tool = Tool.from_function(fn=method, **registration_info)
mcp_server.add_tool(tool)
def register_resources(
@@ -194,11 +239,12 @@ class MCPMixin:
Args:
mcp_server: The FastMCP server instance to register resources with.
- prefix: Optional prefix to prepend to resource names and URIs. If provided,
- the final name will be f"{prefix}{separator}{original_name}" and the
- final URI will be f"{prefix}{separator}{original_uri}".
- separator: The separator string used between prefix and original name/URI.
- Defaults to '+'.
+ prefix: Optional prefix to prepend to resource names and URIs. If
+ provided, the final name will be
+ ``f"{prefix}{separator}{original_name}"`` and the final URI will
+ be ``f"{prefix}{separator}{original_uri}"``.
+ separator: The separator string used between prefix and original
+ name/URI. Defaults to ``'+'``.
"""
for method, registration_info in self._get_methods_to_register(
_MCP_REGISTRATION_RESOURCE_ATTR
@@ -211,18 +257,11 @@ class MCPMixin:
f"{prefix}{separator}{registration_info['uri']}"
)
- resource = Resource.from_function(
- fn=method,
- uri=registration_info["uri"],
- name=registration_info.get("name"),
- title=registration_info.get("title"),
- description=registration_info.get("description"),
- mime_type=registration_info.get("mime_type"),
- tags=registration_info.get("tags"),
- annotations=registration_info.get("annotations"),
- meta=registration_info.get("meta"),
- )
+ enabled = registration_info.pop(_MIXIN_ENABLED_KEY, True)
+ if enabled is False:
+ continue
+ resource = Resource.from_function(fn=method, **registration_info)
mcp_server.add_resource(resource)
def register_prompts(
@@ -235,10 +274,10 @@ class MCPMixin:
Args:
mcp_server: The FastMCP server instance to register prompts with.
- prefix: Optional prefix to prepend to prompt names. If provided, the
- final name will be f"{prefix}{separator}{original_name}".
+ prefix: Optional prefix to prepend to prompt names. If provided,
+ the final name will be ``f"{prefix}{separator}{original_name}"``.
separator: The separator string used between prefix and original name.
- Defaults to '_'.
+ Defaults to ``'_'``.
"""
for method, registration_info in self._get_methods_to_register(
_MCP_REGISTRATION_PROMPT_ATTR
@@ -247,14 +286,12 @@ class MCPMixin:
registration_info["name"] = (
f"{prefix}{separator}{registration_info['name']}"
)
- prompt = Prompt.from_function(
- fn=method,
- name=registration_info.get("name"),
- title=registration_info.get("title"),
- description=registration_info.get("description"),
- tags=registration_info.get("tags"),
- meta=registration_info.get("meta"),
- )
+
+ enabled = registration_info.pop(_MIXIN_ENABLED_KEY, True)
+ if enabled is False:
+ continue
+
+ prompt = Prompt.from_function(fn=method, **registration_info)
mcp_server.add_prompt(prompt)
def register_all(
@@ -267,16 +304,16 @@ class MCPMixin:
) -> None:
"""Registers all marked tools, resources, and prompts with the server.
- This method calls `register_tools`, `register_resources`, and `register_prompts`
- internally, passing the provided prefix and separators.
+ This method calls ``register_tools``, ``register_resources``, and
+ ``register_prompts`` internally, passing the provided prefix and
+ separators.
Args:
mcp_server: The FastMCP server instance to register with.
- prefix: Optional prefix applied to all registered items unless overridden
- by a specific separator argument.
- tool_separator: Separator for tool names (defaults to '_').
- resource_separator: Separator for resource names/URIs (defaults to '+').
- prompt_separator: Separator for prompt names (defaults to '_').
+ prefix: Optional prefix applied to all registered items.
+ tool_separator: Separator for tool names (defaults to ``'_'``).
+ resource_separator: Separator for resource names/URIs (defaults to ``'+'``).
+ prompt_separator: Separator for prompt names (defaults to ``'_'``).
"""
self.register_tools(mcp_server, prefix=prefix, separator=tool_separator)
self.register_resources(mcp_server, prefix=prefix, separator=resource_separator)
diff --git a/src/fastmcp/dependencies.py b/src/fastmcp/dependencies.py
index b23222e9d..2aa8c145a 100644
--- a/src/fastmcp/dependencies.py
+++ b/src/fastmcp/dependencies.py
@@ -1,20 +1,14 @@
"""Dependency injection exports for FastMCP.
-This module re-exports dependency injection symbols from Docket and FastMCP
-to provide a clean, centralized import location for all dependency-related
-functionality.
+This module re-exports dependency injection symbols to provide a clean,
+centralized import location for all dependency-related functionality.
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
-using a vendored DI engine. Only task-related dependencies (CurrentDocket,
+using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket,
CurrentWorker) and background task execution require fastmcp[tasks].
"""
-# Try docket first for isinstance compatibility, fall back to vendored
-try:
- from docket import Depends
-except ImportError:
- from fastmcp._vendor.docket_di import Depends
-
+from uncalled_for import Dependency, Depends, Shared
from fastmcp.server.dependencies import (
CurrentAccessToken,
@@ -37,8 +31,10 @@ __all__ = [
"CurrentHeaders",
"CurrentRequest",
"CurrentWorker",
+ "Dependency",
"Depends",
"Progress",
"ProgressLike",
+ "Shared",
"TokenClaim",
]
diff --git a/src/fastmcp/experimental/__init__.py b/src/fastmcp/experimental/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/fastmcp/experimental/transforms/__init__.py b/src/fastmcp/experimental/transforms/__init__.py
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/src/fastmcp/experimental/transforms/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/fastmcp/experimental/transforms/code_mode.py b/src/fastmcp/experimental/transforms/code_mode.py
new file mode 100644
index 000000000..84b4e4ac7
--- /dev/null
+++ b/src/fastmcp/experimental/transforms/code_mode.py
@@ -0,0 +1,582 @@
+import asyncio
+import importlib
+import json
+from collections.abc import Awaitable, Callable, Sequence
+from typing import Annotated, Any, Literal, Protocol
+
+from mcp.types import TextContent
+from pydantic import Field
+
+from fastmcp.exceptions import NotFoundError
+from fastmcp.server.context import Context
+from fastmcp.server.transforms import GetToolNext
+from fastmcp.server.transforms.catalog import CatalogTransform
+from fastmcp.server.transforms.search.base import (
+ serialize_tools_for_output_json,
+ serialize_tools_for_output_markdown,
+)
+from fastmcp.tools.tool import Tool, ToolResult
+from fastmcp.utilities.versions import VersionSpec
+
+# ---------------------------------------------------------------------------
+# Type aliases
+# ---------------------------------------------------------------------------
+
+GetToolCatalog = Callable[[Context], Awaitable[Sequence[Tool]]]
+"""Async callable that returns the auth-filtered tool catalog."""
+
+SearchFn = Callable[[Sequence[Tool], str], Awaitable[Sequence[Tool]]]
+"""Async callable that searches a tool sequence by query string."""
+
+DiscoveryToolFactory = Callable[[GetToolCatalog], Tool]
+"""Factory that receives catalog access and returns a synthetic Tool."""
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
+ if asyncio.iscoroutinefunction(fn):
+ return fn
+
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
+ return fn(*args, **kwargs)
+
+ return wrapper
+
+
+def _unwrap_tool_result(result: ToolResult) -> dict[str, Any] | str:
+ """Convert a ToolResult for use in the sandbox.
+
+ - Output schema present β structured_content dict (matches the schema)
+ - Otherwise β concatenated text content as a string
+ """
+ if result.structured_content is not None:
+ return result.structured_content
+
+ parts: list[str] = []
+ for content in result.content:
+ if isinstance(content, TextContent):
+ parts.append(content.text)
+ else:
+ parts.append(str(content))
+ return "\n".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Sandbox providers
+# ---------------------------------------------------------------------------
+
+
+class SandboxProvider(Protocol):
+ """Interface for executing LLM-generated Python code in a sandbox.
+
+ WARNING: The ``code`` parameter passed to ``run`` contains untrusted,
+ LLM-generated Python. Implementations MUST execute it in an isolated
+ sandbox β never with plain ``exec()``. Use ``MontySandboxProvider``
+ (backed by ``pydantic-monty``) for production workloads.
+ """
+
+ async def run(
+ self,
+ code: str,
+ *,
+ inputs: dict[str, Any] | None = None,
+ external_functions: dict[str, Callable[..., Any]] | None = None,
+ ) -> Any: ...
+
+
+class MontySandboxProvider:
+ """Sandbox provider backed by `pydantic-monty`.
+
+ Args:
+ limits: Resource limits for sandbox execution. Supported keys:
+ ``max_duration_secs`` (float), ``max_allocations`` (int),
+ ``max_memory`` (int), ``max_recursion_depth`` (int),
+ ``gc_interval`` (int). All are optional; omit a key to
+ leave that limit uncapped.
+ """
+
+ def __init__(
+ self,
+ *,
+ limits: dict[str, Any] | None = None,
+ ) -> None:
+ self.limits = limits
+
+ async def run(
+ self,
+ code: str,
+ *,
+ inputs: dict[str, Any] | None = None,
+ external_functions: dict[str, Callable[..., Any]] | None = None,
+ ) -> Any:
+ try:
+ pydantic_monty = importlib.import_module("pydantic_monty")
+ except ModuleNotFoundError as exc:
+ raise ImportError(
+ "CodeMode requires pydantic-monty for the Monty sandbox provider. "
+ "Install it with `fastmcp[code-mode]` or pass a custom SandboxProvider."
+ ) from exc
+
+ inputs = inputs or {}
+ async_functions = {
+ key: _ensure_async(value)
+ for key, value in (external_functions or {}).items()
+ }
+
+ monty = pydantic_monty.Monty(
+ code,
+ inputs=list(inputs.keys()),
+ external_functions=list(async_functions.keys()),
+ )
+ run_kwargs: dict[str, Any] = {"external_functions": async_functions}
+ if inputs:
+ run_kwargs["inputs"] = inputs
+ if self.limits is not None:
+ run_kwargs["limits"] = self.limits
+ return await pydantic_monty.run_monty_async(monty, **run_kwargs)
+
+
+# ---------------------------------------------------------------------------
+# Built-in discovery tools
+# ---------------------------------------------------------------------------
+
+
+ToolDetailLevel = Literal["brief", "detailed", "full"]
+"""Detail level for discovery tool output.
+
+- ``"brief"``: tool names and one-line descriptions
+- ``"detailed"``: compact markdown with parameter names, types, and required markers
+- ``"full"``: complete JSON schema
+"""
+
+
+def _render_tools(tools: Sequence[Tool], detail: ToolDetailLevel) -> str:
+ """Render tools at the requested detail level.
+
+ The same detail value produces the same output format regardless of
+ which discovery tool calls this, so ``detail="detailed"`` on Search
+ gives identical formatting to ``detail="detailed"`` on GetSchemas.
+ """
+ if not tools:
+ if detail == "full":
+ return json.dumps([], indent=2)
+ return "No tools matched the query."
+ if detail == "full":
+ return json.dumps(serialize_tools_for_output_json(tools), indent=2)
+ if detail == "detailed":
+ return serialize_tools_for_output_markdown(tools)
+ # brief
+ lines: list[str] = []
+ for tool in tools:
+ desc = f": {tool.description}" if tool.description else ""
+ lines.append(f"- {tool.name}{desc}")
+ return "\n".join(lines)
+
+
+class Search:
+ """Discovery tool factory that searches the catalog by query.
+
+ Args:
+ search_fn: Async callable ``(tools, query) -> matching_tools``.
+ Defaults to BM25 ranking.
+ name: Name of the synthetic tool exposed to the LLM.
+ default_detail: Default detail level for search results.
+ ``"brief"`` returns tool names and descriptions only.
+ ``"detailed"`` returns compact markdown with parameter schemas.
+ ``"full"`` returns complete JSON tool definitions.
+ default_limit: Maximum number of results to return.
+ The LLM can override this per call. ``None`` means no limit.
+ """
+
+ def __init__(
+ self,
+ *,
+ search_fn: SearchFn | None = None,
+ name: str = "search",
+ default_detail: ToolDetailLevel | None = None,
+ default_limit: int | None = None,
+ ) -> None:
+ if search_fn is None:
+ from fastmcp.server.transforms.search.bm25 import BM25SearchTransform
+
+ _bm25 = BM25SearchTransform(max_results=default_limit or 50)
+ search_fn = _bm25._search
+ self._search_fn = search_fn
+ self._name = name
+ self._default_detail: ToolDetailLevel = default_detail or "brief"
+ self._default_limit = default_limit
+
+ def __call__(self, get_catalog: GetToolCatalog) -> Tool:
+ search_fn = self._search_fn
+ default_detail = self._default_detail
+ default_limit = self._default_limit
+
+ async def search(
+ query: Annotated[str, "Search query to find available tools"],
+ tags: Annotated[
+ list[str] | None,
+ "Filter to tools with any of these tags before searching",
+ ] = None,
+ detail: Annotated[
+ ToolDetailLevel,
+ "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
+ ] = default_detail,
+ limit: Annotated[
+ int | None,
+ "Maximum number of results to return",
+ ] = default_limit,
+ ctx: Context = None, # type: ignore[assignment]
+ ) -> str:
+ """Search for available tools by query.
+
+ Returns matching tools ranked by relevance.
+ """
+ catalog = await get_catalog(ctx)
+ catalog_size = len(catalog)
+ tools: Sequence[Tool] = catalog
+ if tags:
+ tag_set = set(tags)
+ has_untagged = "untagged" in tag_set
+ real_tags = tag_set - {"untagged"}
+ tools = [
+ t
+ for t in tools
+ if (t.tags & real_tags) or (has_untagged and not t.tags)
+ ]
+ results = await search_fn(tools, query)
+ if limit is not None:
+ results = results[:limit]
+ rendered = _render_tools(results, detail)
+ if len(results) < catalog_size and detail != "full":
+ n = len(results)
+ rendered = f"{n} of {catalog_size} tools:\n\n{rendered}"
+ return rendered
+
+ return Tool.from_function(fn=search, name=self._name)
+
+
+class GetSchemas:
+ """Discovery tool factory that returns schemas for tools by name.
+
+ Args:
+ name: Name of the synthetic tool exposed to the LLM.
+ default_detail: Default detail level for schema results.
+ ``"brief"`` returns tool names and descriptions only.
+ ``"detailed"`` renders compact markdown with parameter names,
+ types, and required markers.
+ ``"full"`` returns the complete JSON schema.
+ """
+
+ def __init__(
+ self,
+ *,
+ name: str = "get_schema",
+ default_detail: ToolDetailLevel | None = None,
+ ) -> None:
+ self._name = name
+ self._default_detail: ToolDetailLevel = default_detail or "detailed"
+
+ def __call__(self, get_catalog: GetToolCatalog) -> Tool:
+ default_detail = self._default_detail
+
+ async def get_schema(
+ tools: Annotated[
+ list[str],
+ "List of tool names to get schemas for",
+ ],
+ detail: Annotated[
+ ToolDetailLevel,
+ "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
+ ] = default_detail,
+ ctx: Context = None, # type: ignore[assignment]
+ ) -> str:
+ """Get parameter schemas for specific tools.
+
+ Use after searching to get the detail needed to call a tool.
+ """
+ catalog = await get_catalog(ctx)
+ catalog_by_name = {t.name: t for t in catalog}
+ matched = [catalog_by_name[n] for n in tools if n in catalog_by_name]
+ not_found = [n for n in tools if n not in catalog_by_name]
+
+ if not matched and not_found:
+ return f"Tools not found: {', '.join(not_found)}"
+
+ if detail == "full":
+ data = serialize_tools_for_output_json(matched)
+ if not_found:
+ data.append({"not_found": not_found})
+ return json.dumps(data, indent=2)
+
+ result = _render_tools(matched, detail)
+ if not_found:
+ result += f"\n\nTools not found: {', '.join(not_found)}"
+ return result
+
+ return Tool.from_function(fn=get_schema, name=self._name)
+
+
+class GetTags:
+ """Discovery tool factory that lists tool tags from the catalog.
+
+ Reads ``tool.tags`` from the catalog and groups tools by tag. Tools
+ without tags appear under ``"untagged"``.
+
+ Args:
+ name: Name of the synthetic tool exposed to the LLM.
+ default_detail: Default detail level.
+ ``"brief"`` returns tag names with tool counts.
+ ``"full"`` lists all tools under each tag.
+ """
+
+ def __init__(
+ self,
+ *,
+ name: str = "tags",
+ default_detail: Literal["brief", "full"] | None = None,
+ ) -> None:
+ self._name = name
+ self._default_detail: Literal["brief", "full"] = default_detail or "brief"
+
+ def __call__(self, get_catalog: GetToolCatalog) -> Tool:
+ default_detail = self._default_detail
+
+ async def tags(
+ detail: Annotated[
+ Literal["brief", "full"],
+ "Level of detail: 'brief' for tag names and counts, 'full' for tools listed under each tag",
+ ] = default_detail,
+ ctx: Context = None, # type: ignore[assignment]
+ ) -> str:
+ """List available tool tags.
+
+ Use to browse available tools by tag before searching.
+ """
+ catalog = await get_catalog(ctx)
+ by_tag: dict[str, list[Tool]] = {}
+ for tool in catalog:
+ if tool.tags:
+ for tag in tool.tags:
+ by_tag.setdefault(tag, []).append(tool)
+ else:
+ by_tag.setdefault("untagged", []).append(tool)
+
+ if not by_tag:
+ return "No tools available."
+
+ if detail == "brief":
+ lines = [
+ f"- {tag} ({len(tools)} tool{'s' if len(tools) != 1 else ''})"
+ for tag, tools in sorted(by_tag.items())
+ ]
+ return "\n".join(lines)
+
+ blocks: list[str] = []
+ for tag, tools in sorted(by_tag.items()):
+ lines = [f"### {tag}"]
+ for tool in tools:
+ desc = f": {tool.description}" if tool.description else ""
+ lines.append(f"- {tool.name}{desc}")
+ blocks.append("\n".join(lines))
+ return "\n\n".join(blocks)
+
+ return Tool.from_function(fn=tags, name=self._name)
+
+
+class ListTools:
+ """Discovery tool factory that lists all tools in the catalog.
+
+ Args:
+ name: Name of the synthetic tool exposed to the LLM.
+ default_detail: Default detail level.
+ ``"brief"`` returns tool names and one-line descriptions.
+ ``"detailed"`` returns compact markdown with parameter schemas.
+ ``"full"`` returns the complete JSON schema.
+ """
+
+ def __init__(
+ self,
+ *,
+ name: str = "list_tools",
+ default_detail: ToolDetailLevel | None = None,
+ ) -> None:
+ self._name = name
+ self._default_detail: ToolDetailLevel = default_detail or "brief"
+
+ def __call__(self, get_catalog: GetToolCatalog) -> Tool:
+ default_detail = self._default_detail
+
+ async def list_tools(
+ detail: Annotated[
+ ToolDetailLevel,
+ "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
+ ] = default_detail,
+ ctx: Context = None, # type: ignore[assignment]
+ ) -> str:
+ """List all available tools.
+
+ Use to see the full catalog before searching or calling tools.
+ """
+ catalog = await get_catalog(ctx)
+ return _render_tools(catalog, detail)
+
+ return Tool.from_function(fn=list_tools, name=self._name)
+
+
+# ---------------------------------------------------------------------------
+# CodeMode
+# ---------------------------------------------------------------------------
+
+
+def _default_discovery_tools() -> list[DiscoveryToolFactory]:
+ return [Search(), GetSchemas()]
+
+
+class CodeMode(CatalogTransform):
+ """Transform that collapses all tools into discovery + execute meta-tools.
+
+ Discovery tools are composable via the ``discovery_tools`` parameter.
+ Each is a callable that receives catalog access and returns a ``Tool``.
+ By default, ``Search`` and ``GetSchemas`` are included for
+ progressive disclosure: search finds candidates, get_schema retrieves
+ parameter details, and execute runs code.
+
+ The ``execute`` tool is always present and provides a sandboxed Python
+ environment with ``call_tool(name, params)`` in scope.
+ """
+
+ def __init__(
+ self,
+ *,
+ sandbox_provider: SandboxProvider | None = None,
+ discovery_tools: list[DiscoveryToolFactory] | None = None,
+ execute_tool_name: str = "execute",
+ execute_description: str | None = None,
+ ) -> None:
+ super().__init__()
+ self.execute_tool_name = execute_tool_name
+ self.execute_description = execute_description
+ self.sandbox_provider = sandbox_provider or MontySandboxProvider()
+
+ self._discovery_factories = (
+ discovery_tools
+ if discovery_tools is not None
+ else _default_discovery_tools()
+ )
+ self._built_discovery_tools: list[Tool] | None = None
+ self._cached_execute_tool: Tool | None = None
+
+ def _build_discovery_tools(self) -> list[Tool]:
+ if self._built_discovery_tools is None:
+ tools = [
+ factory(self.get_tool_catalog) for factory in self._discovery_factories
+ ]
+ names = {t.name for t in tools}
+ if self.execute_tool_name in names:
+ raise ValueError(
+ f"Discovery tool name '{self.execute_tool_name}' "
+ f"collides with execute_tool_name."
+ )
+ if len(names) != len(tools):
+ raise ValueError("Discovery tools must have unique names.")
+ self._built_discovery_tools = tools
+ return self._built_discovery_tools
+
+ async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
+ return [*self._build_discovery_tools(), self._get_execute_tool()]
+
+ async def get_tool(
+ self,
+ name: str,
+ call_next: GetToolNext,
+ *,
+ version: VersionSpec | None = None,
+ ) -> Tool | None:
+ for tool in self._build_discovery_tools():
+ if tool.name == name:
+ return tool
+ if name == self.execute_tool_name:
+ return self._get_execute_tool()
+ return await call_next(name, version=version)
+
+ def _build_execute_description(self) -> str:
+ if self.execute_description is not None:
+ return self.execute_description
+
+ return (
+ "Chain `await call_tool(...)` calls in one Python block; prefer returning the final answer from a single block.\n"
+ "Use `return` to produce output.\n"
+ "Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope."
+ )
+
+ @staticmethod
+ def _find_tool(name: str, tools: Sequence[Tool]) -> Tool | None:
+ """Find a tool by name from a pre-fetched list."""
+ for tool in tools:
+ if tool.name == name:
+ return tool
+ return None
+
+ def _get_execute_tool(self) -> Tool:
+ if self._cached_execute_tool is None:
+ self._cached_execute_tool = self._make_execute_tool()
+ return self._cached_execute_tool
+
+ def _make_execute_tool(self) -> Tool:
+ transform = self
+
+ async def execute(
+ code: Annotated[
+ str,
+ Field(
+ description=(
+ "Python async code to execute tool calls via call_tool(name, arguments)"
+ )
+ ),
+ ],
+ ctx: Context = None, # type: ignore[assignment]
+ ) -> Any:
+ """Execute tool calls using Python code."""
+ cached_tools: Sequence[Tool] | None = None
+
+ async def _get_cached_tools() -> Sequence[Tool]:
+ nonlocal cached_tools
+ if cached_tools is None:
+ cached_tools = await transform.get_tool_catalog(ctx)
+ return cached_tools
+
+ async def call_tool(tool_name: str, params: dict[str, Any]) -> Any:
+ backend_tools = await _get_cached_tools()
+ tool = transform._find_tool(tool_name, backend_tools)
+ if tool is None:
+ raise NotFoundError(f"Unknown tool: {tool_name}")
+
+ result = await ctx.fastmcp.call_tool(tool.name, params)
+ return _unwrap_tool_result(result)
+
+ return await transform.sandbox_provider.run(
+ code,
+ external_functions={"call_tool": call_tool},
+ )
+
+ return Tool.from_function(
+ fn=execute,
+ name=self.execute_tool_name,
+ description=self._build_execute_description(),
+ )
+
+
+__all__ = [
+ "CodeMode",
+ "GetSchemas",
+ "GetTags",
+ "GetToolCatalog",
+ "ListTools",
+ "MontySandboxProvider",
+ "SandboxProvider",
+ "Search",
+]
diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py
index 5f7fbd511..30642683b 100644
--- a/src/fastmcp/resources/types.py
+++ b/src/fastmcp/resources/types.py
@@ -26,7 +26,11 @@ class TextResource(Resource):
async def read(self) -> ResourceResult:
"""Read the text content."""
return ResourceResult(
- contents=[ResourceContent(content=self.text, mime_type=self.mime_type)]
+ contents=[
+ ResourceContent(
+ content=self.text, mime_type=self.mime_type, meta=self.meta
+ )
+ ]
)
@@ -38,7 +42,11 @@ class BinaryResource(Resource):
async def read(self) -> ResourceResult:
"""Read the binary content."""
return ResourceResult(
- contents=[ResourceContent(content=self.data, mime_type=self.mime_type)]
+ contents=[
+ ResourceContent(
+ content=self.data, mime_type=self.mime_type, meta=self.meta
+ )
+ ]
)
diff --git a/src/fastmcp/server/__init__.py b/src/fastmcp/server/__init__.py
index fb9afd895..3f64a0f39 100644
--- a/src/fastmcp/server/__init__.py
+++ b/src/fastmcp/server/__init__.py
@@ -1,6 +1,13 @@
+import importlib
+
from .context import Context
from .server import FastMCP, create_proxy
-from . import dependencies
+
+
+def __getattr__(name: str) -> object:
+ if name == "dependencies":
+ return importlib.import_module("fastmcp.server.dependencies")
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["Context", "FastMCP", "create_proxy"]
diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py
index 9da7bc8e2..fcb0b673c 100644
--- a/src/fastmcp/server/apps.py
+++ b/src/fastmcp/server/apps.py
@@ -7,7 +7,7 @@ UI metadata for clients that support interactive app rendering.
from __future__ import annotations
-from typing import Any
+from typing import Any, Literal
from pydantic import BaseModel, Field
@@ -92,7 +92,7 @@ class AppConfig(BaseModel):
alias="resourceUri",
description="URI of the UI resource (typically ui:// scheme). Tools only.",
)
- visibility: list[str] | None = Field(
+ visibility: list[Literal["app", "model"]] | None = Field(
default=None,
description="Where this tool is visible: 'app', 'model', or both. Tools only.",
)
diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py
index 94e23dca6..cd6a300ad 100644
--- a/src/fastmcp/server/auth/__init__.py
+++ b/src/fastmcp/server/auth/__init__.py
@@ -1,7 +1,10 @@
+from typing import TYPE_CHECKING
+
from .auth import (
OAuthProvider,
TokenVerifier,
RemoteAuthProvider,
+ MultiAuth,
AccessToken,
AuthProvider,
)
@@ -12,10 +15,44 @@ from .authorization import (
restrict_tag,
run_auth_checks,
)
-from .providers.debug import DebugTokenVerifier
-from .providers.jwt import JWTVerifier, StaticTokenVerifier
-from .oauth_proxy import OAuthProxy
-from .oidc_proxy import OIDCProxy
+
+if TYPE_CHECKING:
+ from .oauth_proxy import OAuthProxy as OAuthProxy
+ from .oidc_proxy import OIDCProxy as OIDCProxy
+ from .providers.debug import DebugTokenVerifier as DebugTokenVerifier
+ from .providers.jwt import JWTVerifier as JWTVerifier
+ from .providers.jwt import StaticTokenVerifier as StaticTokenVerifier
+
+
+# --- Lazy imports for performance (see #3292) ---
+# These providers pull in heavy deps (authlib, cryptography, key_value.aio,
+# beartype) that most users never need. Keeping them behind __getattr__
+# avoids ~150ms+ of import overhead for the common server-only case.
+# Do not convert these back to top-level imports.
+
+
+def __getattr__(name: str) -> object:
+ if name == "DebugTokenVerifier":
+ from .providers.debug import DebugTokenVerifier
+
+ return DebugTokenVerifier
+ if name == "JWTVerifier":
+ from .providers.jwt import JWTVerifier
+
+ return JWTVerifier
+ if name == "StaticTokenVerifier":
+ from .providers.jwt import StaticTokenVerifier
+
+ return StaticTokenVerifier
+ if name == "OAuthProxy":
+ from .oauth_proxy import OAuthProxy
+
+ return OAuthProxy
+ if name == "OIDCProxy":
+ from .oidc_proxy import OIDCProxy
+
+ return OIDCProxy
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
@@ -25,6 +62,7 @@ __all__ = [
"AuthProvider",
"DebugTokenVerifier",
"JWTVerifier",
+ "MultiAuth",
"OAuthProvider",
"OAuthProxy",
"OIDCProxy",
diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py
index b8b8b1f8c..873c1945a 100644
--- a/src/fastmcp/server/auth/auth.py
+++ b/src/fastmcp/server/auth/auth.py
@@ -469,6 +469,117 @@ class RemoteAuthProvider(AuthProvider):
return routes
+class MultiAuth(AuthProvider):
+ """Composes an optional auth server with additional token verifiers.
+
+ Use this when a single server needs to accept tokens from multiple sources.
+ For example, an OAuth proxy for interactive clients combined with a JWT
+ verifier for machine-to-machine tokens.
+
+ Token verification tries the server first (if present), then each verifier
+ in order, returning the first successful result. Routes and OAuth metadata
+ come from the server; verifiers contribute only token verification.
+
+ Example:
+ ```python
+ from fastmcp.server.auth import MultiAuth, JWTVerifier, OAuthProxy
+
+ auth = MultiAuth(
+ server=OAuthProxy(issuer_url="https://login.example.com/..."),
+ verifiers=[JWTVerifier(jwks_uri="https://example.com/.well-known/jwks.json")],
+ )
+ mcp = FastMCP("my-server", auth=auth)
+ ```
+ """
+
+ def __init__(
+ self,
+ *,
+ server: AuthProvider | None = None,
+ verifiers: list[TokenVerifier] | TokenVerifier | None = None,
+ base_url: AnyHttpUrl | str | None = None,
+ required_scopes: list[str] | None = None,
+ ):
+ """Initialize the multi-auth provider.
+
+ Args:
+ server: Optional auth provider (e.g., OAuthProxy) that owns routes
+ and OAuth metadata. Also participates in token verification as
+ the first verifier tried.
+ verifiers: One or more token verifiers to try after the server.
+ base_url: Override the base URL. Defaults to the server's base_url.
+ required_scopes: Override required scopes. Defaults to the server's.
+ """
+ if verifiers is None:
+ verifiers = []
+ elif isinstance(verifiers, TokenVerifier):
+ verifiers = [verifiers]
+
+ if server is None and not verifiers:
+ raise ValueError("MultiAuth requires at least a server or one verifier")
+
+ effective_base_url = base_url or (server.base_url if server else None)
+ effective_scopes = (
+ required_scopes
+ if required_scopes is not None
+ else (server.required_scopes if server else None)
+ )
+
+ super().__init__(base_url=effective_base_url, required_scopes=effective_scopes)
+ self.server = server
+ self.verifiers = list(verifiers)
+
+ self._sources: list[AuthProvider] = []
+ if self.server is not None:
+ self._sources.append(self.server)
+ self._sources.extend(self.verifiers)
+
+ async def verify_token(self, token: str) -> AccessToken | None:
+ """Verify a token by trying the server, then each verifier in order.
+
+ Each source is tried independently. If a source raises an exception,
+ it is logged and treated as a non-match so that remaining sources
+ still get a chance to verify the token.
+ """
+ for source in self._sources:
+ try:
+ result = await source.verify_token(token)
+ if result is not None:
+ return result
+ except Exception:
+ logger.debug(
+ "Token verification failed for %s, trying next source",
+ type(source).__name__,
+ exc_info=True,
+ )
+
+ return None
+
+ def set_mcp_path(self, mcp_path: str | None) -> None:
+ """Propagate MCP path to the server and all verifiers."""
+ super().set_mcp_path(mcp_path)
+ if self.server is not None:
+ self.server.set_mcp_path(mcp_path)
+ for verifier in self.verifiers:
+ verifier.set_mcp_path(mcp_path)
+
+ def get_routes(self, mcp_path: str | None = None) -> list[Route]:
+ """Delegate route creation to the server."""
+ if self.server is not None:
+ return self.server.get_routes(mcp_path)
+ return []
+
+ def get_well_known_routes(self, mcp_path: str | None = None) -> list[Route]:
+ """Delegate well-known route creation to the server.
+
+ This ensures that server-specific well-known route logic (e.g.,
+ OAuthProvider's RFC 8414 path-aware discovery) is preserved.
+ """
+ if self.server is not None:
+ return self.server.get_well_known_routes(mcp_path)
+ return []
+
+
class OAuthProvider(
AuthProvider,
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken],
diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py
index 868631d13..310d26f8a 100644
--- a/src/fastmcp/server/auth/providers/azure.py
+++ b/src/fastmcp/server/auth/providers/azure.py
@@ -10,8 +10,10 @@ import hashlib
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, cast
+import httpx
from key_value.aio.protocols import AsyncKeyValue
+from fastmcp.dependencies import Dependency
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes
@@ -107,6 +109,7 @@ class AzureProvider(OAuthProxy):
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
base_authority: str = "login.microsoftonline.com",
+ http_client: httpx.AsyncClient | None = None,
) -> None:
"""Initialize Azure OAuth provider.
@@ -151,6 +154,9 @@ class AzureProvider(OAuthProxy):
When True, users see a consent screen before being redirected to Azure.
When False, authorization proceeds directly without user confirmation.
SECURITY WARNING: Only disable for local development or testing environments.
+ http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches.
+ When provided, the client is reused for JWT key fetches and the caller
+ is responsible for its lifecycle. When None (default), a fresh client is created per fetch.
"""
# Parse scopes if provided as string
parsed_required_scopes = parse_scopes(required_scopes)
@@ -202,6 +208,7 @@ class AzureProvider(OAuthProxy):
audience=client_id,
algorithm="RS256",
required_scopes=validation_scopes, # Only validate non-OIDC scopes
+ http_client=http_client,
)
# Build Azure OAuth endpoints with tenant
@@ -621,12 +628,6 @@ class AzureJWTVerifier(JWTVerifier):
# --- Dependency injection support ---
# These require fastmcp[azure] extra for azure-identity
-# Check if DI engine is available
-try:
- from docket.dependencies import Dependency
-except ImportError:
- from fastmcp._vendor.docket_di import Dependency
-
def _require_azure_identity(feature: str) -> None:
"""Raise ImportError with install instructions if azure-identity is not available."""
@@ -639,7 +640,7 @@ def _require_azure_identity(feature: str) -> None:
) from e
-class _EntraOBOToken(Dependency): # type: ignore[misc]
+class _EntraOBOToken(Dependency[str]):
"""Dependency that performs OBO token exchange for Microsoft Entra.
Uses azure.identity's OnBehalfOfCredential for async-native OBO,
diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py
index 4fb5ebb53..7af28e026 100644
--- a/src/fastmcp/server/auth/providers/discord.py
+++ b/src/fastmcp/server/auth/providers/discord.py
@@ -21,6 +21,7 @@ Example:
from __future__ import annotations
+import contextlib
import time
from datetime import datetime
@@ -49,20 +50,29 @@ class DiscordTokenVerifier(TokenVerifier):
*,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
+ http_client: httpx.AsyncClient | None = None,
):
"""Initialize the Discord token verifier.
Args:
required_scopes: Required OAuth scopes (e.g., ['email'])
timeout_seconds: HTTP request timeout
+ http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ the client is reused across calls and the caller is responsible for its
+ lifecycle. When None (default), a fresh client is created per call.
"""
super().__init__(required_scopes=required_scopes)
self.timeout_seconds = timeout_seconds
+ self._http_client = http_client
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify Discord OAuth token by calling Discord's tokeninfo API."""
try:
- async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
+ async with (
+ contextlib.nullcontext(self._http_client)
+ if self._http_client is not None
+ else httpx.AsyncClient(timeout=self.timeout_seconds)
+ ) as client:
# Use Discord's tokeninfo endpoint to validate the token
headers = {
"Authorization": f"Bearer {token}",
@@ -183,6 +193,7 @@ class DiscordProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
+ http_client: httpx.AsyncClient | None = None,
):
"""Initialize Discord OAuth provider.
@@ -210,6 +221,9 @@ class DiscordProvider(OAuthProxy):
When True, users see a consent screen before being redirected to Discord.
When False, authorization proceeds directly without user confirmation.
SECURITY WARNING: Only disable for local development or testing environments.
+ http_client: Optional httpx.AsyncClient for connection pooling in token verification.
+ When provided, the client is reused across verify_token calls and the caller
+ is responsible for its lifecycle. When None (default), a fresh client is created per call.
"""
# Parse scopes if provided as string
required_scopes_final = (
@@ -222,6 +236,7 @@ class DiscordProvider(OAuthProxy):
token_verifier = DiscordTokenVerifier(
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
+ http_client=http_client,
)
# Initialize OAuth proxy with Discord endpoints
diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py
index abaaa439a..01331ba25 100644
--- a/src/fastmcp/server/auth/providers/github.py
+++ b/src/fastmcp/server/auth/providers/github.py
@@ -21,6 +21,8 @@ Example:
from __future__ import annotations
+import contextlib
+
import httpx
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@@ -46,20 +48,29 @@ class GitHubTokenVerifier(TokenVerifier):
*,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
+ http_client: httpx.AsyncClient | None = None,
):
"""Initialize the GitHub token verifier.
Args:
required_scopes: Required OAuth scopes (e.g., ['user:email'])
timeout_seconds: HTTP request timeout
+ http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ the client is reused across calls and the caller is responsible for its
+ lifecycle. When None (default), a fresh client is created per call.
"""
super().__init__(required_scopes=required_scopes)
self.timeout_seconds = timeout_seconds
+ self._http_client = http_client
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify GitHub OAuth token by calling GitHub API."""
try:
- async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
+ async with (
+ contextlib.nullcontext(self._http_client)
+ if self._http_client is not None
+ else httpx.AsyncClient(timeout=self.timeout_seconds)
+ ) as client:
# Get token info from GitHub API
response = await client.get(
"https://api.github.com/user",
@@ -181,6 +192,7 @@ class GitHubProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
+ http_client: httpx.AsyncClient | None = None,
):
"""Initialize GitHub OAuth provider.
@@ -205,6 +217,9 @@ class GitHubProvider(OAuthProxy):
When True, users see a consent screen before being redirected to GitHub.
When False, authorization proceeds directly without user confirmation.
SECURITY WARNING: Only disable for local development or testing environments.
+ http_client: Optional httpx.AsyncClient for connection pooling in token verification.
+ When provided, the client is reused across verify_token calls and the caller
+ is responsible for its lifecycle. When None (default), a fresh client is created per call.
"""
# Parse scopes if provided as string
required_scopes_final = (
@@ -215,6 +230,7 @@ class GitHubProvider(OAuthProxy):
token_verifier = GitHubTokenVerifier(
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
+ http_client=http_client,
)
# Initialize OAuth proxy with GitHub endpoints
diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py
index 80deac6e3..0dd509e33 100644
--- a/src/fastmcp/server/auth/providers/google.py
+++ b/src/fastmcp/server/auth/providers/google.py
@@ -21,6 +21,7 @@ Example:
from __future__ import annotations
+import contextlib
import time
import httpx
@@ -48,20 +49,29 @@ class GoogleTokenVerifier(TokenVerifier):
*,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
+ http_client: httpx.AsyncClient | None = None,
):
"""Initialize the Google token verifier.
Args:
required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email'])
timeout_seconds: HTTP request timeout
+ http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ the client is reused across calls and the caller is responsible for its
+ lifecycle. When None (default), a fresh client is created per call.
"""
super().__init__(required_scopes=required_scopes)
self.timeout_seconds = timeout_seconds
+ self._http_client = http_client
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify Google OAuth token by calling Google's tokeninfo API."""
try:
- async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
+ async with (
+ contextlib.nullcontext(self._http_client)
+ if self._http_client is not None
+ else httpx.AsyncClient(timeout=self.timeout_seconds)
+ ) as client:
# Use Google's tokeninfo endpoint to validate the token
response = await client.get(
"https://www.googleapis.com/oauth2/v1/tokeninfo",
@@ -198,6 +208,7 @@ class GoogleProvider(OAuthProxy):
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
extra_authorize_params: dict[str, str] | None = None,
+ http_client: httpx.AsyncClient | None = None,
):
"""Initialize Google OAuth provider.
@@ -229,6 +240,9 @@ class GoogleProvider(OAuthProxy):
By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
refresh tokens are returned. You can override these defaults or add additional parameters.
Example: {"prompt": "select_account"} to let users choose their Google account.
+ http_client: Optional httpx.AsyncClient for connection pooling in token verification.
+ When provided, the client is reused across verify_token calls and the caller
+ is responsible for its lifecycle. When None (default), a fresh client is created per call.
"""
# Parse scopes if provided as string
# Google requires at least one scope - openid is the minimal OIDC scope
@@ -240,6 +254,7 @@ class GoogleProvider(OAuthProxy):
token_verifier = GoogleTokenVerifier(
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
+ http_client=http_client,
)
# Set Google-specific defaults for extra authorize params
diff --git a/src/fastmcp/server/auth/providers/introspection.py b/src/fastmcp/server/auth/providers/introspection.py
index 707e25471..09e20abfd 100644
--- a/src/fastmcp/server/auth/providers/introspection.py
+++ b/src/fastmcp/server/auth/providers/introspection.py
@@ -24,7 +24,10 @@ Example:
from __future__ import annotations
import base64
+import contextlib
+import hashlib
import time
+from dataclasses import dataclass
from typing import Any, Literal, get_args
import httpx
@@ -36,6 +39,15 @@ from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
+
+@dataclass
+class _IntrospectionCacheEntry:
+ """Cached introspection result with expiration."""
+
+ result: AccessToken
+ expires_at: float
+
+
ClientAuthMethod = Literal["client_secret_basic", "client_secret_post"]
@@ -59,6 +71,10 @@ class IntrospectionTokenVerifier(TokenVerifier):
- Your tokens require real-time revocation checking
- Your authorization server supports RFC 7662 introspection
+ Caching is disabled by default to preserve real-time revocation semantics.
+ Set ``cache_ttl_seconds`` to enable caching and reduce load on the
+ introspection endpoint (e.g., ``cache_ttl_seconds=300`` for 5 minutes).
+
Example:
```python
verifier = IntrospectionTokenVerifier(
@@ -70,6 +86,9 @@ class IntrospectionTokenVerifier(TokenVerifier):
```
"""
+ # Default cache settings
+ DEFAULT_MAX_CACHE_SIZE = 10000
+
def __init__(
self,
*,
@@ -80,6 +99,9 @@ class IntrospectionTokenVerifier(TokenVerifier):
timeout_seconds: int = 10,
required_scopes: list[str] | None = None,
base_url: AnyHttpUrl | str | None = None,
+ cache_ttl_seconds: int | None = None,
+ max_cache_size: int | None = None,
+ http_client: httpx.AsyncClient | None = None,
):
"""
Initialize the introspection token verifier.
@@ -93,6 +115,15 @@ class IntrospectionTokenVerifier(TokenVerifier):
timeout_seconds: HTTP request timeout in seconds (default: 10)
required_scopes: Required scopes for all tokens (optional)
base_url: Base URL for TokenVerifier protocol
+ cache_ttl_seconds: How long to cache introspection results in seconds.
+ Caching is disabled by default (None) to preserve real-time
+ revocation semantics. Set to a positive integer to enable caching
+ (e.g., 300 for 5 minutes).
+ max_cache_size: Maximum number of tokens to cache when caching is
+ enabled. Default: 10000.
+ http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ the client is reused across calls and the caller is responsible for its
+ lifecycle. When None (default), a fresh client is created per call.
"""
# Parse scopes if provided as string
parsed_required_scopes = (
@@ -120,8 +151,101 @@ class IntrospectionTokenVerifier(TokenVerifier):
self.client_auth_method: ClientAuthMethod = client_auth_method
self.timeout_seconds = timeout_seconds
+ self._http_client = http_client
self.logger = get_logger(__name__)
+ # Cache configuration (None or 0 = disabled)
+ self._cache_ttl = cache_ttl_seconds or 0
+ self._max_cache_size = (
+ max_cache_size
+ if max_cache_size is not None
+ else self.DEFAULT_MAX_CACHE_SIZE
+ )
+ self._cache: dict[str, _IntrospectionCacheEntry] = {}
+ self._last_cleanup = time.monotonic()
+ self._cleanup_interval = 60 # Cleanup every 60 seconds
+
+ def _hash_token(self, token: str) -> str:
+ """Hash token for use as cache key.
+
+ Using SHA-256 for memory efficiency (fixed 64-char hex digest
+ regardless of token length).
+ """
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+ def _cleanup_expired_cache(self) -> None:
+ """Remove expired entries from cache."""
+ now = time.time()
+ expired = [key for key, entry in self._cache.items() if entry.expires_at < now]
+ for key in expired:
+ del self._cache[key]
+ if expired:
+ self.logger.debug("Cleaned up %d expired cache entries", len(expired))
+
+ def _maybe_cleanup(self) -> None:
+ """Periodically cleanup expired entries to prevent unbounded growth."""
+ now = time.monotonic()
+ if now - self._last_cleanup > self._cleanup_interval:
+ self._cleanup_expired_cache()
+ self._last_cleanup = now
+
+ def _get_cached(self, token: str) -> tuple[bool, AccessToken | None]:
+ """Get cached introspection result.
+
+ Returns:
+ Tuple of (is_cached, result):
+ - (True, AccessToken) if cached valid token
+ - (False, None) if not in cache or expired
+ """
+ if self._cache_ttl <= 0 or self._max_cache_size <= 0:
+ return (False, None) # Caching disabled
+
+ cache_key = self._hash_token(token)
+ entry = self._cache.get(cache_key)
+
+ if entry is None:
+ return (False, None) # Not in cache
+
+ if entry.expires_at < time.time():
+ del self._cache[cache_key]
+ return (False, None) # Expired
+
+ # Return a copy to prevent mutations from affecting cached value
+ return (True, entry.result.model_copy(deep=True))
+
+ def _set_cached(self, token: str, result: AccessToken) -> None:
+ """Cache a valid introspection result with TTL.
+
+ Only successful validations are cached. Failures (inactive, expired,
+ missing scopes, errors) are never cached to avoid sticky false negatives.
+ """
+ if self._cache_ttl <= 0 or self._max_cache_size <= 0:
+ return # Caching disabled
+
+ # Periodic cleanup
+ self._maybe_cleanup()
+
+ # Check cache size limit
+ if len(self._cache) >= self._max_cache_size:
+ self._cleanup_expired_cache()
+ # If still at limit after cleanup, evict oldest entry
+ if len(self._cache) >= self._max_cache_size:
+ oldest_key = next(iter(self._cache))
+ del self._cache[oldest_key]
+
+ cache_key = self._hash_token(token)
+
+ # Use token's expiration if available and sooner than TTL
+ expires_at = time.time() + self._cache_ttl
+ if result.expires_at:
+ expires_at = min(expires_at, float(result.expires_at))
+
+ # Store a deep copy to prevent mutations from affecting cached value
+ self._cache[cache_key] = _IntrospectionCacheEntry(
+ result=result.model_copy(deep=True),
+ expires_at=expires_at,
+ )
+
def _create_basic_auth_header(self) -> str:
"""Create HTTP Basic Auth header value from client credentials."""
credentials = f"{self.client_id}:{self.client_secret}"
@@ -159,14 +283,27 @@ class IntrospectionTokenVerifier(TokenVerifier):
authenticated using the configured client authentication method (client_secret_basic
or client_secret_post).
+ Results are cached in-memory to reduce load on the introspection endpoint.
+ Cache TTL and size are configurable via constructor parameters.
+
Args:
token: The opaque token string to validate
Returns:
AccessToken object if valid and active, None if invalid, inactive, or expired
"""
+ # Check cache first
+ is_cached, cached_result = self._get_cached(token)
+ if is_cached:
+ self.logger.debug("Token introspection cache hit")
+ return cached_result
+
try:
- async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
+ async with (
+ contextlib.nullcontext(self._http_client)
+ if self._http_client is not None
+ else httpx.AsyncClient(timeout=self.timeout_seconds)
+ ) as client:
# Prepare introspection request per RFC 7662
# Build request data with token and token_type_hint
data = {
@@ -193,7 +330,7 @@ class IntrospectionTokenVerifier(TokenVerifier):
headers=headers,
)
- # Check for HTTP errors
+ # Check for HTTP errors - don't cache HTTP errors (may be transient)
if response.status_code != 200:
self.logger.debug(
"Token introspection failed: HTTP %d - %s",
@@ -205,6 +342,8 @@ class IntrospectionTokenVerifier(TokenVerifier):
introspection_data = response.json()
# Check if token is active (required field per RFC 7662)
+ # Don't cache inactive tokens - they may become valid later
+ # (e.g., tokens with future nbf, or propagation delays)
if not introspection_data.get("active", False):
self.logger.debug("Token introspection returned active=false")
return None
@@ -229,6 +368,7 @@ class IntrospectionTokenVerifier(TokenVerifier):
scopes = self._extract_scopes(introspection_data)
# Check required scopes
+ # Don't cache scope failures - permissions may be updated dynamically
if self.required_scopes:
token_scopes = set(scopes)
required_scopes = set(self.required_scopes)
@@ -241,13 +381,15 @@ class IntrospectionTokenVerifier(TokenVerifier):
return None
# Create AccessToken with introspection response data
- return AccessToken(
+ result = AccessToken(
token=token,
client_id=str(client_id),
scopes=scopes,
expires_at=int(exp) if exp else None,
claims=introspection_data, # Store full response for extensibility
)
+ self._set_cached(token, result)
+ return result
except httpx.TimeoutException:
self.logger.debug(
diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py
index 828b9238f..5640fa390 100644
--- a/src/fastmcp/server/auth/providers/jwt.py
+++ b/src/fastmcp/server/auth/providers/jwt.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import contextlib
import json
import time
from dataclasses import dataclass
@@ -168,6 +169,7 @@ class JWTVerifier(TokenVerifier):
required_scopes: list[str] | None = None,
base_url: AnyHttpUrl | str | None = None,
ssrf_safe: bool = False,
+ http_client: httpx.AsyncClient | None = None,
):
"""
Initialize a JWTVerifier configured to validate JWTs using either a static key or a JWKS endpoint.
@@ -184,9 +186,14 @@ class JWTVerifier(TokenVerifier):
public IPs, DNS pinning). Enable when the JWKS URI comes from
untrusted input (e.g. CIMD documents). Defaults to False so
operator-configured JWKS URIs (including localhost) work normally.
+ http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ the client is reused for JWKS fetches and the caller is responsible for
+ its lifecycle. When None (default), a fresh client is created per fetch.
+ Cannot be used with ssrf_safe=True.
Raises:
- ValueError: If neither or both of `public_key` and `jwks_uri` are provided, or if `algorithm` is unsupported.
+ ValueError: If neither or both of `public_key` and `jwks_uri` are provided,
+ if `algorithm` is unsupported, or if `http_client` is provided with `ssrf_safe=True`.
"""
if not public_key and not jwks_uri:
raise ValueError("Either public_key or jwks_uri must be provided")
@@ -194,6 +201,13 @@ class JWTVerifier(TokenVerifier):
if public_key and jwks_uri:
raise ValueError("Provide either public_key or jwks_uri, not both")
+ # Only enforce ssrf_safe/http_client exclusivity when JWKS fetching is used
+ if jwks_uri and ssrf_safe and http_client is not None:
+ raise ValueError(
+ "http_client cannot be used with ssrf_safe=True; "
+ "SSRF-safe mode requires its own hardened transport"
+ )
+
algorithm = algorithm or "RS256"
if algorithm not in {
"HS256",
@@ -228,6 +242,7 @@ class JWTVerifier(TokenVerifier):
self.public_key = public_key
self.jwks_uri = jwks_uri
self.ssrf_safe = ssrf_safe
+ self._http_client = http_client
self.jwt = JsonWebToken([self.algorithm])
self.logger = get_logger(__name__)
@@ -328,7 +343,11 @@ class JWTVerifier(TokenVerifier):
)
return json.loads(content)
else:
- async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
+ async with (
+ contextlib.nullcontext(self._http_client)
+ if self._http_client is not None
+ else httpx.AsyncClient(timeout=httpx.Timeout(10.0))
+ ) as client:
response = await client.get(self.jwks_uri)
response.raise_for_status()
return response.json()
diff --git a/src/fastmcp/server/auth/providers/propelauth.py b/src/fastmcp/server/auth/providers/propelauth.py
new file mode 100644
index 000000000..0183a4c31
--- /dev/null
+++ b/src/fastmcp/server/auth/providers/propelauth.py
@@ -0,0 +1,223 @@
+"""PropelAuth authentication provider for FastMCP.
+
+Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
+
+ auth = PropelAuthProvider(
+ auth_url="https://auth.yourdomain.com",
+ introspection_client_id="your-client-id",
+ introspection_client_secret="your-client-secret",
+ base_url="https://your-fastmcp-server.com",
+ required_scopes=["read:user_data"],
+ )
+
+ mcp = FastMCP("My App", auth=auth)
+ ```
+"""
+
+from __future__ import annotations
+
+from typing import TypedDict
+
+import httpx
+from pydantic import AnyHttpUrl, SecretStr
+from starlette.responses import JSONResponse
+from starlette.routing import Route
+
+from fastmcp.server.auth import AccessToken, RemoteAuthProvider
+from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
+from fastmcp.utilities.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class PropelAuthTokenIntrospectionOverrides(TypedDict, total=False):
+ timeout_seconds: int
+ cache_ttl_seconds: int | None
+ max_cache_size: int | None
+ http_client: httpx.AsyncClient | None
+
+
+class PropelAuthProvider(RemoteAuthProvider):
+ """PropelAuth resource server provider using OAuth 2.1 token introspection.
+
+ This provider validates access tokens via PropelAuth's introspection endpoint
+ and forwards authorization server metadata for OAuth discovery.
+
+ Setup:
+ 1. Enable MCP authentication in the PropelAuth Dashboard
+ 2. Configure scopes on the MCP page
+ 3. Select which redirect URIs to enable by picking which clients you support
+ 4. Generate introspection credentials (Client ID + Client Secret)
+
+ For detailed setup instructions, see:
+ https://docs.propelauth.com/mcp-authentication/overview
+
+ Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
+
+ auth = PropelAuthProvider(
+ auth_url="https://auth.yourdomain.com",
+ introspection_client_id="your-client-id",
+ introspection_client_secret="your-client-secret",
+ base_url="https://your-fastmcp-server.com",
+ required_scopes=["read:user_data"],
+ )
+
+ mcp = FastMCP("My App", auth=auth)
+ ```
+ """
+
+ def __init__(
+ self,
+ *,
+ auth_url: AnyHttpUrl | str,
+ introspection_client_id: str,
+ introspection_client_secret: str | SecretStr,
+ base_url: AnyHttpUrl | str,
+ required_scopes: list[str] | None = None,
+ resource: AnyHttpUrl | str | None = None,
+ token_introspection_overrides: (
+ PropelAuthTokenIntrospectionOverrides | None
+ ) = None,
+ ):
+ """Initialize PropelAuth provider.
+
+ Args:
+ auth_url: Your PropelAuth Auth URL (from the Backend Integration page)
+ introspection_client_id: Introspection Client ID from the PropelAuth Dashboard
+ introspection_client_secret: Introspection Client Secret from the PropelAuth Dashboard
+ base_url: Public URL of this FastMCP server
+ required_scopes: Optional list of scopes that must be present in tokens
+ resource: Optional resource URI (RFC 8707) identifying this MCP server.
+ Use this when multiple MCP servers share the same PropelAuth
+ authorization server (e.g. ``resource="https://api.example.com/mcp"``),
+ so only tokens intended for this MCP server are accepted.
+ token_introspection_overrides: Optional overrides for the underlying
+ IntrospectionTokenVerifier (timeout, caching, http_client)
+ """
+ normalized_auth_url = str(auth_url).rstrip("/")
+ introspection_url = f"{normalized_auth_url}/oauth/2.1/introspect"
+ authorization_server_url = AnyHttpUrl(f"{normalized_auth_url}/oauth/2.1")
+
+ if resource is None:
+ self._resource = None
+ logger.debug(
+ "PropelAuthProvider: no resource configured, audience checking disabled"
+ )
+ else:
+ self._resource = str(resource)
+
+ token_verifier = self._create_token_verifier(
+ introspection_url=introspection_url,
+ client_id=introspection_client_id,
+ client_secret=introspection_client_secret,
+ required_scopes=required_scopes,
+ introspection_overrides=token_introspection_overrides,
+ )
+
+ self._normalized_auth_url = normalized_auth_url
+ super().__init__(
+ token_verifier=token_verifier,
+ authorization_servers=[authorization_server_url],
+ base_url=base_url,
+ )
+
+ def get_routes(
+ self,
+ mcp_path: str | None = None,
+ ) -> list[Route]:
+ """Get routes for this provider.
+
+ Includes the standard routes from the RemoteAuthProvider (protected resource metadata routes (RFC 9728)),
+ and creates an authorization server metadata route that forwards to PropelAuth's route
+
+ Args:
+ mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
+ This is used to advertise the resource URL in metadata.
+ """
+ routes = super().get_routes(mcp_path)
+
+ async def oauth_authorization_server_metadata(request):
+ """Forward PropelAuth OAuth authorization server metadata"""
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{self._normalized_auth_url}/.well-known/oauth-authorization-server/oauth/2.1"
+ )
+ 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 PropelAuth metadata: {e}",
+ },
+ status_code=500,
+ )
+
+ routes.append(
+ Route(
+ "/.well-known/oauth-authorization-server",
+ endpoint=oauth_authorization_server_metadata,
+ methods=["GET"],
+ )
+ )
+
+ return routes
+
+ async def verify_token(self, token: str) -> AccessToken | None:
+ """Verify token and check the ``aud`` claim against the configured resource."""
+ result = await super().verify_token(token)
+ if result is None or self._resource is None:
+ return result
+
+ aud = result.claims.get("aud")
+ if aud != self._resource:
+ logger.debug(
+ "PropelAuthProvider: token audience %r does not match resource %s",
+ aud,
+ self._resource,
+ )
+ return None
+
+ return result
+
+ def _create_token_verifier(
+ self,
+ introspection_url: str,
+ client_id: str,
+ client_secret: str | SecretStr,
+ required_scopes: list[str] | None,
+ introspection_overrides: PropelAuthTokenIntrospectionOverrides | None,
+ ) -> IntrospectionTokenVerifier:
+ # Being defensive here, check for only the fields we are expecting
+ safe_overrides: PropelAuthTokenIntrospectionOverrides = {}
+ if introspection_overrides is not None:
+ if "timeout_seconds" in introspection_overrides:
+ safe_overrides["timeout_seconds"] = introspection_overrides[
+ "timeout_seconds"
+ ]
+ if "cache_ttl_seconds" in introspection_overrides:
+ safe_overrides["cache_ttl_seconds"] = introspection_overrides[
+ "cache_ttl_seconds"
+ ]
+ if "max_cache_size" in introspection_overrides:
+ safe_overrides["max_cache_size"] = introspection_overrides[
+ "max_cache_size"
+ ]
+ if "http_client" in introspection_overrides:
+ safe_overrides["http_client"] = introspection_overrides["http_client"]
+
+ return IntrospectionTokenVerifier(
+ introspection_url=introspection_url,
+ client_id=client_id,
+ client_secret=client_secret,
+ required_scopes=required_scopes,
+ **safe_overrides,
+ )
diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py
index 4354d405b..48ed825e1 100644
--- a/src/fastmcp/server/auth/providers/workos.py
+++ b/src/fastmcp/server/auth/providers/workos.py
@@ -10,6 +10,8 @@ Choose based on your WorkOS setup and authentication requirements.
from __future__ import annotations
+import contextlib
+
import httpx
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@@ -38,6 +40,7 @@ class WorkOSTokenVerifier(TokenVerifier):
authkit_domain: str,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
+ http_client: httpx.AsyncClient | None = None,
):
"""Initialize the WorkOS token verifier.
@@ -45,15 +48,23 @@ class WorkOSTokenVerifier(TokenVerifier):
authkit_domain: WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
required_scopes: Required OAuth scopes
timeout_seconds: HTTP request timeout
+ http_client: Optional httpx.AsyncClient for connection pooling. When provided,
+ the client is reused across calls and the caller is responsible for its
+ lifecycle. When None (default), a fresh client is created per call.
"""
super().__init__(required_scopes=required_scopes)
self.authkit_domain = authkit_domain.rstrip("/")
self.timeout_seconds = timeout_seconds
+ self._http_client = http_client
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify WorkOS OAuth token by calling userinfo endpoint."""
try:
- async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
+ async with (
+ contextlib.nullcontext(self._http_client)
+ if self._http_client is not None
+ else httpx.AsyncClient(timeout=self.timeout_seconds)
+ ) as client:
# Use WorkOS AuthKit userinfo endpoint to validate token
response = await client.get(
f"{self.authkit_domain}/oauth2/userinfo",
@@ -146,6 +157,7 @@ class WorkOSProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
+ http_client: httpx.AsyncClient | None = None,
):
"""Initialize WorkOS OAuth provider.
@@ -171,6 +183,9 @@ class WorkOSProvider(OAuthProxy):
When True, users see a consent screen before being redirected to WorkOS.
When False, authorization proceeds directly without user confirmation.
SECURITY WARNING: Only disable for local development or testing environments.
+ http_client: Optional httpx.AsyncClient for connection pooling in token verification.
+ When provided, the client is reused across verify_token calls and the caller
+ is responsible for its lifecycle. When None (default), a fresh client is created per call.
"""
# Apply defaults and ensure authkit_domain is a full URL
authkit_domain_str = authkit_domain
@@ -186,6 +201,7 @@ class WorkOSProvider(OAuthProxy):
authkit_domain=authkit_domain_final,
required_scopes=scopes_final,
timeout_seconds=timeout_seconds,
+ http_client=http_client,
)
# Initialize OAuth proxy with WorkOS AuthKit endpoints
diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py
index afe8966f5..d7b23a245 100644
--- a/src/fastmcp/server/context.py
+++ b/src/fastmcp/server/context.py
@@ -24,6 +24,7 @@ from mcp.types import Resource as SDKResource
from pydantic.networks import AnyUrl
from starlette.requests import Request
from typing_extensions import TypeVar
+from uncalled_for import SharedContext
from fastmcp.resources.resource import ResourceResult
from fastmcp.server.elicitation import (
@@ -266,6 +267,7 @@ class Context:
_current_docket,
_current_server,
_current_worker,
+ is_docket_available,
)
self._server_token = _current_server.set(weakref.ref(self.fastmcp))
@@ -274,32 +276,41 @@ class Context:
# This ensures ContextVars work even in ASGI environments (Lambda, FastAPI mount)
# where lifespan ContextVars don't propagate to request handlers.
server = self.fastmcp
- if server._docket is not None:
- self._docket_token = _current_docket.set(server._docket)
-
- if server._worker is not None:
- self._worker_token = _current_worker.set(server._worker)
+ if is_docket_available():
+ if server._docket is not None:
+ self._docket_token = _current_docket.set(server._docket)
+ if server._worker is not None:
+ self._worker_token = _current_worker.set(server._worker)
+ else:
+ # Without docket, the lifespan won't provide a SharedContext,
+ # so create one scoped to this Context for Shared() dependencies.
+ self._shared_context = SharedContext()
+ await self._shared_context.__aenter__()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
"""Exit the context manager and reset the most recent token."""
- # Reset server/docket/worker tokens
from fastmcp.server.dependencies import (
_current_docket,
_current_server,
_current_worker,
)
+ # Mirror __aenter__: clean up docket/worker tokens or SharedContext
if hasattr(self, "_worker_token"):
_current_worker.reset(self._worker_token)
- delattr(self, "_worker_token")
+ del self._worker_token
if hasattr(self, "_docket_token"):
_current_docket.reset(self._docket_token)
- delattr(self, "_docket_token")
+ del self._docket_token
+ if hasattr(self, "_shared_context"):
+ await self._shared_context.__aexit__(exc_type, exc_val, exc_tb)
+ del self._shared_context
+
if hasattr(self, "_server_token"):
_current_server.reset(self._server_token)
- delattr(self, "_server_token")
+ del self._server_token
# Reset context token
if self._tokens:
@@ -406,10 +417,9 @@ class Context:
return
try:
- from docket.dependencies import Dependency
+ from docket.dependencies import current_execution
- # Get current execution from worker context
- execution = Dependency.execution.get()
+ execution = current_execution.get()
# Update progress in Redis using Docket's progress API.
# Docket only exposes increment() (relative), so we compute
diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py
index ccee89adc..f44026280 100644
--- a/src/fastmcp/server/dependencies.py
+++ b/src/fastmcp/server/dependencies.py
@@ -1,7 +1,7 @@
"""Dependency injection for FastMCP.
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
-using a vendored DI engine. Only task-related dependencies (CurrentDocket,
+using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket,
CurrentWorker) and background task execution require fastmcp[tasks].
"""
@@ -28,6 +28,8 @@ from mcp.server.auth.provider import (
)
from mcp.server.lowlevel.server import request_ctx
from starlette.requests import Request
+from uncalled_for import Dependency, get_dependency_parameters
+from uncalled_for.resolution import _Depends
from fastmcp.exceptions import FastMCPError
from fastmcp.server.auth import AccessToken
@@ -104,10 +106,10 @@ def get_task_context() -> TaskContextInfo | None:
if not is_docket_available():
return None
- from docket.dependencies import Dependency as DocketDependency
+ from docket.dependencies import current_execution
try:
- execution = DocketDependency.execution.get()
+ execution = current_execution.get()
# Parse the task key: {session_id}:{task_id}:{task_type}:{component}
from fastmcp.server.tasks.keys import parse_task_key
@@ -208,24 +210,7 @@ def require_docket(feature: str) -> None:
)
-# --- Dependency injection imports ---
-# Try docket first for isinstance compatibility in worker context,
-# fall back to vendored DI engine when docket is not installed.
-
-try:
- from docket.dependencies import (
- Dependency,
- _Depends,
- get_dependency_parameters,
- )
-except ImportError:
- from fastmcp._vendor.docket_di import (
- Dependency,
- _Depends,
- get_dependency_parameters,
- )
-
-# Import Progress separately to avoid breaking DI fallback if Progress is missing
+# Import Progress separately β it's docket-specific, not part of uncalled-for
try:
from docket.dependencies import Progress as DocketProgress
except ImportError:
@@ -366,31 +351,15 @@ def _clear_signature_caches(fn: Callable[..., Any]) -> None:
Called after modifying a function's signature to ensure downstream
code sees the updated signature.
"""
- # Clear vendored DI caches
- from fastmcp._vendor.docket_di import _parameter_cache, _signature_cache
+ from uncalled_for.introspection import _parameter_cache, _signature_cache
_signature_cache.pop(fn, None)
_parameter_cache.pop(fn, None)
- # Also clear for __func__ if it's a method
if inspect.ismethod(fn):
_signature_cache.pop(fn.__func__, None)
_parameter_cache.pop(fn.__func__, None)
- # Try to clear docket caches if docket is installed
- if is_docket_available():
- try:
- from docket.dependencies import _parameter_cache as docket_param_cache
- from docket.execution import _signature_cache as docket_sig_cache
-
- docket_sig_cache.pop(fn, None)
- docket_param_cache.pop(fn, None)
- if inspect.ismethod(fn):
- docket_sig_cache.pop(fn.__func__, None)
- docket_param_cache.pop(fn.__func__, None)
- except (ImportError, AttributeError):
- pass # Cache access not available in this docket version
-
def get_context() -> Context:
"""Get the current FastMCP Context instance directly."""
@@ -828,7 +797,7 @@ async def _restore_task_origin_request_id(session_id: str, task_id: str) -> str
return None
-class _CurrentContext(Dependency): # type: ignore[misc]
+class _CurrentContext(Dependency["Context"]):
"""Async context manager for Context dependency.
In foreground (request) mode: returns the active context from _current_context.
@@ -893,7 +862,7 @@ class _CurrentContext(Dependency): # type: ignore[misc]
self._context = None
-class _OptionalCurrentContext(Dependency): # type: ignore[misc]
+class _OptionalCurrentContext(Dependency["Context | None"]):
"""Context dependency that degrades to None when no context is active.
This is implemented as a wrapper (composition), not a subclass of
@@ -951,7 +920,7 @@ def OptionalCurrentContext() -> Context | None:
return cast("Context | None", _OptionalCurrentContext())
-class _CurrentDocket(Dependency): # type: ignore[misc]
+class _CurrentDocket(Dependency["Docket"]):
"""Async context manager for Docket dependency."""
async def __aenter__(self) -> Docket:
@@ -996,7 +965,7 @@ def CurrentDocket() -> Docket:
return cast("Docket", _CurrentDocket())
-class _CurrentWorker(Dependency): # type: ignore[misc]
+class _CurrentWorker(Dependency["Worker"]):
"""Async context manager for Worker dependency."""
async def __aenter__(self) -> Worker:
@@ -1040,7 +1009,7 @@ def CurrentWorker() -> Worker:
return cast("Worker", _CurrentWorker())
-class _CurrentFastMCP(Dependency): # type: ignore[misc]
+class _CurrentFastMCP(Dependency["FastMCP"]):
"""Async context manager for FastMCP server dependency."""
async def __aenter__(self) -> FastMCP:
@@ -1081,7 +1050,7 @@ def CurrentFastMCP() -> FastMCP:
return cast(FastMCP, _CurrentFastMCP())
-class _CurrentRequest(Dependency): # type: ignore[misc]
+class _CurrentRequest(Dependency[Request]):
"""Async context manager for HTTP Request dependency."""
async def __aenter__(self) -> Request:
@@ -1117,7 +1086,7 @@ def CurrentRequest() -> Request:
return cast(Request, _CurrentRequest())
-class _CurrentHeaders(Dependency): # type: ignore[misc]
+class _CurrentHeaders(Dependency[dict[str, str]]):
"""Async context manager for HTTP Headers dependency."""
async def __aenter__(self) -> dict[str, str]:
@@ -1241,7 +1210,7 @@ class InMemoryProgress:
self._message = message
-class Progress(Dependency): # type: ignore[misc]
+class Progress(Dependency["Progress"]):
"""FastMCP Progress dependency that works in both server and worker contexts.
Handles three execution modes:
@@ -1254,39 +1223,67 @@ class Progress(Dependency): # type: ignore[misc]
is installed.
"""
- async def __aenter__(self) -> ProgressLike:
- # Check if we're in a FastMCP server context
+ _impl: ProgressLike | None = None
+
+ async def __aenter__(self) -> Progress:
server_ref = _current_server.get()
if server_ref is None or server_ref() is None:
raise RuntimeError("Progress dependency requires a FastMCP server context.")
- # If pydocket is installed, try to use Docket's progress
if is_docket_available():
from docket.dependencies import Progress as DocketProgress
- # Try to get execution from Docket worker context
try:
docket_progress = DocketProgress()
- return await docket_progress.__aenter__()
+ self._impl = await docket_progress.__aenter__()
+ return self
except LookupError:
- # Not in worker context - fall through to in-memory progress
pass
- # Return in-memory progress for immediate execution
- # This is used when:
- # 1. pydocket is not installed
- # 2. Docket is not running (no task-enabled components)
- # 3. In server context (not worker context)
- return InMemoryProgress()
+ self._impl = InMemoryProgress()
+ return self
async def __aexit__(self, *args: object) -> None:
- pass
+ self._impl = None
+
+ @property
+ def current(self) -> int | None:
+ """Current progress value."""
+ assert self._impl is not None, "Progress must be used as a dependency"
+ return self._impl.current
+
+ @property
+ def total(self) -> int:
+ """Total/target progress value."""
+ assert self._impl is not None, "Progress must be used as a dependency"
+ return self._impl.total
+
+ @property
+ def message(self) -> str | None:
+ """Current progress message."""
+ assert self._impl is not None, "Progress must be used as a dependency"
+ return self._impl.message
+
+ async def set_total(self, total: int) -> None:
+ """Set the total/target value for progress tracking."""
+ assert self._impl is not None, "Progress must be used as a dependency"
+ await self._impl.set_total(total)
+
+ async def increment(self, amount: int = 1) -> None:
+ """Atomically increment the current progress value."""
+ assert self._impl is not None, "Progress must be used as a dependency"
+ await self._impl.increment(amount)
+
+ async def set_message(self, message: str | None) -> None:
+ """Update the progress status message."""
+ assert self._impl is not None, "Progress must be used as a dependency"
+ await self._impl.set_message(message)
# --- Access Token dependency ---
-class _CurrentAccessToken(Dependency): # type: ignore[misc]
+class _CurrentAccessToken(Dependency[AccessToken]):
"""Async context manager for AccessToken dependency."""
_access_token_cv_token: Token[AccessToken | None] | None = None
@@ -1346,7 +1343,7 @@ def CurrentAccessToken() -> AccessToken:
# --- Token Claim dependency ---
-class _TokenClaim(Dependency): # type: ignore[misc]
+class _TokenClaim(Dependency[str]):
"""Dependency that extracts a specific claim from the access token."""
def __init__(self, claim_name: str):
diff --git a/src/fastmcp/server/middleware/authorization.py b/src/fastmcp/server/middleware/authorization.py
index abe33447b..4d3914a18 100644
--- a/src/fastmcp/server/middleware/authorization.py
+++ b/src/fastmcp/server/middleware/authorization.py
@@ -102,8 +102,11 @@ class AuthMiddleware(Middleware):
authorized_tools: list[Tool] = []
for tool in tools:
ctx = AuthContext(token=token, component=tool)
- if await run_auth_checks(self.auth, ctx):
- authorized_tools.append(tool)
+ try:
+ if await run_auth_checks(self.auth, ctx):
+ authorized_tools.append(tool)
+ except AuthorizationError:
+ continue
return authorized_tools
@@ -169,8 +172,11 @@ class AuthMiddleware(Middleware):
authorized_resources: list[Resource] = []
for resource in resources:
ctx = AuthContext(token=token, component=resource)
- if await run_auth_checks(self.auth, ctx):
- authorized_resources.append(resource)
+ try:
+ if await run_auth_checks(self.auth, ctx):
+ authorized_resources.append(resource)
+ except AuthorizationError:
+ continue
return authorized_resources
@@ -238,8 +244,11 @@ class AuthMiddleware(Middleware):
authorized_templates: list[ResourceTemplate] = []
for template in templates:
ctx = AuthContext(token=token, component=template)
- if await run_auth_checks(self.auth, ctx):
- authorized_templates.append(template)
+ try:
+ if await run_auth_checks(self.auth, ctx):
+ authorized_templates.append(template)
+ except AuthorizationError:
+ continue
return authorized_templates
@@ -262,8 +271,11 @@ class AuthMiddleware(Middleware):
authorized_prompts: list[Prompt] = []
for prompt in prompts:
ctx = AuthContext(token=token, component=prompt)
- if await run_auth_checks(self.auth, ctx):
- authorized_prompts.append(prompt)
+ try:
+ if await run_auth_checks(self.auth, ctx):
+ authorized_prompts.append(prompt)
+ except AuthorizationError:
+ continue
return authorized_prompts
diff --git a/src/fastmcp/server/mixins/lifespan.py b/src/fastmcp/server/mixins/lifespan.py
index b2778775d..267736a83 100644
--- a/src/fastmcp/server/mixins/lifespan.py
+++ b/src/fastmcp/server/mixins/lifespan.py
@@ -8,6 +8,8 @@ from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager, suppress
from typing import TYPE_CHECKING, Any
+from uncalled_for import SharedContext
+
import fastmcp
from fastmcp.utilities.logging import get_logger
@@ -48,9 +50,11 @@ class LifespanMixin:
server_token = _current_server.set(weakref.ref(self))
try:
- # If docket is not available, skip task infrastructure
+ # If docket is not available, skip task infrastructure but still
+ # set up SharedContext so Shared() dependencies work.
if not is_docket_available():
- yield
+ async with SharedContext():
+ yield
return
# Collect task-enabled components at startup with all transforms applied.
@@ -64,9 +68,11 @@ class LifespanMixin:
raise
task_components = []
- # If no task-enabled components, skip Docket infrastructure entirely
+ # If no task-enabled components, skip Docket infrastructure but still
+ # set up SharedContext so Shared() dependencies work.
if not task_components:
- yield
+ async with SharedContext():
+ yield
return
# Docket is available AND there are task-enabled components
diff --git a/src/fastmcp/server/mixins/transport.py b/src/fastmcp/server/mixins/transport.py
index 1f069a02d..833b5e385 100644
--- a/src/fastmcp/server/mixins/transport.py
+++ b/src/fastmcp/server/mixins/transport.py
@@ -53,7 +53,7 @@ class TransportMixin:
if show_banner is None:
show_banner = fastmcp.settings.show_server_banner
if transport is None:
- transport = "stdio"
+ transport = fastmcp.settings.transport
if transport not in {"stdio", "http", "sse", "streamable-http"}:
raise ValueError(f"Unknown transport: {transport}")
diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py
index 93209eb21..796be4224 100644
--- a/src/fastmcp/server/providers/local_provider/decorators/tools.py
+++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py
@@ -7,10 +7,21 @@ registration functionality to LocalProvider.
from __future__ import annotations
import inspect
+import types
import warnings
from collections.abc import Callable
from functools import partial
-from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
+from typing import (
+ TYPE_CHECKING,
+ Annotated,
+ Any,
+ Literal,
+ TypeVar,
+ Union,
+ get_args,
+ get_origin,
+ overload,
+)
import mcp.types
from mcp.types import AnyFunction, ToolAnnotations
@@ -22,6 +33,14 @@ from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool import Tool
from fastmcp.utilities.types import NotSet, NotSetT
+try:
+ from prefab_ui.app import PrefabApp as _PrefabApp
+ from prefab_ui.components.base import Component as _PrefabComponent
+
+ _HAS_PREFAB = True
+except ImportError:
+ _HAS_PREFAB = False
+
if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.tools.tool import ToolResultSerializerType
@@ -30,6 +49,99 @@ F = TypeVar("F", bound=Callable[..., Any])
DuplicateBehavior = Literal["error", "warn", "replace", "ignore"]
+PREFAB_RENDERER_URI = "ui://prefab/renderer.html"
+
+
+def _is_prefab_type(tp: Any) -> bool:
+ """Check if *tp* is or contains a prefab type, recursing through unions and Annotated."""
+ if isinstance(tp, type) and issubclass(tp, (_PrefabApp, _PrefabComponent)):
+ return True
+ origin = get_origin(tp)
+ if origin is Union or origin is types.UnionType or origin is Annotated:
+ return any(_is_prefab_type(a) for a in get_args(tp))
+ return False
+
+
+def _has_prefab_return_type(tool: Tool) -> bool:
+ """Check if a FunctionTool's return type annotation is a prefab type."""
+ if not _HAS_PREFAB or not isinstance(tool, FunctionTool):
+ return False
+ rt = tool.return_type
+ if rt is None or rt is inspect.Parameter.empty:
+ return False
+ return _is_prefab_type(rt)
+
+
+def _ensure_prefab_renderer(provider: LocalProvider) -> None:
+ """Lazily register the shared prefab renderer as a ui:// resource."""
+ from prefab_ui.renderer import get_renderer_csp, get_renderer_html
+
+ from fastmcp.resources.types import TextResource
+ from fastmcp.server.apps import (
+ UI_MIME_TYPE,
+ AppConfig,
+ ResourceCSP,
+ app_config_to_meta_dict,
+ )
+
+ renderer_key = f"resource:{PREFAB_RENDERER_URI}@"
+ if renderer_key in provider._components:
+ return
+
+ csp = get_renderer_csp()
+ resource_app = AppConfig(
+ csp=ResourceCSP(
+ resource_domains=csp.get("resource_domains"),
+ connect_domains=csp.get("connect_domains"),
+ )
+ )
+ resource = TextResource(
+ uri=PREFAB_RENDERER_URI, # type: ignore[arg-type] # AnyUrl accepts ui:// scheme at runtime
+ name="Prefab Renderer",
+ text=get_renderer_html(),
+ mime_type=UI_MIME_TYPE,
+ meta={"ui": app_config_to_meta_dict(resource_app)},
+ )
+ provider._add_component(resource)
+
+
+def _expand_prefab_ui_meta(tool: Tool) -> None:
+ """Expand meta["ui"] = True into the full AppConfig dict for a prefab tool."""
+ from prefab_ui.renderer import get_renderer_csp
+
+ from fastmcp.server.apps import AppConfig, ResourceCSP, app_config_to_meta_dict
+
+ csp = get_renderer_csp()
+ app_config = AppConfig(
+ resource_uri=PREFAB_RENDERER_URI,
+ csp=ResourceCSP(
+ resource_domains=csp.get("resource_domains"),
+ connect_domains=csp.get("connect_domains"),
+ ),
+ )
+ meta = dict(tool.meta) if tool.meta else {}
+ meta["ui"] = app_config_to_meta_dict(app_config)
+ tool.meta = meta
+
+
+def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None:
+ """Auto-wire prefab UI metadata and renderer resource if needed."""
+ if not _HAS_PREFAB:
+ return
+
+ meta = tool.meta or {}
+ ui = meta.get("ui")
+
+ if ui is True:
+ # Explicit app=True: expand to full AppConfig and register renderer
+ _ensure_prefab_renderer(provider)
+ _expand_prefab_ui_meta(tool)
+ elif ui is None and _has_prefab_return_type(tool):
+ # Inference: return type is a prefab type, auto-wire
+ _ensure_prefab_renderer(provider)
+ _expand_prefab_ui_meta(tool)
+ # If ui is a dict, it's already manually configured β leave it alone
+
class ToolDecoratorMixin:
"""Mixin class providing tool decorator functionality for LocalProvider.
@@ -87,6 +199,7 @@ class ToolDecoratorMixin:
self._add_component(tool)
if not enabled:
self.disable(keys={tool.key})
+ _maybe_apply_prefab_ui(self, tool)
return tool
@overload
@@ -264,6 +377,7 @@ class ToolDecoratorMixin:
self._add_component(tool_obj)
if not enabled:
self.disable(keys={tool_obj.key})
+ _maybe_apply_prefab_ui(self, tool_obj)
return tool_obj
else:
from fastmcp.tools.function_tool import ToolMeta
diff --git a/src/fastmcp/server/providers/proxy.py b/src/fastmcp/server/providers/proxy.py
index 0b7ce0096..5d2ea562f 100644
--- a/src/fastmcp/server/providers/proxy.py
+++ b/src/fastmcp/server/providers/proxy.py
@@ -78,7 +78,7 @@ class ProxyTool(Tool):
"""Gets a client instance by calling the sync or async factory."""
client = self._client_factory()
if inspect.isawaitable(client):
- client = await client
+ client = cast(Client, await client)
return client
def model_copy(self, **kwargs: Any) -> ProxyTool:
@@ -127,7 +127,10 @@ class ProxyTool(Tool):
# request. Stash the current RequestContext in the shared
# ref so handlers can restore it before forwarding.
if isinstance(client, StatefulProxyClient):
- cast(list[Any], client._proxy_rc_ref)[0] = ctx.request_context
+ cast(list[Any], client._proxy_rc_ref)[0] = (
+ ctx.request_context,
+ ctx._fastmcp, # weakref to FastMCP, not the Context
+ )
# Build meta dict from request context
meta: dict[str, Any] | None = None
if hasattr(ctx, "request_context"):
@@ -189,7 +192,7 @@ class ProxyResource(Resource):
"""Gets a client instance by calling the sync or async factory."""
client = self._client_factory()
if inspect.isawaitable(client):
- client = await client
+ client = cast(Client, await client)
return client
def model_copy(self, **kwargs: Any) -> ProxyResource:
@@ -288,7 +291,7 @@ class ProxyTemplate(ResourceTemplate):
"""Gets a client instance by calling the sync or async factory."""
client = self._client_factory()
if inspect.isawaitable(client):
- client = await client
+ client = cast(Client, await client)
return client
def model_copy(self, **kwargs: Any) -> ProxyTemplate:
@@ -403,7 +406,7 @@ class ProxyPrompt(Prompt):
"""Gets a client instance by calling the sync or async factory."""
client = self._client_factory()
if inspect.isawaitable(client):
- client = await client
+ client = cast(Client, await client)
return client
def model_copy(self, **kwargs: Any) -> ProxyPrompt:
@@ -517,7 +520,7 @@ class ProxyProvider(Provider):
"""Gets a client instance by calling the sync or async factory."""
client = self.client_factory()
if inspect.isawaitable(client):
- client = await client
+ client = cast(Client, await client)
return client
# -------------------------------------------------------------------------
@@ -791,24 +794,40 @@ async def default_proxy_progress_handler(
def _restore_request_context(
rc_ref: list[Any],
) -> None:
- """Set the ``request_ctx`` ContextVar from a stashed RequestContext.
+ """Set the ``request_ctx`` and ``_current_context`` ContextVars from stashed values.
Called at the start of proxy handler invocations in
``StatefulProxyClient`` to fix stale ContextVars in the receive-loop
task. Only overrides when the ContextVar is genuinely stale (same
session, different request_id) to avoid corrupting the concurrent
case where multiple sessions share the same ref via ``copy.copy``.
+
+ We stash a ``(RequestContext, weakref[FastMCP])`` tuple β never a
+ ``Context`` instance β because ``Context`` properties are themselves
+ ContextVar-dependent and would resolve stale values in the receive
+ loop. Instead we construct a fresh ``Context`` here after restoring
+ ``request_ctx``, so its property accesses read the correct values.
"""
- rc = rc_ref[0]
- if rc is None:
+ from fastmcp.server.context import Context, _current_context
+
+ stashed = rc_ref[0]
+ if stashed is None:
return
+
+ rc, fastmcp_ref = stashed
try:
current_rc = request_ctx.get()
except LookupError:
request_ctx.set(rc)
+ fastmcp = fastmcp_ref()
+ if fastmcp is not None:
+ _current_context.set(Context(fastmcp))
return
if current_rc.session is rc.session and current_rc.request_id != rc.request_id:
request_ctx.set(rc)
+ fastmcp = fastmcp_ref()
+ if fastmcp is not None:
+ _current_context.set(Context(fastmcp))
def _make_restoring_handler(handler: Callable, rc_ref: list[Any]) -> Callable:
@@ -881,9 +900,10 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]):
# writes [0] before each backend call; handlers read it to detect
# stale ContextVars and restore the correct request_ctx.
#
- # We store the concrete RequestContext (not fastmcp's Context) because
- # Context properties are themselves ContextVar-dependent and resolve
- # in the caller's async context β which is stale in the receive loop.
+ # Stores a (RequestContext, weakref[FastMCP]) tuple β never a Context
+ # instance β because Context properties are ContextVar-dependent and
+ # would resolve stale values in the receive loop. The restore helper
+ # constructs a fresh Context from the weakref after setting request_ctx.
_proxy_rc_ref: list[Any]
def __init__(self, *args: Any, **kwargs: Any):
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index db558dca3..0dc47f143 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -62,7 +62,6 @@ from fastmcp.server.apps import (
resolve_ui_mime_type,
)
from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks
-from fastmcp.server.dependencies import get_access_token
from fastmcp.server.lifespan import Lifespan
from fastmcp.server.low_level import LowLevelServer
from fastmcp.server.middleware import Middleware, MiddlewareContext
@@ -162,6 +161,8 @@ def _get_auth_context() -> tuple[bool, Any]:
is_stdio = _current_transport.get() == "stdio"
if is_stdio:
return (True, None)
+ from fastmcp.server.dependencies import get_access_token
+
return (False, get_access_token())
@@ -226,6 +227,7 @@ class FastMCP(
auth: AuthProvider | None = None,
middleware: Sequence[Middleware] | None = None,
providers: Sequence[Provider] | None = None,
+ transforms: Sequence[Transform] | None = None,
lifespan: LifespanCallable | Lifespan | None = None,
tools: Sequence[Tool | Callable[..., Any]] | None = None,
on_duplicate: DuplicateBehavior | None = None,
@@ -274,6 +276,9 @@ class FastMCP(
for p in providers or []:
self.add_provider(p)
+ for t in transforms or []:
+ self.add_transform(t)
+
# Store mask_error_details for execution error handling
self._mask_error_details: bool = (
mask_error_details
diff --git a/src/fastmcp/server/tasks/subscriptions.py b/src/fastmcp/server/tasks/subscriptions.py
index 5852c2fb8..9c6c8d59e 100644
--- a/src/fastmcp/server/tasks/subscriptions.py
+++ b/src/fastmcp/server/tasks/subscriptions.py
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING
from docket.execution import ExecutionState
from mcp.types import TaskStatusNotification, TaskStatusNotificationParams
+from fastmcp.server.tasks.config import DEFAULT_TTL_MS
from fastmcp.server.tasks.keys import parse_task_key
from fastmcp.server.tasks.requests import DOCKET_TO_MCP_STATE
from fastmcp.utilities.logging import get_logger
@@ -133,7 +134,7 @@ async def _send_status_notification(
"status": mcp_status,
"createdAt": created_at,
"lastUpdatedAt": datetime.now(timezone.utc).isoformat(),
- "ttl": 60000,
+ "ttl": DEFAULT_TTL_MS,
"pollInterval": poll_interval_ms,
}
@@ -198,7 +199,7 @@ async def _send_progress_notification(
"status": mcp_status,
"createdAt": created_at,
"lastUpdatedAt": datetime.now(timezone.utc).isoformat(),
- "ttl": 60000,
+ "ttl": DEFAULT_TTL_MS,
"pollInterval": poll_interval_ms,
"statusMessage": execution.progress.message,
}
diff --git a/src/fastmcp/server/transforms/__init__.py b/src/fastmcp/server/transforms/__init__.py
index 9e55cc903..cdda16d82 100644
--- a/src/fastmcp/server/transforms/__init__.py
+++ b/src/fastmcp/server/transforms/__init__.py
@@ -228,10 +228,6 @@ from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402
from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402
__all__ = [
- "GetPromptNext",
- "GetResourceNext",
- "GetResourceTemplateNext",
- "GetToolNext",
"Namespace",
"PromptsAsTools",
"ResourcesAsTools",
diff --git a/src/fastmcp/server/transforms/catalog.py b/src/fastmcp/server/transforms/catalog.py
new file mode 100644
index 000000000..fd204fa03
--- /dev/null
+++ b/src/fastmcp/server/transforms/catalog.py
@@ -0,0 +1,239 @@
+"""Base class for transforms that need to read the real component catalog.
+
+Some transforms replace ``list_tools()`` output with synthetic components
+(e.g. a search interface) while still needing access to the *real*
+(auth-filtered) catalog at call time. ``CatalogTransform`` provides the
+bypass machinery so subclasses can call ``get_tool_catalog()`` without
+triggering their own replacement logic.
+
+Re-entrancy problem
+-------------------
+
+When a synthetic tool handler calls ``get_tool_catalog()``, that calls
+``ctx.fastmcp.list_tools()`` which re-enters the transform pipeline β
+including *this* transform's ``list_tools()``. If the subclass overrides
+``list_tools()`` directly, the re-entrant call would hit the subclass's
+replacement logic again (returning synthetic tools instead of the real
+catalog). A ``super()`` call can't prevent this because Python can't
+short-circuit a method after ``super()`` returns.
+
+Solution: ``CatalogTransform`` owns ``list_tools()`` and uses a
+per-instance ``ContextVar`` to detect re-entrant calls. During bypass,
+it passes through to the base ``Transform.list_tools()`` (a no-op).
+Otherwise, it delegates to ``transform_tools()`` β the subclass hook
+where replacement logic lives. Same pattern for resources, prompts,
+and resource templates.
+
+This is *not* the same as the ``Provider._list_tools()`` convention
+(which produces raw components with no arguments). ``transform_tools()``
+receives the current catalog and returns a transformed version. The
+distinct name avoids confusion between the two patterns.
+
+Usage::
+
+ class MyTransform(CatalogTransform):
+ async def transform_tools(self, tools):
+ return [self._make_search_tool()]
+
+ def _make_search_tool(self):
+ async def search(ctx: Context = None):
+ real_tools = await self.get_tool_catalog(ctx)
+ ...
+ return Tool.from_function(fn=search, name="search")
+"""
+
+from __future__ import annotations
+
+import itertools
+from collections.abc import Sequence
+from contextvars import ContextVar
+from typing import TYPE_CHECKING
+
+from fastmcp.server.transforms import Transform
+
+if TYPE_CHECKING:
+ from fastmcp.prompts.prompt import Prompt
+ from fastmcp.resources.resource import Resource
+ from fastmcp.resources.template import ResourceTemplate
+ from fastmcp.server.context import Context
+ from fastmcp.tools.tool import Tool
+
+_instance_counter = itertools.count()
+
+
+class CatalogTransform(Transform):
+ """Transform that needs access to the real component catalog.
+
+ Subclasses override ``transform_tools()`` / ``transform_resources()``
+ / ``transform_prompts()`` / ``transform_resource_templates()``
+ instead of the ``list_*()`` methods. The base class owns
+ ``list_*()`` and handles re-entrant bypass automatically β subclasses
+ never see re-entrant calls from ``get_*_catalog()``.
+
+ The ``get_*_catalog()`` methods fetch the real (auth-filtered) catalog
+ by temporarily setting a bypass flag so that this transform's
+ ``list_*()`` passes through without calling the subclass hook.
+ """
+
+ def __init__(self) -> None:
+ self._instance_id: int = next(_instance_counter)
+ self._bypass: ContextVar[bool] = ContextVar(
+ f"_catalog_bypass_{self._instance_id}", default=False
+ )
+
+ # ------------------------------------------------------------------
+ # list_* (bypass-aware β subclasses override transform_* instead)
+ # ------------------------------------------------------------------
+
+ async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
+ if self._bypass.get():
+ return await super().list_tools(tools)
+ return await self.transform_tools(tools)
+
+ async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]:
+ if self._bypass.get():
+ return await super().list_resources(resources)
+ return await self.transform_resources(resources)
+
+ async def list_resource_templates(
+ self, templates: Sequence[ResourceTemplate]
+ ) -> Sequence[ResourceTemplate]:
+ if self._bypass.get():
+ return await super().list_resource_templates(templates)
+ return await self.transform_resource_templates(templates)
+
+ async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]:
+ if self._bypass.get():
+ return await super().list_prompts(prompts)
+ return await self.transform_prompts(prompts)
+
+ # ------------------------------------------------------------------
+ # Subclass hooks (override these, not list_*)
+ # ------------------------------------------------------------------
+
+ async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
+ """Transform the tool catalog.
+
+ Override this method to replace, filter, or augment the tool listing.
+ The default implementation passes through unchanged.
+
+ Do NOT override ``list_tools()`` directly β the base class uses it
+ to handle re-entrant bypass when ``get_tool_catalog()`` reads the
+ real catalog.
+ """
+ return tools
+
+ async def transform_resources(
+ self, resources: Sequence[Resource]
+ ) -> Sequence[Resource]:
+ """Transform the resource catalog.
+
+ Override this method to replace, filter, or augment the resource listing.
+ The default implementation passes through unchanged.
+
+ Do NOT override ``list_resources()`` directly β the base class uses it
+ to handle re-entrant bypass when ``get_resource_catalog()`` reads the
+ real catalog.
+ """
+ return resources
+
+ async def transform_resource_templates(
+ self, templates: Sequence[ResourceTemplate]
+ ) -> Sequence[ResourceTemplate]:
+ """Transform the resource template catalog.
+
+ Override this method to replace, filter, or augment the template listing.
+ The default implementation passes through unchanged.
+
+ Do NOT override ``list_resource_templates()`` directly β the base class
+ uses it to handle re-entrant bypass when
+ ``get_resource_template_catalog()`` reads the real catalog.
+ """
+ return templates
+
+ async def transform_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]:
+ """Transform the prompt catalog.
+
+ Override this method to replace, filter, or augment the prompt listing.
+ The default implementation passes through unchanged.
+
+ Do NOT override ``list_prompts()`` directly β the base class uses it
+ to handle re-entrant bypass when ``get_prompt_catalog()`` reads the
+ real catalog.
+ """
+ return prompts
+
+ # ------------------------------------------------------------------
+ # Catalog accessors
+ # ------------------------------------------------------------------
+
+ async def get_tool_catalog(
+ self, ctx: Context, *, run_middleware: bool = True
+ ) -> Sequence[Tool]:
+ """Fetch the real tool catalog, bypassing this transform.
+
+ Args:
+ ctx: The current request context.
+ run_middleware: Whether to run middleware on the inner call.
+ Defaults to True because this is typically called from a
+ tool handler where list_tools middleware has not yet run.
+ """
+ token = self._bypass.set(True)
+ try:
+ return await ctx.fastmcp.list_tools(run_middleware=run_middleware)
+ finally:
+ self._bypass.reset(token)
+
+ async def get_resource_catalog(
+ self, ctx: Context, *, run_middleware: bool = True
+ ) -> Sequence[Resource]:
+ """Fetch the real resource catalog, bypassing this transform.
+
+ Args:
+ ctx: The current request context.
+ run_middleware: Whether to run middleware on the inner call.
+ Defaults to True because this is typically called from a
+ tool handler where list_resources middleware has not yet run.
+ """
+ token = self._bypass.set(True)
+ try:
+ return await ctx.fastmcp.list_resources(run_middleware=run_middleware)
+ finally:
+ self._bypass.reset(token)
+
+ async def get_prompt_catalog(
+ self, ctx: Context, *, run_middleware: bool = True
+ ) -> Sequence[Prompt]:
+ """Fetch the real prompt catalog, bypassing this transform.
+
+ Args:
+ ctx: The current request context.
+ run_middleware: Whether to run middleware on the inner call.
+ Defaults to True because this is typically called from a
+ tool handler where list_prompts middleware has not yet run.
+ """
+ token = self._bypass.set(True)
+ try:
+ return await ctx.fastmcp.list_prompts(run_middleware=run_middleware)
+ finally:
+ self._bypass.reset(token)
+
+ async def get_resource_template_catalog(
+ self, ctx: Context, *, run_middleware: bool = True
+ ) -> Sequence[ResourceTemplate]:
+ """Fetch the real resource template catalog, bypassing this transform.
+
+ Args:
+ ctx: The current request context.
+ run_middleware: Whether to run middleware on the inner call.
+ Defaults to True because this is typically called from a
+ tool handler where list_resource_templates middleware has
+ not yet run.
+ """
+ token = self._bypass.set(True)
+ try:
+ return await ctx.fastmcp.list_resource_templates(
+ run_middleware=run_middleware
+ )
+ finally:
+ self._bypass.reset(token)
diff --git a/src/fastmcp/server/transforms/search/__init__.py b/src/fastmcp/server/transforms/search/__init__.py
new file mode 100644
index 000000000..756244f23
--- /dev/null
+++ b/src/fastmcp/server/transforms/search/__init__.py
@@ -0,0 +1,31 @@
+"""Search transforms for tool discovery.
+
+Search transforms collapse a large tool catalog into a search interface,
+letting LLMs discover tools on demand instead of seeing the full list.
+
+Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.server.transforms.search import RegexSearchTransform
+
+ mcp = FastMCP("Server")
+ mcp.add_transform(RegexSearchTransform())
+ # list_tools now returns only search_tools + call_tool
+ ```
+"""
+
+from fastmcp.server.transforms.search.base import (
+ SearchResultSerializer,
+ serialize_tools_for_output_json,
+ serialize_tools_for_output_markdown,
+)
+from fastmcp.server.transforms.search.bm25 import BM25SearchTransform
+from fastmcp.server.transforms.search.regex import RegexSearchTransform
+
+__all__ = [
+ "BM25SearchTransform",
+ "RegexSearchTransform",
+ "SearchResultSerializer",
+ "serialize_tools_for_output_json",
+ "serialize_tools_for_output_markdown",
+]
diff --git a/src/fastmcp/server/transforms/search/base.py b/src/fastmcp/server/transforms/search/base.py
new file mode 100644
index 000000000..24743b5d9
--- /dev/null
+++ b/src/fastmcp/server/transforms/search/base.py
@@ -0,0 +1,269 @@
+"""Base class for search transforms.
+
+Search transforms replace ``list_tools()`` output with a small set of
+synthetic tools β a search tool and a call-tool proxy β so LLMs can
+discover tools on demand instead of receiving the full catalog.
+
+All concrete search transforms (``RegexSearchTransform``,
+``BM25SearchTransform``, etc.) inherit from ``BaseSearchTransform`` and
+implement ``_make_search_tool()`` and ``_search()`` to provide their
+specific search strategy.
+
+Example::
+
+ from fastmcp import FastMCP
+ from fastmcp.server.transforms.search import RegexSearchTransform
+
+ mcp = FastMCP("Server")
+
+ @mcp.tool
+ def add(a: int, b: int) -> int: ...
+
+ @mcp.tool
+ def multiply(x: float, y: float) -> float: ...
+
+ # Clients now see only ``search_tools`` and ``call_tool``.
+ # The original tools are discoverable via search.
+ mcp.add_transform(RegexSearchTransform())
+"""
+
+from abc import abstractmethod
+from collections.abc import Awaitable, Callable, Sequence
+from typing import Annotated, Any
+
+from fastmcp.server.context import Context
+from fastmcp.server.transforms import GetToolNext
+from fastmcp.server.transforms.catalog import CatalogTransform
+from fastmcp.tools.tool import Tool, ToolResult
+from fastmcp.utilities.versions import VersionSpec
+
+
+def _extract_searchable_text(tool: Tool) -> str:
+ """Combine tool name, description, and parameter info into searchable text."""
+ parts = [tool.name]
+ if tool.description:
+ parts.append(tool.description)
+
+ schema = tool.parameters
+ if schema:
+ properties = schema.get("properties", {})
+ for param_name, param_info in properties.items():
+ parts.append(param_name)
+ if isinstance(param_info, dict):
+ desc = param_info.get("description", "")
+ if desc:
+ parts.append(desc)
+
+ return " ".join(parts)
+
+
+def serialize_tools_for_output_json(tools: Sequence[Tool]) -> list[dict[str, Any]]:
+ """Serialize tools to the same dict format as ``list_tools`` output."""
+ return [
+ tool.to_mcp_tool().model_dump(mode="json", exclude_none=True) for tool in tools
+ ]
+
+
+SearchResultSerializer = Callable[[Sequence[Tool]], Any | Awaitable[Any]]
+
+
+async def _invoke_serializer(
+ serializer: SearchResultSerializer, tools: Sequence[Tool]
+) -> Any:
+ """Call a serializer and await the result if it returns a coroutine."""
+ result = serializer(tools)
+ if isinstance(result, Awaitable):
+ return await result
+ return result
+
+
+def _union_type(branches: list[Any]) -> str:
+ branch_types = list(dict.fromkeys(_schema_type(b) for b in branches))
+ if "null" not in branch_types:
+ return " | ".join(branch_types) if branch_types else "any"
+ non_null = [b for b in branch_types if b != "null"]
+ if not non_null:
+ return "null"
+ return f"{' | '.join(non_null)}?"
+
+
+def _schema_type(schema: Any) -> str:
+ # Intentionally heuristic: the goal is a concise readable label, not a
+ # complete type system. Malformed schemas (e.g. {"type": ""}) β "any".
+ if not isinstance(schema, dict):
+ return "any"
+ t = schema.get("type")
+ if isinstance(t, str) and t:
+ if t == "array":
+ return f"{_schema_type(schema.get('items'))}[]"
+ if t == "null":
+ return "null"
+ return t
+ if "$ref" in schema:
+ return "object"
+ if "anyOf" in schema:
+ return _union_type(schema["anyOf"])
+ if "oneOf" in schema:
+ return _union_type(schema["oneOf"])
+ if "allOf" in schema:
+ # allOf = intersection / Pydantic composed model β always an object
+ return "object"
+ return "object" if "properties" in schema else "any"
+
+
+def _schema_section(schema: dict[str, Any] | None, title: str) -> list[str]:
+ lines = [f"**{title}**"]
+ if not isinstance(schema, dict):
+ lines.append("- `value` (any)")
+ return lines
+
+ props = schema.get("properties")
+ raw_required = schema.get("required")
+ req = set(raw_required) if isinstance(raw_required, list) else set()
+ if props is None:
+ # Not a properties-based schema β treat as a single unnamed value.
+ lines.append(f"- `value` ({_schema_type(schema)})")
+ return lines
+ if not props:
+ # Object schema with no properties β zero-argument tool.
+ lines.append("*(no parameters)*")
+ return lines
+
+ for name, field in props.items():
+ required = ", required" if name in req else ""
+ lines.append(f"- `{name}` ({_schema_type(field)}{required})")
+ return lines
+
+
+def serialize_tools_for_output_markdown(tools: Sequence[Tool]) -> str:
+ """Serialize tools to compact markdown, using ~65-70% fewer tokens than JSON."""
+ if not tools:
+ return "No tools matched the query."
+ blocks: list[str] = []
+ for tool in tools:
+ lines = [f"### {tool.name}"]
+ if tool.description:
+ lines.extend(["", tool.description.strip()])
+ lines.extend(["", *_schema_section(tool.parameters, "Parameters")])
+ if tool.output_schema is not None:
+ lines.extend(["", *_schema_section(tool.output_schema, "Returns")])
+ blocks.append("\n".join(lines))
+ return "\n\n".join(blocks)
+
+
+class BaseSearchTransform(CatalogTransform):
+ """Replace the tool listing with a search interface.
+
+ When this transform is active, ``list_tools()`` returns only:
+
+ * Any tools listed in ``always_visible`` (pinned).
+ * A **search tool** that finds tools matching a query.
+ * A **call_tool** proxy that executes tools discovered via search.
+
+ Hidden tools remain callable β ``get_tool()`` delegates unknown
+ names downstream, so direct calls and the call-tool proxy both work.
+
+ Search results respect the full auth pipeline: middleware, visibility
+ transforms, and component-level auth checks all apply.
+
+ Args:
+ max_results: Maximum number of tools returned per search.
+ always_visible: Tool names that stay in the ``list_tools``
+ output alongside the synthetic search/call tools.
+ search_tool_name: Name of the generated search tool.
+ call_tool_name: Name of the generated call-tool proxy.
+ """
+
+ def __init__(
+ self,
+ *,
+ max_results: int = 5,
+ always_visible: list[str] | None = None,
+ search_tool_name: str = "search_tools",
+ call_tool_name: str = "call_tool",
+ search_result_serializer: SearchResultSerializer | None = None,
+ ) -> None:
+ super().__init__()
+ self._max_results = max_results
+ self._always_visible = set(always_visible or [])
+ self._search_tool_name = search_tool_name
+ self._call_tool_name = call_tool_name
+ self._search_result_serializer: SearchResultSerializer = (
+ search_result_serializer or serialize_tools_for_output_json
+ )
+
+ # ------------------------------------------------------------------
+ # Transform interface
+ # ------------------------------------------------------------------
+
+ async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
+ """Replace the catalog with pinned + synthetic search/call tools."""
+ pinned = [t for t in tools if t.name in self._always_visible]
+ return [*pinned, self._make_search_tool(), self._make_call_tool()]
+
+ async def get_tool(
+ self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
+ ) -> Tool | None:
+ """Intercept synthetic tool names; delegate everything else."""
+ if name == self._search_tool_name:
+ return self._make_search_tool()
+ if name == self._call_tool_name:
+ return self._make_call_tool()
+ return await call_next(name, version=version)
+
+ # ------------------------------------------------------------------
+ # Synthetic tools
+ # ------------------------------------------------------------------
+
+ @abstractmethod
+ def _make_search_tool(self) -> Tool:
+ """Create the search tool. Subclasses define the parameter schema."""
+ ...
+
+ def _make_call_tool(self) -> Tool:
+ """Create the call_tool proxy that executes discovered tools."""
+ transform = self
+
+ async def call_tool(
+ name: Annotated[str, "The name of the tool to call"],
+ arguments: Annotated[
+ dict[str, Any] | None, "Arguments to pass to the tool"
+ ] = None,
+ ctx: Context = None, # type: ignore[assignment]
+ ) -> ToolResult:
+ """Call a tool by name with the given arguments.
+
+ Use this to execute tools discovered via search_tools.
+ """
+ if name in {transform._call_tool_name, transform._search_tool_name}:
+ raise ValueError(
+ f"'{name}' is a synthetic search tool and cannot be called via the call_tool proxy"
+ )
+ return await ctx.fastmcp.call_tool(name, arguments)
+
+ return Tool.from_function(fn=call_tool, name=self._call_tool_name)
+
+ # ------------------------------------------------------------------
+ # Serialization
+ # ------------------------------------------------------------------
+
+ async def _render_results(self, tools: Sequence[Tool]) -> Any:
+ return await _invoke_serializer(self._search_result_serializer, tools)
+
+ # ------------------------------------------------------------------
+ # Catalog access
+ # ------------------------------------------------------------------
+
+ async def _get_visible_tools(self, ctx: Context) -> Sequence[Tool]:
+ """Get the auth-filtered tool catalog, excluding pinned tools."""
+ tools = await self.get_tool_catalog(ctx)
+ return [t for t in tools if t.name not in self._always_visible]
+
+ # ------------------------------------------------------------------
+ # Abstract search
+ # ------------------------------------------------------------------
+
+ @abstractmethod
+ async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
+ """Search the given tools and return matches."""
+ ...
diff --git a/src/fastmcp/server/transforms/search/bm25.py b/src/fastmcp/server/transforms/search/bm25.py
new file mode 100644
index 000000000..8e06ac4be
--- /dev/null
+++ b/src/fastmcp/server/transforms/search/bm25.py
@@ -0,0 +1,144 @@
+"""BM25-based search transform."""
+
+import hashlib
+import math
+import re
+from collections.abc import Sequence
+from typing import Annotated, Any
+
+from fastmcp.server.context import Context
+from fastmcp.server.transforms.search.base import (
+ BaseSearchTransform,
+ SearchResultSerializer,
+ _extract_searchable_text,
+)
+from fastmcp.tools.tool import Tool
+
+
+def _tokenize(text: str) -> list[str]:
+ """Lowercase, split on non-alphanumeric, filter short tokens."""
+ return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) > 1]
+
+
+class _BM25Index:
+ """Self-contained BM25 Okapi index."""
+
+ def __init__(self, k1: float = 1.5, b: float = 0.75) -> None:
+ self.k1 = k1
+ self.b = b
+ self._doc_tokens: list[list[str]] = []
+ self._doc_lengths: list[int] = []
+ self._avg_dl: float = 0.0
+ self._df: dict[str, int] = {}
+ self._tf: list[dict[str, int]] = []
+ self._n: int = 0
+
+ def build(self, documents: list[str]) -> None:
+ self._doc_tokens = [_tokenize(doc) for doc in documents]
+ self._doc_lengths = [len(tokens) for tokens in self._doc_tokens]
+ self._n = len(documents)
+ self._avg_dl = sum(self._doc_lengths) / self._n if self._n else 0.0
+
+ self._df = {}
+ self._tf = []
+ for tokens in self._doc_tokens:
+ tf: dict[str, int] = {}
+ seen: set[str] = set()
+ for token in tokens:
+ tf[token] = tf.get(token, 0) + 1
+ if token not in seen:
+ self._df[token] = self._df.get(token, 0) + 1
+ seen.add(token)
+ self._tf.append(tf)
+
+ def query(self, text: str, top_k: int) -> list[int]:
+ """Return indices of top_k documents sorted by BM25 score."""
+ query_tokens = _tokenize(text)
+ if not query_tokens or not self._n:
+ return []
+
+ scores: list[float] = [0.0] * self._n
+ for token in query_tokens:
+ if token not in self._df:
+ continue
+ idf = math.log(
+ (self._n - self._df[token] + 0.5) / (self._df[token] + 0.5) + 1.0
+ )
+ for i in range(self._n):
+ tf = self._tf[i].get(token, 0)
+ if tf == 0:
+ continue
+ dl = self._doc_lengths[i]
+ numerator = tf * (self.k1 + 1)
+ denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avg_dl)
+ scores[i] += idf * numerator / denominator
+
+ ranked = sorted(range(self._n), key=lambda i: scores[i], reverse=True)
+ return [i for i in ranked[:top_k] if scores[i] > 0]
+
+
+def _catalog_hash(tools: Sequence[Tool]) -> str:
+ """SHA256 hash of sorted tool searchable text for staleness detection."""
+ key = "|".join(sorted(_extract_searchable_text(t) for t in tools))
+ return hashlib.sha256(key.encode()).hexdigest()
+
+
+class BM25SearchTransform(BaseSearchTransform):
+ """Search transform using BM25 Okapi relevance ranking.
+
+ Maintains an in-memory index that is lazily rebuilt when the tool
+ catalog changes (detected via a hash of tool names).
+ """
+
+ def __init__(
+ self,
+ *,
+ max_results: int = 5,
+ always_visible: list[str] | None = None,
+ search_tool_name: str = "search_tools",
+ call_tool_name: str = "call_tool",
+ search_result_serializer: SearchResultSerializer | None = None,
+ ) -> None:
+ super().__init__(
+ max_results=max_results,
+ always_visible=always_visible,
+ search_tool_name=search_tool_name,
+ call_tool_name=call_tool_name,
+ search_result_serializer=search_result_serializer,
+ )
+ self._index = _BM25Index()
+ self._indexed_tools: Sequence[Tool] = ()
+ self._last_hash: str = ""
+
+ def _make_search_tool(self) -> Tool:
+ transform = self
+
+ async def search_tools(
+ query: Annotated[str, "Natural language query to search for tools"],
+ ctx: Context = None, # type: ignore[assignment]
+ ) -> str | list[dict[str, Any]]:
+ """Search for tools using natural language.
+
+ Returns matching tool definitions ranked by relevance,
+ in the same format as list_tools.
+ """
+ hidden = await transform._get_visible_tools(ctx)
+ results = await transform._search(hidden, query)
+ return await transform._render_results(results)
+
+ return Tool.from_function(fn=search_tools, name=self._search_tool_name)
+
+ async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
+ current_hash = _catalog_hash(tools)
+ if current_hash != self._last_hash:
+ documents = [_extract_searchable_text(t) for t in tools]
+ new_index = _BM25Index(self._index.k1, self._index.b)
+ new_index.build(documents)
+ self._index, self._indexed_tools, self._last_hash = (
+ new_index,
+ tools,
+ current_hash,
+ )
+
+ indices = self._index.query(query, self._max_results)
+ return [self._indexed_tools[i] for i in indices]
diff --git a/src/fastmcp/server/transforms/search/regex.py b/src/fastmcp/server/transforms/search/regex.py
new file mode 100644
index 000000000..8f00bdce6
--- /dev/null
+++ b/src/fastmcp/server/transforms/search/regex.py
@@ -0,0 +1,55 @@
+"""Regex-based search transform."""
+
+import re
+from collections.abc import Sequence
+from typing import Annotated, Any
+
+from fastmcp.server.context import Context
+from fastmcp.server.transforms.search.base import (
+ BaseSearchTransform,
+ _extract_searchable_text,
+)
+from fastmcp.tools.tool import Tool
+
+
+class RegexSearchTransform(BaseSearchTransform):
+ """Search transform using regex pattern matching.
+
+ Tools are matched against their name, description, and parameter
+ information using ``re.search`` with ``re.IGNORECASE``.
+ """
+
+ def _make_search_tool(self) -> Tool:
+ transform = self
+
+ async def search_tools(
+ pattern: Annotated[
+ str,
+ "Regex pattern to match against tool names, descriptions, and parameters",
+ ],
+ ctx: Context = None, # type: ignore[assignment]
+ ) -> str | list[dict[str, Any]]:
+ """Search for tools matching a regex pattern.
+
+ Returns matching tool definitions in the same format as list_tools.
+ """
+ hidden = await transform._get_visible_tools(ctx)
+ results = await transform._search(hidden, pattern)
+ return await transform._render_results(results)
+
+ return Tool.from_function(fn=search_tools, name=self._search_tool_name)
+
+ async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
+ try:
+ compiled = re.compile(query, re.IGNORECASE)
+ except re.error:
+ return []
+
+ matches: list[Tool] = []
+ for tool in tools:
+ text = _extract_searchable_text(tool)
+ if compiled.search(text):
+ matches.append(tool)
+ if len(matches) >= self._max_results:
+ break
+ return matches
diff --git a/src/fastmcp/server/transforms/version_filter.py b/src/fastmcp/server/transforms/version_filter.py
index d49928cd0..1b1d0270c 100644
--- a/src/fastmcp/server/transforms/version_filter.py
+++ b/src/fastmcp/server/transforms/version_filter.py
@@ -24,9 +24,11 @@ if TYPE_CHECKING:
class VersionFilter(Transform):
"""Filters components by version range.
- When applied to a provider or server, only components within the version
- range are visible. Within that filtered set, the highest version of each
- component is exposed to clients (standard deduplication behavior).
+ When applied to a provider or server, components within the version range
+ are visible, and unversioned components are included by default. Within
+ that filtered set, the highest version of each component is exposed to
+ clients (standard deduplication behavior). Set
+ ``include_unversioned=False`` to exclude unversioned components.
Parameters mirror comparison operators for clarity:
@@ -41,6 +43,8 @@ class VersionFilter(Transform):
Args:
version_gte: Versions >= this value pass through.
version_lt: Versions < this value pass through.
+ include_unversioned: Whether unversioned components (``version=None``)
+ should pass through the filter. Defaults to True.
"""
def __init__(
@@ -48,6 +52,7 @@ class VersionFilter(Transform):
*,
version_gte: str | None = None,
version_lt: str | None = None,
+ include_unversioned: bool = True,
) -> None:
if version_gte is None and version_lt is None:
raise ValueError(
@@ -55,6 +60,7 @@ class VersionFilter(Transform):
)
self.version_gte = version_gte
self.version_lt = version_lt
+ self.include_unversioned = include_unversioned
self._spec = VersionSpec(gte=version_gte, lt=version_lt)
def __repr__(self) -> str:
@@ -63,6 +69,8 @@ class VersionFilter(Transform):
parts.append(f"version_gte={self.version_gte!r}")
if self.version_lt:
parts.append(f"version_lt={self.version_lt!r}")
+ if not self.include_unversioned:
+ parts.append("include_unversioned=False")
return f"VersionFilter({', '.join(parts)})"
# -------------------------------------------------------------------------
@@ -70,7 +78,11 @@ class VersionFilter(Transform):
# -------------------------------------------------------------------------
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
- return [t for t in tools if self._spec.matches(t.version)]
+ return [
+ t
+ for t in tools
+ if self._spec.matches(t.version, match_none=self.include_unversioned)
+ ]
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
@@ -82,7 +94,11 @@ class VersionFilter(Transform):
# -------------------------------------------------------------------------
async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]:
- return [r for r in resources if self._spec.matches(r.version)]
+ return [
+ r
+ for r in resources
+ if self._spec.matches(r.version, match_none=self.include_unversioned)
+ ]
async def get_resource(
self,
@@ -100,7 +116,11 @@ class VersionFilter(Transform):
async def list_resource_templates(
self, templates: Sequence[ResourceTemplate]
) -> Sequence[ResourceTemplate]:
- return [t for t in templates if self._spec.matches(t.version)]
+ return [
+ t
+ for t in templates
+ if self._spec.matches(t.version, match_none=self.include_unversioned)
+ ]
async def get_resource_template(
self,
@@ -116,7 +136,11 @@ class VersionFilter(Transform):
# -------------------------------------------------------------------------
async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]:
- return [p for p in prompts if self._spec.matches(p.version)]
+ return [
+ p
+ for p in prompts
+ if self._spec.matches(p.version, match_none=self.include_unversioned)
+ ]
async def get_prompt(
self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None
diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py
index 561a80437..bc6d22065 100644
--- a/src/fastmcp/settings.py
+++ b/src/fastmcp/settings.py
@@ -227,6 +227,9 @@ class Settings(BaseSettings):
),
] = None
+ # Transport settings
+ transport: Literal["stdio", "http", "sse", "streamable-http"] = "stdio"
+
# HTTP settings
host: str = "127.0.0.1"
port: int = 8000
diff --git a/src/fastmcp/tools/function_parsing.py b/src/fastmcp/tools/function_parsing.py
index d48f6dbe6..a056c37c9 100644
--- a/src/fastmcp/tools/function_parsing.py
+++ b/src/fastmcp/tools/function_parsing.py
@@ -3,9 +3,10 @@
from __future__ import annotations
import inspect
+import types
from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any, Generic, get_type_hints
+from typing import Annotated, Any, Generic, Union, get_args, get_origin, get_type_hints
import mcp.types
from pydantic import PydanticSchemaGenerationError
@@ -27,6 +28,25 @@ from fastmcp.utilities.types import (
replace_type,
)
+try:
+ from prefab_ui.app import PrefabApp as _PrefabApp
+ from prefab_ui.components.base import Component as _PrefabComponent
+
+ _PREFAB_TYPES: tuple[type, ...] = (_PrefabApp, _PrefabComponent)
+except ImportError:
+ _PREFAB_TYPES = ()
+
+
+def _contains_prefab_type(tp: Any) -> bool:
+ """Check if *tp* is or contains a prefab type, recursing through unions and Annotated."""
+ if isinstance(tp, type) and issubclass(tp, _PREFAB_TYPES):
+ return True
+ origin = get_origin(tp)
+ if origin is Union or origin is types.UnionType or origin is Annotated:
+ return any(_contains_prefab_type(a) for a in get_args(tp))
+ return False
+
+
T = TypeVarExt("T", default=Any)
logger = get_logger(__name__)
@@ -65,6 +85,7 @@ class ParsedFunction:
description: str | None
input_schema: dict[str, Any]
output_schema: dict[str, Any] | None
+ return_type: Any = None
@classmethod
def from_function(
@@ -145,7 +166,18 @@ class ParsedFunction:
# If resolution fails, keep the string annotation
logger.debug("Failed to resolve type hint for return annotation: %s", e)
+ # Save original for return_type before any schema-related replacement
+ original_output_type = output_type
+
if output_type not in (inspect._empty, None, Any, ...):
+ # Prefab component subclasses (Column, Card, etc.) shouldn't
+ # produce output schemas β replace_type only does exact matching,
+ # so we handle subclass matching explicitly here. We also need
+ # to handle composite types like ``Column | None`` and
+ # ``Annotated[PrefabApp, ...]`` by recursing into their args.
+ if _PREFAB_TYPES and _contains_prefab_type(output_type):
+ output_type = _UnserializableType
+
# there are a variety of types that we don't want to attempt to
# serialize because they are either used by FastMCP internally,
# or are MCP content types that explicitly don't form structured
@@ -164,6 +196,7 @@ class ParsedFunction:
mcp.types.AudioContent,
mcp.types.ResourceLink,
mcp.types.EmbeddedResource,
+ *_PREFAB_TYPES,
),
_UnserializableType,
),
@@ -198,4 +231,5 @@ class ParsedFunction:
description=fn_doc,
input_schema=input_schema,
output_schema=output_schema or None,
+ return_type=original_output_type,
)
diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py
index 6c1a361f6..e88828a80 100644
--- a/src/fastmcp/tools/function_tool.py
+++ b/src/fastmcp/tools/function_tool.py
@@ -8,6 +8,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
+ Annotated,
Any,
Literal,
Protocol,
@@ -20,6 +21,7 @@ import anyio
import mcp.types
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, Icon, ToolAnnotations, ToolExecution
+from pydantic import Field
from pydantic.json_schema import SkipJsonSchema
import fastmcp
@@ -84,6 +86,7 @@ class ToolMeta:
class FunctionTool(Tool):
fn: SkipJsonSchema[Callable[..., Any]]
+ return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None
def to_mcp_tool(
self,
@@ -230,6 +233,7 @@ class FunctionTool(Tool):
return cls(
fn=parsed_fn.fn,
+ return_type=parsed_fn.return_type,
name=metadata.name or parsed_fn.name,
version=str(metadata.version) if metadata.version is not None else None,
title=metadata.title,
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index 3c99db465..b23ebfc8b 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -38,6 +38,14 @@ from fastmcp.utilities.types import (
NotSetT,
)
+try:
+ from prefab_ui.app import PrefabApp as _PrefabApp
+ from prefab_ui.components.base import Component as _PrefabComponent
+
+ _HAS_PREFAB = True
+except ImportError:
+ _HAS_PREFAB = False
+
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
@@ -82,6 +90,14 @@ class ToolResult(BaseModel):
converted_content: list[ContentBlock] = _convert_to_content(result=content)
if structured_content is not None:
+ # Convert Prefab types to their wire-format envelope before
+ # generic serialization, so the renderer gets the right shape.
+ if _HAS_PREFAB:
+ if isinstance(structured_content, _PrefabApp):
+ structured_content = structured_content.to_json()
+ elif isinstance(structured_content, _PrefabComponent):
+ structured_content = _PrefabApp(view=structured_content).to_json()
+
try:
structured_content = pydantic_core.to_jsonable_python(
value=structured_content
@@ -248,6 +264,12 @@ class Tool(FastMCPComponent):
if isinstance(raw_value, ToolResult):
return raw_value
+ if _HAS_PREFAB:
+ if isinstance(raw_value, _PrefabApp):
+ return _prefab_to_tool_result(raw_value)
+ if isinstance(raw_value, _PrefabComponent):
+ return _prefab_to_tool_result(_PrefabApp(view=raw_value))
+
content = _convert_to_content(raw_value, serializer=self.serializer)
# Skip structured content for ContentBlock types only if no output_schema
@@ -454,6 +476,17 @@ def _convert_to_single_content_block(
return TextContent(type="text", text=_serialize_with_fallback(item, serializer))
+_PREFAB_TEXT_FALLBACK = "[Rendered Prefab UI]"
+
+
+def _prefab_to_tool_result(app: Any) -> ToolResult:
+ """Convert a PrefabApp to a FastMCP ToolResult."""
+ return ToolResult(
+ content=[TextContent(type="text", text=_PREFAB_TEXT_FALLBACK)],
+ structured_content=app.to_json(),
+ )
+
+
def _convert_to_content(
result: Any,
serializer: ToolResultSerializerType | None = None,
diff --git a/src/fastmcp/utilities/openapi/schemas.py b/src/fastmcp/utilities/openapi/schemas.py
index bb057f2eb..fa93b6c8d 100644
--- a/src/fastmcp/utilities/openapi/schemas.py
+++ b/src/fastmcp/utilities/openapi/schemas.py
@@ -123,6 +123,19 @@ def _replace_ref_with_defs(
schema["additionalProperties"] = _replace_ref_with_defs(
additionalProperties
)
+ # Handle propertyNames
+ if property_names := schema.get("propertyNames"):
+ if isinstance(property_names, dict):
+ schema["propertyNames"] = _replace_ref_with_defs(property_names)
+ # Handle patternProperties
+ if pattern_properties := schema.get("patternProperties"):
+ if isinstance(pattern_properties, dict):
+ schema["patternProperties"] = {
+ pattern: _replace_ref_with_defs(subschema)
+ if isinstance(subschema, dict)
+ else subschema
+ for pattern, subschema in pattern_properties.items()
+ }
if info.get("description", description) and not schema.get("description"):
schema["description"] = description
return schema
diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py
index 184ea1dde..462bd7734 100644
--- a/tests/cli/test_run.py
+++ b/tests/cli/test_run.py
@@ -1,13 +1,18 @@
import inspect
import json
+import subprocess
+import sys
from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import ValidationError
+from fastmcp.cli.cli import inspector, run
from fastmcp.cli.run import (
create_mcp_config_server,
is_url,
+ run_module_command,
)
from fastmcp.client.client import Client
from fastmcp.client.transports import FastMCPTransport
@@ -695,3 +700,194 @@ class TestReloadFunctionality:
}
for ext in expected:
assert ext in WATCHED_EXTENSIONS, f"Expected {ext} in WATCHED_EXTENSIONS"
+
+
+class TestRunModuleCommand:
+ """Test run_module_command functionality."""
+
+ def test_runs_python_m_module(self):
+ """Test that run_module_command invokes python -m ."""
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+
+ with (
+ patch(
+ "fastmcp.cli.run.subprocess.run", return_value=mock_result
+ ) as mock_run,
+ pytest.raises(SystemExit) as exc_info,
+ ):
+ run_module_command("my_package")
+
+ assert exc_info.value.code == 0
+ call_args = mock_run.call_args
+ cmd = call_args[0][0]
+ assert "-m" in cmd
+ assert "my_package" in cmd
+
+ def test_forwards_extra_args(self):
+ """Test that extra arguments are forwarded after the module name."""
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+
+ with (
+ patch(
+ "fastmcp.cli.run.subprocess.run", return_value=mock_result
+ ) as mock_run,
+ pytest.raises(SystemExit),
+ ):
+ run_module_command("my_package", extra_args=["--host", "0.0.0.0"])
+
+ cmd = mock_run.call_args[0][0]
+ assert "--host" in cmd
+ assert "0.0.0.0" in cmd
+
+ def test_uses_env_command_builder(self):
+ """Test that env_command_builder wraps the command."""
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+
+ def fake_builder(cmd: list[str]) -> list[str]:
+ return ["uv", "run", *cmd]
+
+ with (
+ patch(
+ "fastmcp.cli.run.subprocess.run", return_value=mock_result
+ ) as mock_run,
+ pytest.raises(SystemExit),
+ ):
+ run_module_command("my_package", env_command_builder=fake_builder)
+
+ cmd = mock_run.call_args[0][0]
+ assert cmd[0] == "uv"
+ assert cmd[1] == "run"
+ # Should use bare "python" (not sys.executable) so uv resolves the interpreter
+ assert cmd[2] == "python"
+ assert "-m" in cmd
+ assert "my_package" in cmd
+
+ def test_exits_with_subprocess_error_code(self):
+ """Test that non-zero exit codes from the module are propagated."""
+ with (
+ patch(
+ "fastmcp.cli.run.subprocess.run",
+ side_effect=subprocess.CalledProcessError(42, ["python", "-m", "bad"]),
+ ),
+ pytest.raises(SystemExit) as exc_info,
+ ):
+ run_module_command("bad")
+
+ assert exc_info.value.code == 42
+
+ def test_no_env_builder_runs_plain_python(self):
+ """Test that without env_command_builder, plain python is used."""
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+
+ with (
+ patch(
+ "fastmcp.cli.run.subprocess.run", return_value=mock_result
+ ) as mock_run,
+ pytest.raises(SystemExit),
+ ):
+ run_module_command("my_module", env_command_builder=None)
+
+ cmd = mock_run.call_args[0][0]
+ assert cmd[0] == sys.executable
+ assert cmd[1] == "-m"
+ assert cmd[2] == "my_module"
+
+
+class TestRunModuleMode:
+ """Test the run command's module-mode branch."""
+
+ async def test_run_module_mode_requires_server_spec(self):
+ """Test that module mode exits with error when server_spec is None."""
+ with pytest.raises(SystemExit) as exc_info:
+ await run(None, module=True)
+
+ assert exc_info.value.code == 1
+
+ async def test_run_module_mode_warns_ignored_options(self, caplog):
+ """Test that ignored options produce a warning in module mode."""
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+
+ with (
+ patch("fastmcp.cli.run.subprocess.run", return_value=mock_result),
+ pytest.raises(SystemExit),
+ caplog.at_level("WARNING"),
+ ):
+ await run(
+ "my_module",
+ module=True,
+ transport="sse",
+ host="0.0.0.0",
+ port=8080,
+ )
+
+ assert any("ignored in module mode" in r.message for r in caplog.records)
+
+ async def test_run_module_mode_delegates_to_run_module_command(self):
+ """Test that module mode calls run_module_command with correct args."""
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+
+ with (
+ patch(
+ "fastmcp.cli.run.subprocess.run", return_value=mock_result
+ ) as mock_subprocess,
+ pytest.raises(SystemExit),
+ ):
+ await run("my_module", module=True)
+
+ cmd = mock_subprocess.call_args[0][0]
+ assert "-m" in cmd
+ assert "my_module" in cmd
+
+ async def test_run_module_mode_with_reload(self):
+ """Test that --reload in module mode delegates to run_with_reload."""
+ with patch(
+ "fastmcp.cli.run.run_with_reload", new_callable=AsyncMock
+ ) as mock_reload:
+ await run("my_module", module=True, reload=True, skip_env=True)
+
+ mock_reload.assert_called_once()
+ cmd = mock_reload.call_args[0][0]
+ assert "fastmcp" in cmd
+ assert "--module" in cmd
+ assert "--no-reload" in cmd
+ assert "my_module" in cmd
+
+
+class TestInspectorModuleMode:
+ """Test the inspector command's module-mode handling."""
+
+ async def test_inspector_module_mode_skips_load_server(self):
+ """Test that inspector with module=True skips load_server() and forwards --module."""
+ mock_config = MagicMock()
+ mock_config.deployment.port = 8080
+ mock_config.environment.build_command = lambda cmd: cmd
+ mock_config.source.load_server = AsyncMock()
+
+ mock_process = MagicMock()
+ mock_process.returncode = 0
+
+ with (
+ patch(
+ "fastmcp.cli.cli.load_and_merge_config",
+ return_value=(mock_config, "my_module"),
+ ),
+ patch("fastmcp.cli.cli._get_npx_command", return_value="npx"),
+ patch(
+ "fastmcp.cli.cli.subprocess.run", return_value=mock_process
+ ) as mock_subprocess,
+ pytest.raises(SystemExit),
+ ):
+ await inspector("my_module", module=True)
+
+ # load_server should NOT have been called in module mode
+ mock_config.source.load_server.assert_not_called()
+
+ # --module should be in the subprocess command
+ cmd = mock_subprocess.call_args[0][0]
+ assert "--module" in cmd
diff --git a/tests/client/sampling/handlers/test_google_genai_handler.py b/tests/client/sampling/handlers/test_google_genai_handler.py
new file mode 100644
index 000000000..92403461e
--- /dev/null
+++ b/tests/client/sampling/handlers/test_google_genai_handler.py
@@ -0,0 +1,360 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+try:
+ from google.genai import Client as GoogleGenaiClient
+ from google.genai.types import (
+ Candidate,
+ FunctionCall,
+ FunctionCallingConfigMode,
+ GenerateContentResponse,
+ ModelContent,
+ Part,
+ UserContent,
+ )
+ from mcp.types import (
+ CreateMessageResult,
+ ModelHint,
+ ModelPreferences,
+ TextContent,
+ ToolChoice,
+ ToolResultContent,
+ ToolUseContent,
+ )
+
+ from fastmcp.client.sampling.handlers.google_genai import (
+ GoogleGenaiSamplingHandler,
+ _convert_messages_to_google_genai_content,
+ _convert_tool_choice_to_google_genai,
+ _response_to_create_message_result,
+ _response_to_result_with_tools,
+ _sampling_content_to_google_genai_part,
+ )
+
+ GOOGLE_GENAI_AVAILABLE = True
+except ImportError:
+ GOOGLE_GENAI_AVAILABLE = False
+
+pytestmark = pytest.mark.skipif(
+ not GOOGLE_GENAI_AVAILABLE, reason="google-genai not installed"
+)
+
+
+def test_convert_sampling_messages_to_google_genai_content():
+ from mcp.types import SamplingMessage, TextContent
+
+ msgs = _convert_messages_to_google_genai_content(
+ messages=[
+ SamplingMessage(
+ role="user", content=TextContent(type="text", text="hello")
+ ),
+ SamplingMessage(
+ role="assistant", content=TextContent(type="text", text="ok")
+ ),
+ ],
+ )
+
+ assert len(msgs) == 2
+ assert isinstance(msgs[0], UserContent)
+ assert isinstance(msgs[1], ModelContent)
+ assert msgs[0].parts[0].text == "hello"
+ assert msgs[1].parts[0].text == "ok"
+
+
+def test_convert_to_google_genai_messages_raises_on_non_text():
+ from mcp.types import SamplingMessage
+
+ from fastmcp.utilities.types import Image
+
+ with pytest.raises(ValueError):
+ _convert_messages_to_google_genai_content(
+ messages=[
+ SamplingMessage(
+ role="user",
+ content=Image(data=b"abc").to_image_content(),
+ )
+ ],
+ )
+
+
+def test_get_model():
+ mock_client = MagicMock(spec=GoogleGenaiClient)
+ handler = GoogleGenaiSamplingHandler(
+ default_model="fallback-model", client=mock_client
+ )
+
+ # Test with Gemini model hint
+ prefs = ModelPreferences(hints=[ModelHint(name="gemini-2.0-flash-exp")])
+ assert handler._get_model(prefs) == "gemini-2.0-flash-exp"
+
+ # Test with None
+ assert handler._get_model(None) == "fallback-model"
+
+ # Test with empty hints
+ prefs_empty = ModelPreferences(hints=[])
+ assert handler._get_model(prefs_empty) == "fallback-model"
+
+ # Test with non-Gemini hint falls back to default
+ prefs_other = ModelPreferences(hints=[ModelHint(name="gpt-4o")])
+ assert handler._get_model(prefs_other) == "fallback-model"
+
+ # Test with mixed hints selects first Gemini model
+ prefs_mixed = ModelPreferences(
+ hints=[ModelHint(name="claude-3.5-sonnet"), ModelHint(name="gemini-2.0-flash")]
+ )
+ assert handler._get_model(prefs_mixed) == "gemini-2.0-flash"
+
+
+async def test_response_to_create_message_result():
+ # Create a mock response
+ mock_response = MagicMock(spec=GenerateContentResponse)
+ mock_response.text = "HELPFUL CONTENT FROM GEMINI"
+
+ result: CreateMessageResult = _response_to_create_message_result(
+ response=mock_response, model="gemini-2.0-flash-exp"
+ )
+ assert result == CreateMessageResult(
+ content=TextContent(type="text", text="HELPFUL CONTENT FROM GEMINI"),
+ role="assistant",
+ model="gemini-2.0-flash-exp",
+ )
+
+
+def test_convert_tool_choice_to_google_genai():
+ # Test auto mode
+ result = _convert_tool_choice_to_google_genai(ToolChoice(mode="auto"))
+ assert result.function_calling_config is not None
+ assert result.function_calling_config.mode == FunctionCallingConfigMode.AUTO
+
+ # Test required mode
+ result = _convert_tool_choice_to_google_genai(ToolChoice(mode="required"))
+ assert result.function_calling_config is not None
+ assert result.function_calling_config.mode == FunctionCallingConfigMode.ANY
+
+ # Test none mode
+ result = _convert_tool_choice_to_google_genai(ToolChoice(mode="none"))
+ assert result.function_calling_config is not None
+ assert result.function_calling_config.mode == FunctionCallingConfigMode.NONE
+
+ # Test None (defaults to auto)
+ result = _convert_tool_choice_to_google_genai(None)
+ assert result.function_calling_config is not None
+ assert result.function_calling_config.mode == FunctionCallingConfigMode.AUTO
+
+
+def test_sampling_content_to_google_genai_part_tool_use():
+ """Test converting ToolUseContent to Google GenAI Part with FunctionCall."""
+ content = ToolUseContent(
+ type="tool_use",
+ id="get_weather_abc123",
+ name="get_weather",
+ input={"city": "London"},
+ )
+
+ part = _sampling_content_to_google_genai_part(content)
+
+ assert part.function_call is not None
+ assert part.function_call.name == "get_weather"
+ assert part.function_call.args == {"city": "London"}
+
+
+def test_sampling_content_to_google_genai_part_tool_result():
+ """Test converting ToolResultContent to Google GenAI Part with FunctionResponse."""
+ content = ToolResultContent(
+ type="tool_result",
+ toolUseId="get_weather_abc123",
+ content=[TextContent(type="text", text="Weather is sunny")],
+ )
+
+ part = _sampling_content_to_google_genai_part(content)
+
+ assert part.function_response is not None
+ # Function name is extracted from toolUseId by removing the UUID suffix
+ assert part.function_response.name == "get_weather"
+ assert part.function_response.response == {"result": "Weather is sunny"}
+
+
+def test_sampling_content_to_google_genai_part_tool_result_empty():
+ """Test converting empty ToolResultContent to Google GenAI Part."""
+ content = ToolResultContent(
+ type="tool_result",
+ toolUseId="my_tool_xyz789",
+ content=[],
+ )
+
+ part = _sampling_content_to_google_genai_part(content)
+
+ assert part.function_response is not None
+ assert part.function_response.name == "my_tool"
+ assert part.function_response.response == {"result": ""}
+
+
+def test_sampling_content_to_google_genai_part_tool_result_no_underscore():
+ """Test ToolResultContent when toolUseId has no underscore (fallback)."""
+ content = ToolResultContent(
+ type="tool_result",
+ toolUseId="simplefunction",
+ content=[TextContent(type="text", text="Result")],
+ )
+
+ part = _sampling_content_to_google_genai_part(content)
+
+ # When no underscore, the full ID is used as the name
+ assert part.function_response is not None
+ assert part.function_response.name == "simplefunction"
+
+
+def test_convert_messages_with_tool_use():
+ """Test converting messages containing ToolUseContent."""
+ from mcp.types import SamplingMessage
+
+ msgs = _convert_messages_to_google_genai_content(
+ messages=[
+ SamplingMessage(
+ role="user",
+ content=TextContent(type="text", text="What's the weather?"),
+ ),
+ SamplingMessage(
+ role="assistant",
+ content=ToolUseContent(
+ type="tool_use",
+ id="get_weather_123",
+ name="get_weather",
+ input={"city": "NYC"},
+ ),
+ ),
+ ],
+ )
+
+ assert len(msgs) == 2
+ assert isinstance(msgs[0], UserContent)
+ assert isinstance(msgs[1], ModelContent)
+ assert msgs[1].parts[0].function_call is not None
+ assert msgs[1].parts[0].function_call.name == "get_weather"
+
+
+def test_convert_messages_with_tool_result():
+ """Test converting messages containing ToolResultContent."""
+ from mcp.types import SamplingMessage
+
+ msgs = _convert_messages_to_google_genai_content(
+ messages=[
+ SamplingMessage(
+ role="user",
+ content=ToolResultContent(
+ type="tool_result",
+ toolUseId="get_weather_123",
+ content=[TextContent(type="text", text="Sunny, 72Β°F")],
+ ),
+ ),
+ ],
+ )
+
+ assert len(msgs) == 1
+ assert isinstance(msgs[0], UserContent)
+ assert msgs[0].parts[0].function_response is not None
+ assert msgs[0].parts[0].function_response.name == "get_weather"
+
+
+def test_convert_messages_with_multiple_content_blocks():
+ """Test converting messages with multiple content blocks (list content)."""
+ from mcp.types import SamplingMessage
+
+ msgs = _convert_messages_to_google_genai_content(
+ messages=[
+ SamplingMessage(
+ role="user",
+ content=[
+ TextContent(type="text", text="I need weather info."),
+ ToolResultContent(
+ type="tool_result",
+ toolUseId="get_weather_xyz",
+ content=[TextContent(type="text", text="Cloudy")],
+ ),
+ ],
+ ),
+ ],
+ )
+
+ assert len(msgs) == 1
+ assert isinstance(msgs[0], UserContent)
+ assert len(msgs[0].parts) == 2
+ assert msgs[0].parts[0].text == "I need weather info."
+ assert msgs[0].parts[1].function_response is not None
+
+
+def test_response_to_result_with_tools_text_only():
+ """Test _response_to_result_with_tools with a text-only response."""
+ mock_candidate = MagicMock(spec=Candidate)
+ mock_candidate.content = MagicMock()
+ mock_candidate.content.parts = [Part(text="Here's the answer")]
+ mock_candidate.finish_reason = "STOP"
+
+ mock_response = MagicMock(spec=GenerateContentResponse)
+ mock_response.candidates = [mock_candidate]
+
+ result = _response_to_result_with_tools(mock_response, model="gemini-2.0-flash")
+
+ assert result.role == "assistant"
+ assert result.model == "gemini-2.0-flash"
+ assert result.stopReason == "endTurn"
+ assert isinstance(result.content, list)
+ assert len(result.content) == 1
+ assert result.content[0].type == "text"
+ assert isinstance(result.content[0], TextContent)
+ assert result.content[0].text == "Here's the answer"
+
+
+def test_response_to_result_with_tools_function_call():
+ """Test _response_to_result_with_tools with a function call response."""
+ mock_candidate = MagicMock(spec=Candidate)
+ mock_candidate.content = MagicMock()
+ mock_candidate.content.parts = [
+ Part(function_call=FunctionCall(name="get_weather", args={"city": "Paris"}))
+ ]
+ mock_candidate.finish_reason = "STOP"
+
+ mock_response = MagicMock(spec=GenerateContentResponse)
+ mock_response.candidates = [mock_candidate]
+
+ result = _response_to_result_with_tools(mock_response, model="gemini-2.0-flash")
+
+ assert result.stopReason == "toolUse"
+ assert isinstance(result.content, list)
+ assert len(result.content) == 1
+ tool_use = result.content[0]
+ assert isinstance(tool_use, ToolUseContent)
+ assert tool_use.type == "tool_use"
+ assert tool_use.name == "get_weather"
+ assert tool_use.input == {"city": "Paris"}
+ # ID should be in format "get_weather_{uuid}"
+ assert tool_use.id.startswith("get_weather_")
+
+
+def test_response_to_result_with_tools_mixed_content():
+ """Test _response_to_result_with_tools with text and function call."""
+ mock_candidate = MagicMock(spec=Candidate)
+ mock_candidate.content = MagicMock()
+ mock_candidate.content.parts = [
+ Part(text="Let me check that for you."),
+ Part(function_call=FunctionCall(name="search", args={"query": "test"})),
+ ]
+ mock_candidate.finish_reason = "STOP"
+
+ mock_response = MagicMock(spec=GenerateContentResponse)
+ mock_response.candidates = [mock_candidate]
+
+ result = _response_to_result_with_tools(mock_response, model="gemini-2.0-flash")
+
+ assert result.stopReason == "toolUse"
+ assert isinstance(result.content, list)
+ assert len(result.content) == 2
+ text_content = result.content[0]
+ assert isinstance(text_content, TextContent)
+ assert text_content.type == "text"
+ assert text_content.text == "Let me check that for you."
+ tool_use = result.content[1]
+ assert isinstance(tool_use, ToolUseContent)
+ assert tool_use.type == "tool_use"
+ assert tool_use.name == "search"
diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py
index 2316f6dae..ff00def14 100644
--- a/tests/client/test_elicitation.py
+++ b/tests/client/test_elicitation.py
@@ -4,7 +4,7 @@ from typing import Any, Literal, cast
import pytest
from mcp.types import ElicitRequestFormParams, ElicitRequestParams
-from pydantic import BaseModel, Field
+from pydantic import BaseModel
from typing_extensions import TypedDict
from fastmcp import Context, FastMCP
@@ -15,7 +15,6 @@ from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
- get_elicitation_schema,
validate_elicitation_json_schema,
)
from fastmcp.utilities.types import TypeAdapter
@@ -659,474 +658,3 @@ class TestPatternMatching:
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("pattern_match_tool", {})
assert result.data == "Cancelled"
-
-
-async def test_elicitation_implicit_acceptance(fastmcp_server):
- """Test that elicitation handler can return data directly without ElicitResult wrapper."""
-
- async def elicitation_handler(message, response_type, params, ctx):
- # Return data directly without wrapping in ElicitResult
- # This should be treated as implicit acceptance
- return response_type(name="Bob")
-
- async with Client(
- fastmcp_server, elicitation_handler=elicitation_handler
- ) as client:
- result = await client.call_tool("ask_for_name")
- assert result.data == "Hello, Bob!"
-
-
-async def test_elicitation_implicit_acceptance_must_be_dict(fastmcp_server):
- """Test that elicitation handler can return data directly without ElicitResult wrapper."""
-
- async def elicitation_handler(message, response_type, params, ctx):
- # Return data directly without wrapping in ElicitResult
- # This should be treated as implicit acceptance
- return "Bob"
-
- async with Client(
- fastmcp_server, elicitation_handler=elicitation_handler
- ) as client:
- with pytest.raises(
- ToolError,
- match="Elicitation responses must be serializable as a JSON object",
- ):
- await client.call_tool("ask_for_name")
-
-
-def test_enum_elicitation_schema_inline():
- """Test that enum schemas are generated inline without $ref/$defs for MCP compatibility."""
-
- class Priority(Enum):
- LOW = "low"
- MEDIUM = "medium"
- HIGH = "high"
-
- @dataclass
- class TaskRequest:
- title: str
- priority: Priority
-
- # Generate elicitation schema
- schema = get_elicitation_schema(TaskRequest)
-
- # Verify no $defs section exists (enums should be inlined)
- assert "$defs" not in schema, (
- "Schema should not contain $defs - enums must be inline"
- )
-
- # Verify no $ref in properties
- for prop_name, prop_schema in schema.get("properties", {}).items():
- assert "$ref" not in prop_schema, (
- f"Property {prop_name} contains $ref - should be inline"
- )
-
- # Verify the priority field has inline enum values
- priority_schema = schema["properties"]["priority"]
- assert "enum" in priority_schema, "Priority should have enum values inline"
- assert priority_schema["enum"] == ["low", "medium", "high"]
- assert priority_schema.get("type") == "string"
-
- # Verify title field is a simple string
- assert schema["properties"]["title"]["type"] == "string"
-
-
-def test_enum_elicitation_schema_inline_untitled():
- """Test that enum schemas generate simple enum pattern (no automatic titles)."""
-
- class TaskStatus(Enum):
- NOT_STARTED = "not_started"
- IN_PROGRESS = "in_progress"
- COMPLETED = "completed"
- ON_HOLD = "on_hold"
-
- @dataclass
- class TaskUpdate:
- task_id: str
- status: TaskStatus
-
- # Generate elicitation schema
- schema = get_elicitation_schema(TaskUpdate)
-
- # Verify enum is inline
- assert "$defs" not in schema
- assert "$ref" not in str(schema)
-
- status_schema = schema["properties"]["status"]
- # Should generate simple enum pattern (no automatic title generation)
- assert "enum" in status_schema
- assert "oneOf" not in status_schema
- assert "enumNames" not in status_schema
- assert status_schema["enum"] == [
- "not_started",
- "in_progress",
- "completed",
- "on_hold",
- ]
-
-
-async def test_dict_based_titled_single_select():
- """Test dict-based titled single-select enum."""
- mcp = FastMCP("TestServer")
-
- @mcp.tool
- async def my_tool(ctx: Context) -> str:
- result = await ctx.elicit(
- "Choose priority",
- response_type={
- "low": {"title": "Low Priority"},
- "high": {"title": "High Priority"},
- },
- )
- if result.action == "accept":
- assert isinstance(result, AcceptedElicitation)
- assert isinstance(result.data, str)
- return result.data
- return "declined"
-
- async def elicitation_handler(message, response_type, params, ctx):
- # Verify schema follows SEP-1330 pattern with type: "string"
- schema = params.requestedSchema
- assert schema["type"] == "object"
- assert "value" in schema["properties"]
- value_schema = schema["properties"]["value"]
- assert value_schema["type"] == "string"
- assert "oneOf" in value_schema
- one_of = value_schema["oneOf"]
- assert {"const": "low", "title": "Low Priority"} in one_of
- assert {"const": "high", "title": "High Priority"} in one_of
-
- return ElicitResult(action="accept", content={"value": "low"})
-
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
- result = await client.call_tool("my_tool", {})
- assert result.data == "low"
-
-
-async def test_list_list_multi_select_untitled():
- """Test list[list[str]] for multi-select untitled shorthand."""
- mcp = FastMCP("TestServer")
-
- @mcp.tool
- async def my_tool(ctx: Context) -> str:
- result = await ctx.elicit(
- "Choose tags",
- response_type=[["bug", "feature", "documentation"]],
- )
- if result.action == "accept":
- assert isinstance(result, AcceptedElicitation)
- assert isinstance(result.data, list)
- return ",".join(result.data) # type: ignore[no-matching-overload]
- return "declined"
-
- async def elicitation_handler(message, response_type, params, ctx):
- # Verify schema has array with enum pattern
- schema = params.requestedSchema
- assert schema["type"] == "object"
- assert "value" in schema["properties"]
- value_schema = schema["properties"]["value"]
- assert value_schema["type"] == "array"
- assert "enum" in value_schema["items"]
- assert value_schema["items"]["enum"] == ["bug", "feature", "documentation"]
-
- return ElicitResult(action="accept", content={"value": ["bug", "feature"]})
-
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
- result = await client.call_tool("my_tool", {})
- assert result.data == "bug,feature"
-
-
-async def test_list_dict_multi_select_titled():
- """Test list[dict] for multi-select titled."""
- mcp = FastMCP("TestServer")
-
- @mcp.tool
- async def my_tool(ctx: Context) -> str:
- result = await ctx.elicit(
- "Choose priorities",
- response_type=[
- {
- "low": {"title": "Low Priority"},
- "high": {"title": "High Priority"},
- }
- ],
- )
- if result.action == "accept":
- assert isinstance(result, AcceptedElicitation)
- assert isinstance(result.data, list)
- return ",".join(result.data) # type: ignore[no-matching-overload]
- return "declined"
-
- async def elicitation_handler(message, response_type, params, ctx):
- # Verify schema has array with SEP-1330 compliant items (anyOf pattern)
- schema = params.requestedSchema
- assert schema["type"] == "object"
- assert "value" in schema["properties"]
- value_schema = schema["properties"]["value"]
- assert value_schema["type"] == "array"
- items_schema = value_schema["items"]
- assert "anyOf" in items_schema
- any_of = items_schema["anyOf"]
- assert {"const": "low", "title": "Low Priority"} in any_of
- assert {"const": "high", "title": "High Priority"} in any_of
-
- return ElicitResult(action="accept", content={"value": ["low", "high"]})
-
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
- result = await client.call_tool("my_tool", {})
- assert result.data == "low,high"
-
-
-async def test_list_enum_multi_select():
- """Test list[Enum] for multi-select with enum in dataclass field."""
-
- class Priority(Enum):
- LOW = "low"
- MEDIUM = "medium"
- HIGH = "high"
-
- @dataclass
- class TaskRequest:
- priorities: list[Priority]
-
- schema = get_elicitation_schema(TaskRequest)
-
- priorities_schema = schema["properties"]["priorities"]
- assert priorities_schema["type"] == "array"
- assert "items" in priorities_schema
- items_schema = priorities_schema["items"]
- # Should have enum pattern for untitled enums
- assert "enum" in items_schema
- assert items_schema["enum"] == ["low", "medium", "high"]
-
-
-async def test_list_enum_multi_select_direct():
- """Test list[Enum] type annotation passed directly to ctx.elicit()."""
- mcp = FastMCP("TestServer")
-
- class Priority(Enum):
- LOW = "low"
- MEDIUM = "medium"
- HIGH = "high"
-
- @mcp.tool
- async def my_tool(ctx: Context) -> str:
- result = await ctx.elicit(
- "Choose priorities",
- response_type=list[Priority], # Type annotation for multi-select
- )
- if result.action == "accept":
- assert isinstance(result, AcceptedElicitation)
- assert isinstance(result.data, list)
- priorities = result.data
- return ",".join(
- [p.value if isinstance(p, Priority) else str(p) for p in priorities]
- )
- return "declined"
-
- async def elicitation_handler(message, response_type, params, ctx):
- # Verify schema has array with enum pattern
- schema = params.requestedSchema
- assert schema["type"] == "object"
- assert "value" in schema["properties"]
- value_schema = schema["properties"]["value"]
- assert value_schema["type"] == "array"
- assert "enum" in value_schema["items"]
- assert value_schema["items"]["enum"] == ["low", "medium", "high"]
-
- return ElicitResult(action="accept", content={"value": ["low", "high"]})
-
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
- result = await client.call_tool("my_tool", {})
- assert result.data == "low,high"
-
-
-async def test_validation_allows_enum_arrays():
- """Test validation accepts arrays with enum items."""
- schema = {
- "type": "object",
- "properties": {
- "priorities": {
- "type": "array",
- "items": {"enum": ["low", "medium", "high"]},
- }
- },
- }
- validate_elicitation_json_schema(schema) # Should not raise
-
-
-async def test_validation_allows_enum_arrays_with_anyof():
- """Test validation accepts arrays with anyOf enum pattern (SEP-1330 compliant)."""
- schema = {
- "type": "object",
- "properties": {
- "priorities": {
- "type": "array",
- "items": {
- "anyOf": [
- {"const": "low", "title": "Low Priority"},
- {"const": "high", "title": "High Priority"},
- ]
- },
- }
- },
- }
- validate_elicitation_json_schema(schema) # Should not raise
-
-
-async def test_validation_rejects_non_enum_arrays():
- """Test validation still rejects arrays of objects."""
- schema = {
- "type": "object",
- "properties": {
- "users": {
- "type": "array",
- "items": {"type": "object", "properties": {"name": {"type": "string"}}},
- }
- },
- }
- with pytest.raises(TypeError, match="array of objects"):
- validate_elicitation_json_schema(schema)
-
-
-async def test_validation_rejects_primitive_arrays():
- """Test validation rejects arrays of primitives without enum pattern."""
- schema = {
- "type": "object",
- "properties": {
- "names": {"type": "array", "items": {"type": "string"}},
- },
- }
- with pytest.raises(TypeError, match="arrays are only allowed"):
- validate_elicitation_json_schema(schema)
-
-
-class TestElicitationDefaults:
- """Test suite for default values in elicitation schemas."""
-
- def test_string_default_preserved(self):
- """Test that string defaults are preserved in the schema."""
-
- class Model(BaseModel):
- email: str = Field(default="[email protected]")
-
- schema = get_elicitation_schema(Model)
- props = schema.get("properties", {})
-
- assert "email" in props
- assert "default" in props["email"]
- assert props["email"]["default"] == "[email protected]"
- assert props["email"]["type"] == "string"
-
- def test_integer_default_preserved(self):
- """Test that integer defaults are preserved in the schema."""
-
- class Model(BaseModel):
- count: int = Field(default=50)
-
- schema = get_elicitation_schema(Model)
- props = schema.get("properties", {})
-
- assert "count" in props
- assert "default" in props["count"]
- assert props["count"]["default"] == 50
- assert props["count"]["type"] == "integer"
-
- def test_number_default_preserved(self):
- """Test that number defaults are preserved in the schema."""
-
- class Model(BaseModel):
- price: float = Field(default=3.14)
-
- schema = get_elicitation_schema(Model)
- props = schema.get("properties", {})
-
- assert "price" in props
- assert "default" in props["price"]
- assert props["price"]["default"] == 3.14
- assert props["price"]["type"] == "number"
-
- def test_boolean_default_preserved(self):
- """Test that boolean defaults are preserved in the schema."""
-
- class Model(BaseModel):
- enabled: bool = Field(default=False)
-
- schema = get_elicitation_schema(Model)
- props = schema.get("properties", {})
-
- assert "enabled" in props
- assert "default" in props["enabled"]
- assert props["enabled"]["default"] is False
- assert props["enabled"]["type"] == "boolean"
-
- def test_enum_default_preserved(self):
- """Test that enum defaults are preserved in the schema."""
-
- class Priority(Enum):
- LOW = "low"
- MEDIUM = "medium"
- HIGH = "high"
-
- class Model(BaseModel):
- choice: Priority = Field(default=Priority.MEDIUM)
-
- schema = get_elicitation_schema(Model)
- props = schema.get("properties", {})
-
- assert "choice" in props
- assert "default" in props["choice"]
- assert props["choice"]["default"] == "medium"
- assert "enum" in props["choice"]
- assert props["choice"]["type"] == "string"
-
- def test_all_defaults_preserved_together(self):
- """Test that all default types are preserved when used together."""
-
- class Priority(Enum):
- A = "A"
- B = "B"
-
- class Model(BaseModel):
- string_field: str = Field(default="[email protected]")
- integer_field: int = Field(default=50)
- number_field: float = Field(default=3.14)
- boolean_field: bool = Field(default=False)
- enum_field: Priority = Field(default=Priority.A)
-
- schema = get_elicitation_schema(Model)
- props = schema.get("properties", {})
-
- assert props["string_field"]["default"] == "[email protected]"
- assert props["integer_field"]["default"] == 50
- assert props["number_field"]["default"] == 3.14
- assert props["boolean_field"]["default"] is False
- assert props["enum_field"]["default"] == "A"
-
- def test_mixed_defaults_and_required(self):
- """Test that fields with defaults are not in required list."""
-
- class Model(BaseModel):
- required_field: str = Field(description="Required field")
- optional_with_default: int = Field(default=42)
-
- schema = get_elicitation_schema(Model)
- props = schema.get("properties", {})
- required = schema.get("required", [])
-
- assert "required_field" in required
- assert "optional_with_default" not in required
- assert props["optional_with_default"]["default"] == 42
-
- def test_compress_schema_preserves_defaults(self):
- """Test that compress_schema() doesn't strip default values."""
-
- class Model(BaseModel):
- string_field: str = Field(default="test")
- integer_field: int = Field(default=42)
-
- schema = get_elicitation_schema(Model)
- props = schema.get("properties", {})
-
- assert "default" in props["string_field"]
- assert "default" in props["integer_field"]
diff --git a/tests/client/test_elicitation_enums.py b/tests/client/test_elicitation_enums.py
new file mode 100644
index 000000000..d67e2b4f1
--- /dev/null
+++ b/tests/client/test_elicitation_enums.py
@@ -0,0 +1,516 @@
+"""Tests for enum-based elicitation, multi-select, and default values."""
+
+from dataclasses import dataclass
+from enum import Enum
+
+import pytest
+from pydantic import BaseModel, Field
+
+from fastmcp import Context, FastMCP
+from fastmcp.client.client import Client
+from fastmcp.client.elicitation import ElicitResult
+from fastmcp.exceptions import ToolError
+from fastmcp.server.elicitation import (
+ AcceptedElicitation,
+ get_elicitation_schema,
+ validate_elicitation_json_schema,
+)
+
+
+@pytest.fixture
+def fastmcp_server():
+ mcp = FastMCP("TestServer")
+
+ @dataclass
+ class Person:
+ name: str
+
+ @mcp.tool
+ async def ask_for_name(context: Context) -> str:
+ result = await context.elicit(
+ message="What is your name?",
+ response_type=Person,
+ )
+ if result.action == "accept":
+ assert isinstance(result, AcceptedElicitation)
+ assert isinstance(result.data, Person)
+ return f"Hello, {result.data.name}!"
+ else:
+ return "No name provided."
+
+ @mcp.tool
+ def simple_test() -> str:
+ return "Hello!"
+
+ return mcp
+
+
+async def test_elicitation_implicit_acceptance(fastmcp_server):
+ """Test that elicitation handler can return data directly without ElicitResult wrapper."""
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ # Return data directly without wrapping in ElicitResult
+ # This should be treated as implicit acceptance
+ return response_type(name="Bob")
+
+ async with Client(
+ fastmcp_server, elicitation_handler=elicitation_handler
+ ) as client:
+ result = await client.call_tool("ask_for_name")
+ assert result.data == "Hello, Bob!"
+
+
+async def test_elicitation_implicit_acceptance_must_be_dict(fastmcp_server):
+ """Test that elicitation handler can return data directly without ElicitResult wrapper."""
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ # Return data directly without wrapping in ElicitResult
+ # This should be treated as implicit acceptance
+ return "Bob"
+
+ async with Client(
+ fastmcp_server, elicitation_handler=elicitation_handler
+ ) as client:
+ with pytest.raises(
+ ToolError,
+ match="Elicitation responses must be serializable as a JSON object",
+ ):
+ await client.call_tool("ask_for_name")
+
+
+def test_enum_elicitation_schema_inline():
+ """Test that enum schemas are generated inline without $ref/$defs for MCP compatibility."""
+
+ class Priority(Enum):
+ LOW = "low"
+ MEDIUM = "medium"
+ HIGH = "high"
+
+ @dataclass
+ class TaskRequest:
+ title: str
+ priority: Priority
+
+ # Generate elicitation schema
+ schema = get_elicitation_schema(TaskRequest)
+
+ # Verify no $defs section exists (enums should be inlined)
+ assert "$defs" not in schema, (
+ "Schema should not contain $defs - enums must be inline"
+ )
+
+ # Verify no $ref in properties
+ for prop_name, prop_schema in schema.get("properties", {}).items():
+ assert "$ref" not in prop_schema, (
+ f"Property {prop_name} contains $ref - should be inline"
+ )
+
+ # Verify the priority field has inline enum values
+ priority_schema = schema["properties"]["priority"]
+ assert "enum" in priority_schema, "Priority should have enum values inline"
+ assert priority_schema["enum"] == ["low", "medium", "high"]
+ assert priority_schema.get("type") == "string"
+
+ # Verify title field is a simple string
+ assert schema["properties"]["title"]["type"] == "string"
+
+
+def test_enum_elicitation_schema_inline_untitled():
+ """Test that enum schemas generate simple enum pattern (no automatic titles)."""
+
+ class TaskStatus(Enum):
+ NOT_STARTED = "not_started"
+ IN_PROGRESS = "in_progress"
+ COMPLETED = "completed"
+ ON_HOLD = "on_hold"
+
+ @dataclass
+ class TaskUpdate:
+ task_id: str
+ status: TaskStatus
+
+ # Generate elicitation schema
+ schema = get_elicitation_schema(TaskUpdate)
+
+ # Verify enum is inline
+ assert "$defs" not in schema
+ assert "$ref" not in str(schema)
+
+ status_schema = schema["properties"]["status"]
+ # Should generate simple enum pattern (no automatic title generation)
+ assert "enum" in status_schema
+ assert "oneOf" not in status_schema
+ assert "enumNames" not in status_schema
+ assert status_schema["enum"] == [
+ "not_started",
+ "in_progress",
+ "completed",
+ "on_hold",
+ ]
+
+
+async def test_dict_based_titled_single_select():
+ """Test dict-based titled single-select enum."""
+ mcp = FastMCP("TestServer")
+
+ @mcp.tool
+ async def my_tool(ctx: Context) -> str:
+ result = await ctx.elicit(
+ "Choose priority",
+ response_type={
+ "low": {"title": "Low Priority"},
+ "high": {"title": "High Priority"},
+ },
+ )
+ if result.action == "accept":
+ assert isinstance(result, AcceptedElicitation)
+ assert isinstance(result.data, str)
+ return result.data
+ return "declined"
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ # Verify schema follows SEP-1330 pattern with type: "string"
+ schema = params.requestedSchema
+ assert schema["type"] == "object"
+ assert "value" in schema["properties"]
+ value_schema = schema["properties"]["value"]
+ assert value_schema["type"] == "string"
+ assert "oneOf" in value_schema
+ one_of = value_schema["oneOf"]
+ assert {"const": "low", "title": "Low Priority"} in one_of
+ assert {"const": "high", "title": "High Priority"} in one_of
+
+ return ElicitResult(action="accept", content={"value": "low"})
+
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ result = await client.call_tool("my_tool", {})
+ assert result.data == "low"
+
+
+async def test_list_list_multi_select_untitled():
+ """Test list[list[str]] for multi-select untitled shorthand."""
+ mcp = FastMCP("TestServer")
+
+ @mcp.tool
+ async def my_tool(ctx: Context) -> str:
+ result = await ctx.elicit(
+ "Choose tags",
+ response_type=[["bug", "feature", "documentation"]],
+ )
+ if result.action == "accept":
+ assert isinstance(result, AcceptedElicitation)
+ assert isinstance(result.data, list)
+ return ",".join(result.data) # type: ignore[no-matching-overload]
+ return "declined"
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ # Verify schema has array with enum pattern
+ schema = params.requestedSchema
+ assert schema["type"] == "object"
+ assert "value" in schema["properties"]
+ value_schema = schema["properties"]["value"]
+ assert value_schema["type"] == "array"
+ assert "enum" in value_schema["items"]
+ assert value_schema["items"]["enum"] == ["bug", "feature", "documentation"]
+
+ return ElicitResult(action="accept", content={"value": ["bug", "feature"]})
+
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ result = await client.call_tool("my_tool", {})
+ assert result.data == "bug,feature"
+
+
+async def test_list_dict_multi_select_titled():
+ """Test list[dict] for multi-select titled."""
+ mcp = FastMCP("TestServer")
+
+ @mcp.tool
+ async def my_tool(ctx: Context) -> str:
+ result = await ctx.elicit(
+ "Choose priorities",
+ response_type=[
+ {
+ "low": {"title": "Low Priority"},
+ "high": {"title": "High Priority"},
+ }
+ ],
+ )
+ if result.action == "accept":
+ assert isinstance(result, AcceptedElicitation)
+ assert isinstance(result.data, list)
+ return ",".join(result.data) # type: ignore[no-matching-overload]
+ return "declined"
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ # Verify schema has array with SEP-1330 compliant items (anyOf pattern)
+ schema = params.requestedSchema
+ assert schema["type"] == "object"
+ assert "value" in schema["properties"]
+ value_schema = schema["properties"]["value"]
+ assert value_schema["type"] == "array"
+ items_schema = value_schema["items"]
+ assert "anyOf" in items_schema
+ any_of = items_schema["anyOf"]
+ assert {"const": "low", "title": "Low Priority"} in any_of
+ assert {"const": "high", "title": "High Priority"} in any_of
+
+ return ElicitResult(action="accept", content={"value": ["low", "high"]})
+
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ result = await client.call_tool("my_tool", {})
+ assert result.data == "low,high"
+
+
+async def test_list_enum_multi_select():
+ """Test list[Enum] for multi-select with enum in dataclass field."""
+
+ class Priority(Enum):
+ LOW = "low"
+ MEDIUM = "medium"
+ HIGH = "high"
+
+ @dataclass
+ class TaskRequest:
+ priorities: list[Priority]
+
+ schema = get_elicitation_schema(TaskRequest)
+
+ priorities_schema = schema["properties"]["priorities"]
+ assert priorities_schema["type"] == "array"
+ assert "items" in priorities_schema
+ items_schema = priorities_schema["items"]
+ # Should have enum pattern for untitled enums
+ assert "enum" in items_schema
+ assert items_schema["enum"] == ["low", "medium", "high"]
+
+
+async def test_list_enum_multi_select_direct():
+ """Test list[Enum] type annotation passed directly to ctx.elicit()."""
+ mcp = FastMCP("TestServer")
+
+ class Priority(Enum):
+ LOW = "low"
+ MEDIUM = "medium"
+ HIGH = "high"
+
+ @mcp.tool
+ async def my_tool(ctx: Context) -> str:
+ result = await ctx.elicit(
+ "Choose priorities",
+ response_type=list[Priority], # Type annotation for multi-select
+ )
+ if result.action == "accept":
+ assert isinstance(result, AcceptedElicitation)
+ assert isinstance(result.data, list)
+ priorities = result.data
+ return ",".join(
+ [p.value if isinstance(p, Priority) else str(p) for p in priorities]
+ )
+ return "declined"
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ # Verify schema has array with enum pattern
+ schema = params.requestedSchema
+ assert schema["type"] == "object"
+ assert "value" in schema["properties"]
+ value_schema = schema["properties"]["value"]
+ assert value_schema["type"] == "array"
+ assert "enum" in value_schema["items"]
+ assert value_schema["items"]["enum"] == ["low", "medium", "high"]
+
+ return ElicitResult(action="accept", content={"value": ["low", "high"]})
+
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ result = await client.call_tool("my_tool", {})
+ assert result.data == "low,high"
+
+
+async def test_validation_allows_enum_arrays():
+ """Test validation accepts arrays with enum items."""
+ schema = {
+ "type": "object",
+ "properties": {
+ "priorities": {
+ "type": "array",
+ "items": {"enum": ["low", "medium", "high"]},
+ }
+ },
+ }
+ validate_elicitation_json_schema(schema) # Should not raise
+
+
+async def test_validation_allows_enum_arrays_with_anyof():
+ """Test validation accepts arrays with anyOf enum pattern (SEP-1330 compliant)."""
+ schema = {
+ "type": "object",
+ "properties": {
+ "priorities": {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {"const": "low", "title": "Low Priority"},
+ {"const": "high", "title": "High Priority"},
+ ]
+ },
+ }
+ },
+ }
+ validate_elicitation_json_schema(schema) # Should not raise
+
+
+async def test_validation_rejects_non_enum_arrays():
+ """Test validation still rejects arrays of objects."""
+ schema = {
+ "type": "object",
+ "properties": {
+ "users": {
+ "type": "array",
+ "items": {"type": "object", "properties": {"name": {"type": "string"}}},
+ }
+ },
+ }
+ with pytest.raises(TypeError, match="array of objects"):
+ validate_elicitation_json_schema(schema)
+
+
+async def test_validation_rejects_primitive_arrays():
+ """Test validation rejects arrays of primitives without enum pattern."""
+ schema = {
+ "type": "object",
+ "properties": {
+ "names": {"type": "array", "items": {"type": "string"}},
+ },
+ }
+ with pytest.raises(TypeError, match="arrays are only allowed"):
+ validate_elicitation_json_schema(schema)
+
+
+class TestElicitationDefaults:
+ """Test suite for default values in elicitation schemas."""
+
+ def test_string_default_preserved(self):
+ """Test that string defaults are preserved in the schema."""
+
+ class Model(BaseModel):
+ email: str = Field(default="[email protected]")
+
+ schema = get_elicitation_schema(Model)
+ props = schema.get("properties", {})
+
+ assert "email" in props
+ assert "default" in props["email"]
+ assert props["email"]["default"] == "[email protected]"
+ assert props["email"]["type"] == "string"
+
+ def test_integer_default_preserved(self):
+ """Test that integer defaults are preserved in the schema."""
+
+ class Model(BaseModel):
+ count: int = Field(default=50)
+
+ schema = get_elicitation_schema(Model)
+ props = schema.get("properties", {})
+
+ assert "count" in props
+ assert "default" in props["count"]
+ assert props["count"]["default"] == 50
+ assert props["count"]["type"] == "integer"
+
+ def test_number_default_preserved(self):
+ """Test that number defaults are preserved in the schema."""
+
+ class Model(BaseModel):
+ price: float = Field(default=3.14)
+
+ schema = get_elicitation_schema(Model)
+ props = schema.get("properties", {})
+
+ assert "price" in props
+ assert "default" in props["price"]
+ assert props["price"]["default"] == 3.14
+ assert props["price"]["type"] == "number"
+
+ def test_boolean_default_preserved(self):
+ """Test that boolean defaults are preserved in the schema."""
+
+ class Model(BaseModel):
+ enabled: bool = Field(default=False)
+
+ schema = get_elicitation_schema(Model)
+ props = schema.get("properties", {})
+
+ assert "enabled" in props
+ assert "default" in props["enabled"]
+ assert props["enabled"]["default"] is False
+ assert props["enabled"]["type"] == "boolean"
+
+ def test_enum_default_preserved(self):
+ """Test that enum defaults are preserved in the schema."""
+
+ class Priority(Enum):
+ LOW = "low"
+ MEDIUM = "medium"
+ HIGH = "high"
+
+ class Model(BaseModel):
+ choice: Priority = Field(default=Priority.MEDIUM)
+
+ schema = get_elicitation_schema(Model)
+ props = schema.get("properties", {})
+
+ assert "choice" in props
+ assert "default" in props["choice"]
+ assert props["choice"]["default"] == "medium"
+ assert "enum" in props["choice"]
+ assert props["choice"]["type"] == "string"
+
+ def test_all_defaults_preserved_together(self):
+ """Test that all default types are preserved when used together."""
+
+ class Priority(Enum):
+ A = "A"
+ B = "B"
+
+ class Model(BaseModel):
+ string_field: str = Field(default="[email protected]")
+ integer_field: int = Field(default=50)
+ number_field: float = Field(default=3.14)
+ boolean_field: bool = Field(default=False)
+ enum_field: Priority = Field(default=Priority.A)
+
+ schema = get_elicitation_schema(Model)
+ props = schema.get("properties", {})
+
+ assert props["string_field"]["default"] == "[email protected]"
+ assert props["integer_field"]["default"] == 50
+ assert props["number_field"]["default"] == 3.14
+ assert props["boolean_field"]["default"] is False
+ assert props["enum_field"]["default"] == "A"
+
+ def test_mixed_defaults_and_required(self):
+ """Test that fields with defaults are not in required list."""
+
+ class Model(BaseModel):
+ required_field: str = Field(description="Required field")
+ optional_with_default: int = Field(default=42)
+
+ schema = get_elicitation_schema(Model)
+ props = schema.get("properties", {})
+ required = schema.get("required", [])
+
+ assert "required_field" in required
+ assert "optional_with_default" not in required
+ assert props["optional_with_default"]["default"] == 42
+
+ def test_compress_schema_preserves_defaults(self):
+ """Test that compress_schema() doesn't strip default values."""
+
+ class Model(BaseModel):
+ string_field: str = Field(default="test")
+ integer_field: int = Field(default=42)
+
+ schema = get_elicitation_schema(Model)
+ props = schema.get("properties", {})
+
+ assert "default" in props["string_field"]
+ assert "default" in props["integer_field"]
diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py
index e5a45adc7..b23276778 100644
--- a/tests/client/test_sampling.py
+++ b/tests/client/test_sampling.py
@@ -174,6 +174,64 @@ async def test_sampling_with_image(fastmcp_server: FastMCP):
]
+class TestSamplingDefaultCapabilities:
+ """Tests for default sampling capability advertisement (issue #3329)."""
+
+ async def test_default_sampling_capabilities_omit_tools(self):
+ """Default sampling capabilities should not include tools field.
+
+ When serialized with exclude_none=True (as the MCP session does),
+ the capability should produce {"sampling": {}} rather than
+ {"sampling": {"tools": {}}}, ensuring compatibility with servers
+ that don't recognize the tools sub-field (e.g. older Java MCP SDK).
+ """
+ import mcp.types as mcp_types
+
+ server = FastMCP()
+
+ def handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> str:
+ return "ok"
+
+ client = Client(server, sampling_handler=handler)
+ caps = client._session_kwargs["sampling_capabilities"]
+ assert isinstance(caps, mcp_types.SamplingCapability)
+ assert caps.tools is None
+
+ async def test_set_sampling_callback_default_capabilities_omit_tools(self):
+ """set_sampling_callback should also default to no tools capability."""
+ import mcp.types as mcp_types
+
+ server = FastMCP()
+ client = Client(server)
+ client.set_sampling_callback(lambda msgs, params, ctx: "ok")
+ caps = client._session_kwargs["sampling_capabilities"]
+ assert isinstance(caps, mcp_types.SamplingCapability)
+ assert caps.tools is None
+
+ async def test_explicit_tools_capability_is_preserved(self):
+ """Explicitly passing tools capability should be respected."""
+ import mcp.types as mcp_types
+
+ server = FastMCP()
+
+ def handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> str:
+ return "ok"
+
+ explicit_caps = mcp_types.SamplingCapability(
+ tools=mcp_types.SamplingToolsCapability()
+ )
+ client = Client(
+ server, sampling_handler=handler, sampling_capabilities=explicit_caps
+ )
+ caps = client._session_kwargs["sampling_capabilities"]
+ assert isinstance(caps, mcp_types.SamplingCapability)
+ assert caps.tools is not None
+
+
class TestSamplingWithTools:
"""Tests for sampling with tools functionality."""
@@ -286,1203 +344,3 @@ class TestSamplingWithTools:
assert "auto" in choices
assert "required" in choices
assert "none" in choices
-
-
-class TestAutomaticToolLoop:
- """Tests for automatic tool execution loop in ctx.sample()."""
-
- async def test_automatic_tool_loop_executes_tools(self):
- """Test that ctx.sample() automatically executes tool calls."""
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
-
- call_count = 0
- tool_was_called = False
-
- def get_weather(city: str) -> str:
- """Get weather for a city."""
- nonlocal tool_was_called
- tool_was_called = True
- return f"Weather in {city}: sunny, 72Β°F"
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- # First call: return tool use
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="get_weather",
- input={"city": "Seattle"},
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- # Second call: return final response
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="The weather is sunny!")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def weather_assistant(question: str, context: Context) -> str:
- result = await context.sample(
- messages=question,
- tools=[get_weather],
- )
- # Get text from SamplingResult
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool(
- "weather_assistant", {"question": "What's the weather?"}
- )
-
- assert tool_was_called
- assert call_count == 2
- assert result.data == "The weather is sunny!"
-
- async def test_automatic_tool_loop_multiple_tools(self):
- """Test that multiple tool calls in one response are all executed."""
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
-
- executed_tools: list[str] = []
-
- def tool_a(x: int) -> int:
- """Tool A."""
- executed_tools.append(f"tool_a({x})")
- return x * 2
-
- def tool_b(y: int) -> int:
- """Tool B."""
- executed_tools.append(f"tool_b({y})")
- return y + 10
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- # Return multiple tool calls
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use", id="call_a", name="tool_a", input={"x": 5}
- ),
- ToolUseContent(
- type="tool_use", id="call_b", name="tool_b", input={"y": 3}
- ),
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def multi_tool(context: Context) -> str:
- result = await context.sample(messages="Run tools", tools=[tool_a, tool_b])
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("multi_tool", {})
-
- assert executed_tools == ["tool_a(5)", "tool_b(3)"]
- assert result.data == "Done!"
-
- async def test_automatic_tool_loop_handles_unknown_tool(self):
- """Test that unknown tool names result in error being passed to LLM."""
- from mcp.types import (
- CreateMessageResultWithTools,
- ToolResultContent,
- ToolUseContent,
- )
-
- def known_tool() -> str:
- """A known tool."""
- return "known result"
-
- messages_received: list[list[SamplingMessage]] = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- messages_received.append(list(messages))
-
- if len(messages_received) == 1:
- # Request unknown tool
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="unknown_tool",
- input={},
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Handled error")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_unknown(context: Context) -> str:
- result = await context.sample(messages="Test", tools=[known_tool])
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_unknown", {})
-
- # Check that error was passed back in messages
- assert len(messages_received) == 2
- last_messages = messages_received[1]
- # Find the tool result in list content
- tool_result = None
- for msg in last_messages:
- # Tool results are now in a list
- if isinstance(msg.content, list):
- for item in msg.content:
- if isinstance(item, ToolResultContent):
- tool_result = item
- break
- elif isinstance(msg.content, ToolResultContent):
- tool_result = msg.content
- break
- assert tool_result is not None
- assert tool_result.isError is True
- # Content is list of TextContent objects
- assert isinstance(tool_result.content[0], TextContent)
- error_text = tool_result.content[0].text
- assert "Unknown tool" in error_text
- assert result.data == "Handled error"
-
- async def test_automatic_tool_loop_handles_tool_exception(self):
- """Test that tool exceptions are caught and passed to LLM as errors."""
- from mcp.types import (
- CreateMessageResultWithTools,
- ToolResultContent,
- ToolUseContent,
- )
-
- def failing_tool() -> str:
- """A tool that raises an exception."""
- raise ValueError("Tool failed intentionally")
-
- messages_received: list[list[SamplingMessage]] = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- messages_received.append(list(messages))
-
- if len(messages_received) == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="failing_tool",
- input={},
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Handled error")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_exception(context: Context) -> str:
- result = await context.sample(messages="Test", tools=[failing_tool])
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_exception", {})
-
- # Check that error was passed back
- assert len(messages_received) == 2
- last_messages = messages_received[1]
- # Find the tool result in list content
- tool_result = None
- for msg in last_messages:
- # Tool results are now in a list
- if isinstance(msg.content, list):
- for item in msg.content:
- if isinstance(item, ToolResultContent):
- tool_result = item
- break
- elif isinstance(msg.content, ToolResultContent):
- tool_result = msg.content
- break
- assert tool_result is not None
- assert tool_result.isError is True
- # Content is list of TextContent objects
- assert isinstance(tool_result.content[0], TextContent)
- error_text = tool_result.content[0].text
- assert "Tool failed intentionally" in error_text
- assert result.data == "Handled error"
-
- async def test_concurrent_tool_execution_default_sequential(self):
- """Test that tools execute sequentially by default."""
- import asyncio
- import time
-
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
-
- execution_order: list[tuple[str, float]] = []
-
- async def slow_tool_a(x: int) -> int:
- """Slow tool A."""
- start = time.time()
- execution_order.append(("tool_a_start", start))
- await asyncio.sleep(0.1)
- execution_order.append(("tool_a_end", time.time()))
- return x * 2
-
- async def slow_tool_b(y: int) -> int:
- """Slow tool B."""
- start = time.time()
- execution_order.append(("tool_b_start", start))
- await asyncio.sleep(0.1)
- execution_order.append(("tool_b_end", time.time()))
- return y + 10
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_a",
- name="slow_tool_a",
- input={"x": 5},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_b",
- name="slow_tool_b",
- input={"y": 3},
- ),
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- result = await context.sample(
- messages="Run tools",
- tools=[slow_tool_a, slow_tool_b],
- # Default: tool_concurrency=None (sequential)
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Done!"
- # Verify sequential execution: tool_a must complete before tool_b starts
- events = [e[0] for e in execution_order]
- assert events == ["tool_a_start", "tool_a_end", "tool_b_start", "tool_b_end"]
-
- async def test_concurrent_tool_execution_unlimited(self):
- """Test unlimited parallel tool execution with tool_concurrency=0."""
- import asyncio
- import time
-
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
-
- execution_times: dict[str, dict[str, float]] = {}
-
- async def slow_tool_a(x: int) -> int:
- """Slow tool A."""
- execution_times["tool_a"] = {"start": time.time()}
- await asyncio.sleep(0.1)
- execution_times["tool_a"]["end"] = time.time()
- return x * 2
-
- async def slow_tool_b(y: int) -> int:
- """Slow tool B."""
- execution_times["tool_b"] = {"start": time.time()}
- await asyncio.sleep(0.1)
- execution_times["tool_b"]["end"] = time.time()
- return y + 10
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_a",
- name="slow_tool_a",
- input={"x": 5},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_b",
- name="slow_tool_b",
- input={"y": 3},
- ),
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- result = await context.sample(
- messages="Run tools",
- tools=[slow_tool_a, slow_tool_b],
- tool_concurrency=0, # Unlimited parallel
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Done!"
- # Verify parallel execution: both tools should overlap in time
- assert "tool_a" in execution_times
- assert "tool_b" in execution_times
- # tool_b should start before tool_a finishes (overlap)
- assert execution_times["tool_b"]["start"] < execution_times["tool_a"]["end"]
-
- async def test_concurrent_tool_execution_bounded(self):
- """Test bounded parallel execution with tool_concurrency=2."""
- import asyncio
- import time
-
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
-
- execution_order: list[tuple[str, float]] = []
-
- async def slow_tool(name: str, duration: float = 0.1) -> str:
- """Generic slow tool."""
- execution_order.append((f"{name}_start", time.time()))
- await asyncio.sleep(duration)
- execution_order.append((f"{name}_end", time.time()))
- return f"{name} done"
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- # Request 3 tools (with concurrency=2, first 2 run parallel, then 3rd)
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="slow_tool",
- input={"name": "tool_1", "duration": 0.1},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_2",
- name="slow_tool",
- input={"name": "tool_2", "duration": 0.1},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_3",
- name="slow_tool",
- input={"name": "tool_3", "duration": 0.05},
- ),
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- result = await context.sample(
- messages="Run tools",
- tools=[slow_tool],
- tool_concurrency=2, # Max 2 concurrent
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Done!"
- # Verify that at most 2 tools run concurrently
- events = [e[0] for e in execution_order]
- # First 2 tools should start before either ends
- assert events[0] in ["tool_1_start", "tool_2_start"]
- assert events[1] in ["tool_1_start", "tool_2_start"]
- # Third tool should start after at least one of the first two finishes
- tool_3_start_idx = events.index("tool_3_start")
- assert (
- "tool_1_end" in events[:tool_3_start_idx]
- or "tool_2_end" in events[:tool_3_start_idx]
- )
-
- async def test_sequential_tool_forces_sequential_execution(self):
- """Test that sequential=True forces all tools to execute sequentially."""
- import asyncio
- import time
-
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
-
- execution_order: list[tuple[str, float]] = []
-
- async def normal_tool(x: int) -> int:
- """Normal tool."""
- execution_order.append(("normal_start", time.time()))
- await asyncio.sleep(0.05)
- execution_order.append(("normal_end", time.time()))
- return x * 2
-
- async def sequential_tool(y: int) -> int:
- """Sequential tool."""
- execution_order.append(("sequential_start", time.time()))
- await asyncio.sleep(0.05)
- execution_order.append(("sequential_end", time.time()))
- return y + 10
-
- call_count = 0
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="normal_tool",
- input={"x": 5},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_2",
- name="sequential_tool",
- input={"y": 3},
- ),
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- # Create tools with sequential=True for one of them
- normal = SamplingTool.from_function(normal_tool, sequential=False)
- sequential = SamplingTool.from_function(sequential_tool, sequential=True)
-
- result = await context.sample(
- messages="Run tools",
- tools=[normal, sequential],
- tool_concurrency=0, # Request unlimited, but sequential tool forces sequential
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Done!"
- # Verify sequential execution: first tool must complete before second starts
- events = [e[0] for e in execution_order]
- assert events[0] in ["normal_start", "sequential_start"]
- assert events[1] in ["normal_end", "sequential_end"]
- # Ensure the second tool starts after the first ends
- if events[0] == "normal_start":
- assert events[1] == "normal_end"
- assert events[2] == "sequential_start"
- else:
- assert events[1] == "sequential_end"
- assert events[2] == "normal_start"
-
- async def test_concurrent_tool_execution_error_handling(self):
- """Test that errors are captured per-tool in parallel execution."""
- from mcp.types import (
- CreateMessageResultWithTools,
- ToolResultContent,
- ToolUseContent,
- )
-
- def good_tool() -> str:
- return "success"
-
- def bad_tool() -> str:
- raise ValueError("Tool error")
-
- messages_received: list[list[SamplingMessage]] = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- messages_received.append(list(messages))
-
- if len(messages_received) == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use", id="call_1", name="good_tool", input={}
- ),
- ToolUseContent(
- type="tool_use", id="call_2", name="bad_tool", input={}
- ),
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Handled errors")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- result = await context.sample(
- messages="Run tools",
- tools=[good_tool, bad_tool],
- tool_concurrency=0, # Parallel execution
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Handled errors"
- # Check that tool results include both success and error
- tool_result_message = messages_received[1][-1]
- assert tool_result_message.role == "user"
- tool_results = cast(list[ToolResultContent], tool_result_message.content)
- assert len(tool_results) == 2
- # One should be success, one should be error
- assert any(not r.isError for r in tool_results)
- assert any(r.isError for r in tool_results)
-
- async def test_concurrent_tool_result_order_preserved(self):
- """Test that tool results maintain the same order as tool calls."""
- import asyncio
-
- from mcp.types import (
- CreateMessageResultWithTools,
- ToolResultContent,
- ToolUseContent,
- )
-
- async def tool_with_delay(value: int, delay: float) -> int:
- """Tool that takes variable time."""
- await asyncio.sleep(delay)
- return value
-
- messages_received: list[list[SamplingMessage]] = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- messages_received.append(list(messages))
-
- if len(messages_received) == 1:
- # Tools with different delays - later tools finish first
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="tool_with_delay",
- input={"value": 1, "delay": 0.15},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_2",
- name="tool_with_delay",
- input={"value": 2, "delay": 0.05},
- ),
- ToolUseContent(
- type="tool_use",
- id="call_3",
- name="tool_with_delay",
- input={"value": 3, "delay": 0.1},
- ),
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done!")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_tool(context: Context) -> str:
- result = await context.sample(
- messages="Run tools",
- tools=[tool_with_delay],
- tool_concurrency=0, # Parallel execution
- )
- return result.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_tool", {})
-
- assert result.data == "Done!"
- # Check that results are in the correct order (1, 2, 3) despite finishing order (2, 3, 1)
- tool_result_message = messages_received[1][-1]
- tool_results = cast(list[ToolResultContent], tool_result_message.content)
- assert len(tool_results) == 3
- assert tool_results[0].toolUseId == "call_1"
- assert tool_results[1].toolUseId == "call_2"
- assert tool_results[2].toolUseId == "call_3"
- # Check values are correct
- result_texts = [cast(TextContent, r.content[0]).text for r in tool_results]
- assert result_texts == ["1", "2", "3"]
-
-
-class TestSamplingResultType:
- """Tests for result_type parameter (structured output)."""
-
- async def test_result_type_creates_final_response_tool(self):
- """Test that result_type creates a synthetic final_response tool."""
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
- from pydantic import BaseModel
-
- class MathResult(BaseModel):
- answer: int
- explanation: str
-
- received_tools: list = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- received_tools.extend(params.tools or [])
-
- # Return the final_response tool call
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="final_response",
- input={"answer": 42, "explanation": "The meaning of life"},
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def math_tool(context: Context) -> str:
- result = await context.sample(
- messages="What is 6 * 7?",
- result_type=MathResult,
- )
- # result.result should be a MathResult object
- assert isinstance(result.result, MathResult)
- return f"{result.result.answer}: {result.result.explanation}"
-
- async with Client(mcp) as client:
- result = await client.call_tool("math_tool", {})
-
- # Check that final_response tool was added
- tool_names = [t.name for t in received_tools]
- assert "final_response" in tool_names
-
- # Check the result
- assert result.data == "42: The meaning of life"
-
- async def test_result_type_with_user_tools(self):
- """Test result_type works alongside user-provided tools."""
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
- from pydantic import BaseModel
-
- class SearchResult(BaseModel):
- summary: str
- sources: list[str]
-
- def search(query: str) -> str:
- """Search for information."""
- return f"Found info about: {query}"
-
- call_count = 0
- tool_was_called = False
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count, tool_was_called
- call_count += 1
-
- if call_count == 1:
- # First call: use the search tool
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="search",
- input={"query": "Python tutorials"},
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- # Second call: call final_response
- tool_was_called = True
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_2",
- name="final_response",
- input={
- "summary": "Python is great",
- "sources": ["python.org", "docs.python.org"],
- },
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def research(context: Context) -> str:
- result = await context.sample(
- messages="Research Python",
- tools=[search],
- result_type=SearchResult,
- )
- assert isinstance(result.result, SearchResult)
- return f"{result.result.summary} - {len(result.result.sources)} sources"
-
- async with Client(mcp) as client:
- result = await client.call_tool("research", {})
-
- assert tool_was_called
- assert result.data == "Python is great - 2 sources"
-
- async def test_result_type_validation_error_retries(self):
- """Test that validation errors are sent back to LLM for retry."""
- from mcp.types import (
- CreateMessageResultWithTools,
- ToolResultContent,
- ToolUseContent,
- )
- from pydantic import BaseModel
-
- class StrictResult(BaseModel):
- value: int # Must be an int
-
- messages_received: list[list[SamplingMessage]] = []
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- messages_received.append(list(messages))
-
- if len(messages_received) == 1:
- # First call: invalid type
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="final_response",
- input={"value": "not_an_int"}, # Wrong type
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- # Second call: valid type after seeing error
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_2",
- name="final_response",
- input={"value": 42}, # Correct type
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def validate_tool(context: Context) -> str:
- result = await context.sample(
- messages="Give me a number",
- result_type=StrictResult,
- )
- assert isinstance(result.result, StrictResult)
- return str(result.result.value)
-
- async with Client(mcp) as client:
- result = await client.call_tool("validate_tool", {})
-
- # Should have retried after validation error
- assert len(messages_received) == 2
-
- # Check that error was passed back
- last_messages = messages_received[1]
- # Find the tool result in list content
- tool_result = None
- for msg in last_messages:
- # Tool results are now in a list
- if isinstance(msg.content, list):
- for item in msg.content:
- if isinstance(item, ToolResultContent):
- tool_result = item
- break
- elif isinstance(msg.content, ToolResultContent):
- tool_result = msg.content
- break
- assert tool_result is not None
- assert tool_result.isError is True
- assert isinstance(tool_result.content[0], TextContent)
- error_text = tool_result.content[0].text
- assert "Validation error" in error_text
-
- # Final result should be correct
- assert result.data == "42"
-
- async def test_sampling_result_has_text_and_history(self):
- """Test that SamplingResult has text, result, and history attributes."""
- from mcp.types import CreateMessageResultWithTools
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Hello world")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def check_result(context: Context) -> str:
- result = await context.sample(messages="Say hello")
- # Check all attributes exist
- assert result.text == "Hello world"
- assert result.result == "Hello world"
- assert len(result.history) >= 1
- return "ok"
-
- async with Client(mcp) as client:
- result = await client.call_tool("check_result", {})
-
- assert result.data == "ok"
-
-
-class TestSampleStep:
- """Tests for ctx.sample_step() - single LLM call with manual control."""
-
- async def test_sample_step_basic(self):
- """Test basic sample_step returns text response."""
- from mcp.types import CreateMessageResultWithTools
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Hello from step")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_step(context: Context) -> str:
- step = await context.sample_step(messages="Hi")
- assert not step.is_tool_use
- assert step.text == "Hello from step"
- return step.text or ""
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_step", {})
-
- assert result.data == "Hello from step"
-
- async def test_sample_step_with_tool_execution(self):
- """Test sample_step executes tools by default."""
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
-
- call_count = 0
-
- def my_tool(x: int) -> str:
- """A test tool."""
- return f"result:{x}"
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- nonlocal call_count
- call_count += 1
-
- if call_count == 1:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="my_tool",
- input={"x": 42},
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
- else:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[TextContent(type="text", text="Done")],
- model="test-model",
- stopReason="endTurn",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_step(context: Context) -> str:
- messages: str | list[SamplingMessage] = "Run tool"
-
- while True:
- step = await context.sample_step(messages=messages, tools=[my_tool])
-
- if not step.is_tool_use:
- return step.text or ""
-
- # History should include tool results when execute_tools=True
- messages = step.history
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_step", {})
-
- assert result.data == "Done"
- assert call_count == 2
-
- async def test_sample_step_execute_tools_false(self):
- """Test sample_step with execute_tools=False doesn't execute tools."""
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
-
- tool_executed = False
-
- def my_tool() -> str:
- """A test tool."""
- nonlocal tool_executed
- tool_executed = True
- return "executed"
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="my_tool",
- input={},
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- @mcp.tool
- async def test_step(context: Context) -> str:
- step = await context.sample_step(
- messages="Run tool",
- tools=[my_tool],
- execute_tools=False,
- )
- assert step.is_tool_use
- assert len(step.tool_calls) == 1
- assert step.tool_calls[0].name == "my_tool"
- # History should include assistant message but no tool results
- assert len(step.history) == 2 # user + assistant
- return "ok"
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_step", {})
-
- assert result.data == "ok"
- assert not tool_executed # Tool should not have been executed
-
- async def test_sample_step_history_includes_assistant_message(self):
- """Test that history includes assistant message when execute_tools=False."""
- from mcp.types import CreateMessageResultWithTools, ToolUseContent
-
- def sampling_handler(
- messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
- ) -> CreateMessageResultWithTools:
- return CreateMessageResultWithTools(
- role="assistant",
- content=[
- ToolUseContent(
- type="tool_use",
- id="call_1",
- name="my_tool",
- input={"query": "test"},
- )
- ],
- model="test-model",
- stopReason="toolUse",
- )
-
- mcp = FastMCP(sampling_handler=sampling_handler)
-
- def my_tool(query: str) -> str:
- return f"result for {query}"
-
- @mcp.tool
- async def test_step(context: Context) -> str:
- step = await context.sample_step(
- messages="Search",
- tools=[my_tool],
- execute_tools=False,
- )
- # History should have: user message + assistant message
- assert len(step.history) == 2
- assert step.history[0].role == "user"
- assert step.history[1].role == "assistant"
- return "ok"
-
- async with Client(mcp) as client:
- result = await client.call_tool("test_step", {})
-
- assert result.data == "ok"
diff --git a/tests/client/test_sampling_result_types.py b/tests/client/test_sampling_result_types.py
new file mode 100644
index 000000000..73d6fbddc
--- /dev/null
+++ b/tests/client/test_sampling_result_types.py
@@ -0,0 +1,442 @@
+from mcp.types import TextContent
+
+from fastmcp import Client, Context, FastMCP
+from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
+
+
+class TestSamplingResultType:
+ """Tests for result_type parameter (structured output)."""
+
+ async def test_result_type_creates_final_response_tool(self):
+ """Test that result_type creates a synthetic final_response tool."""
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+ from pydantic import BaseModel
+
+ class MathResult(BaseModel):
+ answer: int
+ explanation: str
+
+ received_tools: list = []
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ received_tools.extend(params.tools or [])
+
+ # Return the final_response tool call
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="final_response",
+ input={"answer": 42, "explanation": "The meaning of life"},
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def math_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="What is 6 * 7?",
+ result_type=MathResult,
+ )
+ # result.result should be a MathResult object
+ assert isinstance(result.result, MathResult)
+ return f"{result.result.answer}: {result.result.explanation}"
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("math_tool", {})
+
+ # Check that final_response tool was added
+ tool_names = [t.name for t in received_tools]
+ assert "final_response" in tool_names
+
+ # Check the result
+ assert result.data == "42: The meaning of life"
+
+ async def test_result_type_with_user_tools(self):
+ """Test result_type works alongside user-provided tools."""
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+ from pydantic import BaseModel
+
+ class SearchResult(BaseModel):
+ summary: str
+ sources: list[str]
+
+ def search(query: str) -> str:
+ """Search for information."""
+ return f"Found info about: {query}"
+
+ call_count = 0
+ tool_was_called = False
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count, tool_was_called
+ call_count += 1
+
+ if call_count == 1:
+ # First call: use the search tool
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="search",
+ input={"query": "Python tutorials"},
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ # Second call: call final_response
+ tool_was_called = True
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_2",
+ name="final_response",
+ input={
+ "summary": "Python is great",
+ "sources": ["python.org", "docs.python.org"],
+ },
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def research(context: Context) -> str:
+ result = await context.sample(
+ messages="Research Python",
+ tools=[search],
+ result_type=SearchResult,
+ )
+ assert isinstance(result.result, SearchResult)
+ return f"{result.result.summary} - {len(result.result.sources)} sources"
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("research", {})
+
+ assert tool_was_called
+ assert result.data == "Python is great - 2 sources"
+
+ async def test_result_type_validation_error_retries(self):
+ """Test that validation errors are sent back to LLM for retry."""
+ from mcp.types import (
+ CreateMessageResultWithTools,
+ ToolResultContent,
+ ToolUseContent,
+ )
+ from pydantic import BaseModel
+
+ class StrictResult(BaseModel):
+ value: int # Must be an int
+
+ messages_received: list[list[SamplingMessage]] = []
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ messages_received.append(list(messages))
+
+ if len(messages_received) == 1:
+ # First call: invalid type
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="final_response",
+ input={"value": "not_an_int"}, # Wrong type
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ # Second call: valid type after seeing error
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_2",
+ name="final_response",
+ input={"value": 42}, # Correct type
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def validate_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Give me a number",
+ result_type=StrictResult,
+ )
+ assert isinstance(result.result, StrictResult)
+ return str(result.result.value)
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("validate_tool", {})
+
+ # Should have retried after validation error
+ assert len(messages_received) == 2
+
+ # Check that error was passed back
+ last_messages = messages_received[1]
+ # Find the tool result in list content
+ tool_result = None
+ for msg in last_messages:
+ # Tool results are now in a list
+ if isinstance(msg.content, list):
+ for item in msg.content:
+ if isinstance(item, ToolResultContent):
+ tool_result = item
+ break
+ elif isinstance(msg.content, ToolResultContent):
+ tool_result = msg.content
+ break
+ assert tool_result is not None
+ assert tool_result.isError is True
+ assert isinstance(tool_result.content[0], TextContent)
+ error_text = tool_result.content[0].text
+ assert "Validation error" in error_text
+
+ # Final result should be correct
+ assert result.data == "42"
+
+ async def test_sampling_result_has_text_and_history(self):
+ """Test that SamplingResult has text, result, and history attributes."""
+ from mcp.types import CreateMessageResultWithTools
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Hello world")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def check_result(context: Context) -> str:
+ result = await context.sample(messages="Say hello")
+ # Check all attributes exist
+ assert result.text == "Hello world"
+ assert result.result == "Hello world"
+ assert len(result.history) >= 1
+ return "ok"
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("check_result", {})
+
+ assert result.data == "ok"
+
+
+class TestSampleStep:
+ """Tests for ctx.sample_step() - single LLM call with manual control."""
+
+ async def test_sample_step_basic(self):
+ """Test basic sample_step returns text response."""
+ from mcp.types import CreateMessageResultWithTools
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Hello from step")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_step(context: Context) -> str:
+ step = await context.sample_step(messages="Hi")
+ assert not step.is_tool_use
+ assert step.text == "Hello from step"
+ return step.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_step", {})
+
+ assert result.data == "Hello from step"
+
+ async def test_sample_step_with_tool_execution(self):
+ """Test sample_step executes tools by default."""
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ call_count = 0
+
+ def my_tool(x: int) -> str:
+ """A test tool."""
+ return f"result:{x}"
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="my_tool",
+ input={"x": 42},
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_step(context: Context) -> str:
+ messages: str | list[SamplingMessage] = "Run tool"
+
+ while True:
+ step = await context.sample_step(messages=messages, tools=[my_tool])
+
+ if not step.is_tool_use:
+ return step.text or ""
+
+ # History should include tool results when execute_tools=True
+ messages = step.history
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_step", {})
+
+ assert result.data == "Done"
+ assert call_count == 2
+
+ async def test_sample_step_execute_tools_false(self):
+ """Test sample_step with execute_tools=False doesn't execute tools."""
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ tool_executed = False
+
+ def my_tool() -> str:
+ """A test tool."""
+ nonlocal tool_executed
+ tool_executed = True
+ return "executed"
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="my_tool",
+ input={},
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_step(context: Context) -> str:
+ step = await context.sample_step(
+ messages="Run tool",
+ tools=[my_tool],
+ execute_tools=False,
+ )
+ assert step.is_tool_use
+ assert len(step.tool_calls) == 1
+ assert step.tool_calls[0].name == "my_tool"
+ # History should include assistant message but no tool results
+ assert len(step.history) == 2 # user + assistant
+ return "ok"
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_step", {})
+
+ assert result.data == "ok"
+ assert not tool_executed # Tool should not have been executed
+
+ async def test_sample_step_history_includes_assistant_message(self):
+ """Test that history includes assistant message when execute_tools=False."""
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="my_tool",
+ input={"query": "test"},
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ def my_tool(query: str) -> str:
+ return f"result for {query}"
+
+ @mcp.tool
+ async def test_step(context: Context) -> str:
+ step = await context.sample_step(
+ messages="Search",
+ tools=[my_tool],
+ execute_tools=False,
+ )
+ # History should have: user message + assistant message
+ assert len(step.history) == 2
+ assert step.history[0].role == "user"
+ assert step.history[1].role == "assistant"
+ return "ok"
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_step", {})
+
+ assert result.data == "ok"
diff --git a/tests/client/test_sampling_tool_loop.py b/tests/client/test_sampling_tool_loop.py
new file mode 100644
index 000000000..7b3c2e9ad
--- /dev/null
+++ b/tests/client/test_sampling_tool_loop.py
@@ -0,0 +1,769 @@
+from typing import cast
+
+from mcp.types import TextContent
+
+from fastmcp import Client, Context, FastMCP
+from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
+from fastmcp.server.sampling import SamplingTool
+
+
+class TestAutomaticToolLoop:
+ """Tests for automatic tool execution loop in ctx.sample()."""
+
+ async def test_automatic_tool_loop_executes_tools(self):
+ """Test that ctx.sample() automatically executes tool calls."""
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ call_count = 0
+ tool_was_called = False
+
+ def get_weather(city: str) -> str:
+ """Get weather for a city."""
+ nonlocal tool_was_called
+ tool_was_called = True
+ return f"Weather in {city}: sunny, 72Β°F"
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ # First call: return tool use
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="get_weather",
+ input={"city": "Seattle"},
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ # Second call: return final response
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="The weather is sunny!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def weather_assistant(question: str, context: Context) -> str:
+ result = await context.sample(
+ messages=question,
+ tools=[get_weather],
+ )
+ # Get text from SamplingResult
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool(
+ "weather_assistant", {"question": "What's the weather?"}
+ )
+
+ assert tool_was_called
+ assert call_count == 2
+ assert result.data == "The weather is sunny!"
+
+ async def test_automatic_tool_loop_multiple_tools(self):
+ """Test that multiple tool calls in one response are all executed."""
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ executed_tools: list[str] = []
+
+ def tool_a(x: int) -> int:
+ """Tool A."""
+ executed_tools.append(f"tool_a({x})")
+ return x * 2
+
+ def tool_b(y: int) -> int:
+ """Tool B."""
+ executed_tools.append(f"tool_b({y})")
+ return y + 10
+
+ call_count = 0
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ # Return multiple tool calls
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use", id="call_a", name="tool_a", input={"x": 5}
+ ),
+ ToolUseContent(
+ type="tool_use", id="call_b", name="tool_b", input={"y": 3}
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def multi_tool(context: Context) -> str:
+ result = await context.sample(messages="Run tools", tools=[tool_a, tool_b])
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("multi_tool", {})
+
+ assert executed_tools == ["tool_a(5)", "tool_b(3)"]
+ assert result.data == "Done!"
+
+ async def test_automatic_tool_loop_handles_unknown_tool(self):
+ """Test that unknown tool names result in error being passed to LLM."""
+ from mcp.types import (
+ CreateMessageResultWithTools,
+ ToolResultContent,
+ ToolUseContent,
+ )
+
+ def known_tool() -> str:
+ """A known tool."""
+ return "known result"
+
+ messages_received: list[list[SamplingMessage]] = []
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ messages_received.append(list(messages))
+
+ if len(messages_received) == 1:
+ # Request unknown tool
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="unknown_tool",
+ input={},
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Handled error")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_unknown(context: Context) -> str:
+ result = await context.sample(messages="Test", tools=[known_tool])
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_unknown", {})
+
+ # Check that error was passed back in messages
+ assert len(messages_received) == 2
+ last_messages = messages_received[1]
+ # Find the tool result in list content
+ tool_result = None
+ for msg in last_messages:
+ # Tool results are now in a list
+ if isinstance(msg.content, list):
+ for item in msg.content:
+ if isinstance(item, ToolResultContent):
+ tool_result = item
+ break
+ elif isinstance(msg.content, ToolResultContent):
+ tool_result = msg.content
+ break
+ assert tool_result is not None
+ assert tool_result.isError is True
+ # Content is list of TextContent objects
+ assert isinstance(tool_result.content[0], TextContent)
+ error_text = tool_result.content[0].text
+ assert "Unknown tool" in error_text
+ assert result.data == "Handled error"
+
+ async def test_automatic_tool_loop_handles_tool_exception(self):
+ """Test that tool exceptions are caught and passed to LLM as errors."""
+ from mcp.types import (
+ CreateMessageResultWithTools,
+ ToolResultContent,
+ ToolUseContent,
+ )
+
+ def failing_tool() -> str:
+ """A tool that raises an exception."""
+ raise ValueError("Tool failed intentionally")
+
+ messages_received: list[list[SamplingMessage]] = []
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ messages_received.append(list(messages))
+
+ if len(messages_received) == 1:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="failing_tool",
+ input={},
+ )
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Handled error")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_exception(context: Context) -> str:
+ result = await context.sample(messages="Test", tools=[failing_tool])
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_exception", {})
+
+ # Check that error was passed back
+ assert len(messages_received) == 2
+ last_messages = messages_received[1]
+ # Find the tool result in list content
+ tool_result = None
+ for msg in last_messages:
+ # Tool results are now in a list
+ if isinstance(msg.content, list):
+ for item in msg.content:
+ if isinstance(item, ToolResultContent):
+ tool_result = item
+ break
+ elif isinstance(msg.content, ToolResultContent):
+ tool_result = msg.content
+ break
+ assert tool_result is not None
+ assert tool_result.isError is True
+ # Content is list of TextContent objects
+ assert isinstance(tool_result.content[0], TextContent)
+ error_text = tool_result.content[0].text
+ assert "Tool failed intentionally" in error_text
+ assert result.data == "Handled error"
+
+ async def test_concurrent_tool_execution_default_sequential(self):
+ """Test that tools execute sequentially by default."""
+ import asyncio
+ import time
+
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ execution_order: list[tuple[str, float]] = []
+
+ async def slow_tool_a(x: int) -> int:
+ """Slow tool A."""
+ start = time.time()
+ execution_order.append(("tool_a_start", start))
+ await asyncio.sleep(0.1)
+ execution_order.append(("tool_a_end", time.time()))
+ return x * 2
+
+ async def slow_tool_b(y: int) -> int:
+ """Slow tool B."""
+ start = time.time()
+ execution_order.append(("tool_b_start", start))
+ await asyncio.sleep(0.1)
+ execution_order.append(("tool_b_end", time.time()))
+ return y + 10
+
+ call_count = 0
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_a",
+ name="slow_tool_a",
+ input={"x": 5},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_b",
+ name="slow_tool_b",
+ input={"y": 3},
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Run tools",
+ tools=[slow_tool_a, slow_tool_b],
+ # Default: tool_concurrency=None (sequential)
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Done!"
+ # Verify sequential execution: tool_a must complete before tool_b starts
+ events = [e[0] for e in execution_order]
+ assert events == ["tool_a_start", "tool_a_end", "tool_b_start", "tool_b_end"]
+
+ async def test_concurrent_tool_execution_unlimited(self):
+ """Test unlimited parallel tool execution with tool_concurrency=0."""
+ import asyncio
+ import time
+
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ execution_times: dict[str, dict[str, float]] = {}
+
+ async def slow_tool_a(x: int) -> int:
+ """Slow tool A."""
+ execution_times["tool_a"] = {"start": time.time()}
+ await asyncio.sleep(0.1)
+ execution_times["tool_a"]["end"] = time.time()
+ return x * 2
+
+ async def slow_tool_b(y: int) -> int:
+ """Slow tool B."""
+ execution_times["tool_b"] = {"start": time.time()}
+ await asyncio.sleep(0.1)
+ execution_times["tool_b"]["end"] = time.time()
+ return y + 10
+
+ call_count = 0
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_a",
+ name="slow_tool_a",
+ input={"x": 5},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_b",
+ name="slow_tool_b",
+ input={"y": 3},
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Run tools",
+ tools=[slow_tool_a, slow_tool_b],
+ tool_concurrency=0, # Unlimited parallel
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Done!"
+ # Verify parallel execution: both tools should overlap in time
+ assert "tool_a" in execution_times
+ assert "tool_b" in execution_times
+ # tool_b should start before tool_a finishes (overlap)
+ assert execution_times["tool_b"]["start"] < execution_times["tool_a"]["end"]
+
+ async def test_concurrent_tool_execution_bounded(self):
+ """Test bounded parallel execution with tool_concurrency=2."""
+ import asyncio
+ import time
+
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ execution_order: list[tuple[str, float]] = []
+
+ async def slow_tool(name: str, duration: float = 0.1) -> str:
+ """Generic slow tool."""
+ execution_order.append((f"{name}_start", time.time()))
+ await asyncio.sleep(duration)
+ execution_order.append((f"{name}_end", time.time()))
+ return f"{name} done"
+
+ call_count = 0
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ # Request 3 tools (with concurrency=2, first 2 run parallel, then 3rd)
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="slow_tool",
+ input={"name": "tool_1", "duration": 0.1},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_2",
+ name="slow_tool",
+ input={"name": "tool_2", "duration": 0.1},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_3",
+ name="slow_tool",
+ input={"name": "tool_3", "duration": 0.05},
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Run tools",
+ tools=[slow_tool],
+ tool_concurrency=2, # Max 2 concurrent
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Done!"
+ # Verify that at most 2 tools run concurrently
+ events = [e[0] for e in execution_order]
+ # First 2 tools should start before either ends
+ assert events[0] in ["tool_1_start", "tool_2_start"]
+ assert events[1] in ["tool_1_start", "tool_2_start"]
+ # Third tool should start after at least one of the first two finishes
+ tool_3_start_idx = events.index("tool_3_start")
+ assert (
+ "tool_1_end" in events[:tool_3_start_idx]
+ or "tool_2_end" in events[:tool_3_start_idx]
+ )
+
+ async def test_sequential_tool_forces_sequential_execution(self):
+ """Test that sequential=True forces all tools to execute sequentially."""
+ import asyncio
+ import time
+
+ from mcp.types import CreateMessageResultWithTools, ToolUseContent
+
+ execution_order: list[tuple[str, float]] = []
+
+ async def normal_tool(x: int) -> int:
+ """Normal tool."""
+ execution_order.append(("normal_start", time.time()))
+ await asyncio.sleep(0.05)
+ execution_order.append(("normal_end", time.time()))
+ return x * 2
+
+ async def sequential_tool(y: int) -> int:
+ """Sequential tool."""
+ execution_order.append(("sequential_start", time.time()))
+ await asyncio.sleep(0.05)
+ execution_order.append(("sequential_end", time.time()))
+ return y + 10
+
+ call_count = 0
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="normal_tool",
+ input={"x": 5},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_2",
+ name="sequential_tool",
+ input={"y": 3},
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ # Create tools with sequential=True for one of them
+ normal = SamplingTool.from_function(normal_tool, sequential=False)
+ sequential = SamplingTool.from_function(sequential_tool, sequential=True)
+
+ result = await context.sample(
+ messages="Run tools",
+ tools=[normal, sequential],
+ tool_concurrency=0, # Request unlimited, but sequential tool forces sequential
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Done!"
+ # Verify sequential execution: first tool must complete before second starts
+ events = [e[0] for e in execution_order]
+ assert events[0] in ["normal_start", "sequential_start"]
+ assert events[1] in ["normal_end", "sequential_end"]
+ # Ensure the second tool starts after the first ends
+ if events[0] == "normal_start":
+ assert events[1] == "normal_end"
+ assert events[2] == "sequential_start"
+ else:
+ assert events[1] == "sequential_end"
+ assert events[2] == "normal_start"
+
+ async def test_concurrent_tool_execution_error_handling(self):
+ """Test that errors are captured per-tool in parallel execution."""
+ from mcp.types import (
+ CreateMessageResultWithTools,
+ ToolResultContent,
+ ToolUseContent,
+ )
+
+ def good_tool() -> str:
+ return "success"
+
+ def bad_tool() -> str:
+ raise ValueError("Tool error")
+
+ messages_received: list[list[SamplingMessage]] = []
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ messages_received.append(list(messages))
+
+ if len(messages_received) == 1:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use", id="call_1", name="good_tool", input={}
+ ),
+ ToolUseContent(
+ type="tool_use", id="call_2", name="bad_tool", input={}
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Handled errors")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Run tools",
+ tools=[good_tool, bad_tool],
+ tool_concurrency=0, # Parallel execution
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Handled errors"
+ # Check that tool results include both success and error
+ tool_result_message = messages_received[1][-1]
+ assert tool_result_message.role == "user"
+ tool_results = cast(list[ToolResultContent], tool_result_message.content)
+ assert len(tool_results) == 2
+ # One should be success, one should be error
+ assert any(not r.isError for r in tool_results)
+ assert any(r.isError for r in tool_results)
+
+ async def test_concurrent_tool_result_order_preserved(self):
+ """Test that tool results maintain the same order as tool calls."""
+ import asyncio
+
+ from mcp.types import (
+ CreateMessageResultWithTools,
+ ToolResultContent,
+ ToolUseContent,
+ )
+
+ async def tool_with_delay(value: int, delay: float) -> int:
+ """Tool that takes variable time."""
+ await asyncio.sleep(delay)
+ return value
+
+ messages_received: list[list[SamplingMessage]] = []
+
+ def sampling_handler(
+ messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
+ ) -> CreateMessageResultWithTools:
+ messages_received.append(list(messages))
+
+ if len(messages_received) == 1:
+ # Tools with different delays - later tools finish first
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[
+ ToolUseContent(
+ type="tool_use",
+ id="call_1",
+ name="tool_with_delay",
+ input={"value": 1, "delay": 0.15},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_2",
+ name="tool_with_delay",
+ input={"value": 2, "delay": 0.05},
+ ),
+ ToolUseContent(
+ type="tool_use",
+ id="call_3",
+ name="tool_with_delay",
+ input={"value": 3, "delay": 0.1},
+ ),
+ ],
+ model="test-model",
+ stopReason="toolUse",
+ )
+ else:
+ return CreateMessageResultWithTools(
+ role="assistant",
+ content=[TextContent(type="text", text="Done!")],
+ model="test-model",
+ stopReason="endTurn",
+ )
+
+ mcp = FastMCP(sampling_handler=sampling_handler)
+
+ @mcp.tool
+ async def test_tool(context: Context) -> str:
+ result = await context.sample(
+ messages="Run tools",
+ tools=[tool_with_delay],
+ tool_concurrency=0, # Parallel execution
+ )
+ return result.text or ""
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("test_tool", {})
+
+ assert result.data == "Done!"
+ # Check that results are in the correct order (1, 2, 3) despite finishing order (2, 3, 1)
+ tool_result_message = messages_received[1][-1]
+ tool_results = cast(list[ToolResultContent], tool_result_message.content)
+ assert len(tool_results) == 3
+ assert tool_results[0].toolUseId == "call_1"
+ assert tool_results[1].toolUseId == "call_2"
+ assert tool_results[2].toolUseId == "call_3"
+ # Check values are correct
+ result_texts = [cast(TextContent, r.content[0]).text for r in tool_results]
+ assert result_texts == ["1", "2", "3"]
diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py
index b57a8b4e3..4f9e51f04 100644
--- a/tests/client/test_sse.py
+++ b/tests/client/test_sse.py
@@ -195,6 +195,6 @@ class TestTimeout:
"""
async with Client(
transport=SSETransport(sse_server),
- timeout=0.1,
+ timeout=0.5,
) as client:
- await client.call_tool("sleep", {"seconds": 0.03}, timeout=2)
+ await client.call_tool("sleep", {"seconds": 0.8}, timeout=2)
diff --git a/tests/contrib/test_mcp_mixin.py b/tests/contrib/test_mcp_mixin.py
index 1c1ba6085..22bf02ceb 100644
--- a/tests/contrib/test_mcp_mixin.py
+++ b/tests/contrib/test_mcp_mixin.py
@@ -1,5 +1,7 @@
"""Tests for the MCPMixin class."""
+import inspect
+
import pytest
from fastmcp import FastMCP
@@ -13,6 +15,9 @@ from fastmcp.contrib.mcp_mixin.mcp_mixin import (
_DEFAULT_SEPARATOR_PROMPT,
_DEFAULT_SEPARATOR_RESOURCE,
_DEFAULT_SEPARATOR_TOOL,
+ _PROMPT_VALID_KWARGS,
+ _RESOURCE_VALID_KWARGS,
+ _TOOL_VALID_KWARGS,
)
@@ -337,3 +342,210 @@ class TestMCPMixin:
assert prompt.title == "My Prompt Title"
assert prompt.meta == {"priority": "high", "category": "analysis"}
+
+
+class TestMCPMixinKwargsSync:
+ """Verify that the valid-kwarg sets stay in sync with from_function signatures."""
+
+ def test_tool_valid_kwargs_match_from_function(self):
+ from fastmcp.tools.tool import Tool
+
+ expected = frozenset(
+ p for p in inspect.signature(Tool.from_function).parameters if p != "fn"
+ )
+ assert _TOOL_VALID_KWARGS == expected
+
+ def test_resource_valid_kwargs_match_from_function(self):
+ from fastmcp.resources.resource import Resource
+
+ expected = frozenset(
+ p
+ for p in inspect.signature(Resource.from_function).parameters
+ if p not in ("fn", "uri")
+ )
+ assert _RESOURCE_VALID_KWARGS == expected
+
+ def test_prompt_valid_kwargs_match_from_function(self):
+ from fastmcp.prompts.prompt import Prompt
+
+ expected = frozenset(
+ p for p in inspect.signature(Prompt.from_function).parameters if p != "fn"
+ )
+ assert _PROMPT_VALID_KWARGS == expected
+
+
+class TestMCPMixinValidation:
+ """Unknown kwargs raise TypeError at decoration time, not at registration."""
+
+ def test_mcp_tool_rejects_unknown_param(self):
+ with pytest.raises(TypeError, match="unexpected keyword argument"):
+
+ @mcp_tool(definitely_not_a_real_param="oops")
+ def my_tool(self):
+ pass
+
+ def test_mcp_resource_rejects_unknown_param(self):
+ with pytest.raises(TypeError, match="unexpected keyword argument"):
+
+ @mcp_resource(uri="test://x", definitely_not_a_real_param="oops")
+ def my_resource(self):
+ pass
+
+ def test_mcp_prompt_rejects_unknown_param(self):
+ with pytest.raises(TypeError, match="unexpected keyword argument"):
+
+ @mcp_prompt(definitely_not_a_real_param="oops")
+ def my_prompt(self):
+ pass
+
+ def test_error_raised_at_decoration_not_registration(self):
+ """The TypeError must surface when the decorator is applied, not later."""
+ with pytest.raises(TypeError):
+
+ class MyMixin(MCPMixin):
+ @mcp_tool(bad_kwarg=True)
+ def tool(self):
+ pass
+
+
+class TestMCPMixinEnabled:
+ """enabled=False suppresses registration; enabled=True (default) registers normally."""
+
+ async def test_tool_enabled_false_skips_registration(self):
+ mcp = FastMCP()
+
+ class MyMixin(MCPMixin):
+ @mcp_tool(enabled=False)
+ def hidden_tool(self):
+ pass
+
+ @mcp_tool()
+ def visible_tool(self):
+ pass
+
+ MyMixin().register_tools(mcp)
+ tools = await mcp.list_tools()
+ names = {t.name for t in tools}
+ assert "visible_tool" in names
+ assert "hidden_tool" not in names
+
+ async def test_resource_enabled_false_skips_registration(self):
+ mcp = FastMCP()
+
+ class MyMixin(MCPMixin):
+ @mcp_resource(uri="test://hidden", enabled=False)
+ def hidden_resource(self):
+ pass
+
+ @mcp_resource(uri="test://visible")
+ def visible_resource(self):
+ pass
+
+ MyMixin().register_resources(mcp)
+ resources = await mcp.list_resources()
+ uris = {str(r.uri) for r in resources}
+ assert "test://visible" in uris
+ assert "test://hidden" not in uris
+
+ async def test_prompt_enabled_false_skips_registration(self):
+ mcp = FastMCP()
+
+ class MyMixin(MCPMixin):
+ @mcp_prompt(enabled=False)
+ def hidden_prompt(self):
+ pass
+
+ @mcp_prompt()
+ def visible_prompt(self):
+ pass
+
+ MyMixin().register_prompts(mcp)
+ prompts = await mcp.list_prompts()
+ names = {p.name for p in prompts}
+ assert "visible_prompt" in names
+ assert "hidden_prompt" not in names
+
+ async def test_tool_enabled_true_registers_normally(self):
+ mcp = FastMCP()
+
+ class MyMixin(MCPMixin):
+ @mcp_tool(enabled=True)
+ def my_tool(self):
+ pass
+
+ MyMixin().register_tools(mcp)
+ tools = await mcp.list_tools()
+ assert any(t.name == "my_tool" for t in tools)
+
+
+class TestMCPMixinNewParams:
+ """Parameters that were previously missing now work end-to-end."""
+
+ async def test_tool_auth_param_forwarded(self):
+ from fastmcp.server.auth import require_scopes
+
+ mcp = FastMCP()
+
+ class MyMixin(MCPMixin):
+ @mcp_tool(auth=require_scopes("write"))
+ def secure_tool(self):
+ return "ok"
+
+ MyMixin().register_tools(mcp)
+ # list_tools() filters by auth context; check internal provider directly
+ tools = await mcp.local_provider.list_tools()
+ assert any(t.name == "secure_tool" for t in tools)
+
+ async def test_tool_timeout_param_forwarded(self):
+ mcp = FastMCP()
+
+ class MyMixin(MCPMixin):
+ @mcp_tool(timeout=5.0)
+ def timed_tool(self):
+ return "ok"
+
+ MyMixin().register_tools(mcp)
+ tools = await mcp.list_tools()
+ assert any(t.name == "timed_tool" for t in tools)
+
+ async def test_tool_version_param_forwarded(self):
+ mcp = FastMCP()
+
+ class MyMixin(MCPMixin):
+ @mcp_tool(version="2.0")
+ def versioned_tool(self):
+ return "ok"
+
+ MyMixin().register_tools(mcp)
+ tools = await mcp.list_tools()
+ assert any(t.name == "versioned_tool" for t in tools)
+
+ async def test_resource_auth_param_forwarded(self):
+ from fastmcp.server.auth import require_scopes
+
+ mcp = FastMCP()
+
+ class MyMixin(MCPMixin):
+ @mcp_resource(uri="test://secure", auth=require_scopes("read"))
+ def secure_resource(self):
+ return "data"
+
+ MyMixin().register_resources(mcp)
+ # list_resources() filters by auth context; check internal provider directly
+ resources = await mcp.local_provider.list_resources()
+ assert any(str(r.uri) == "test://secure" for r in resources)
+
+ async def test_prompt_auth_param_forwarded(self):
+ from fastmcp.server.auth import require_scopes
+
+ mcp = FastMCP()
+
+ class MyMixin(MCPMixin):
+ @mcp_prompt(auth=require_scopes("read"))
+ def secure_prompt(self):
+ return "prompt text"
+
+ MyMixin().register_prompts(mcp)
+ # list_prompts() filters by auth context; check internal provider directly
+ prompts = await mcp.local_provider.list_prompts()
+ assert any(p.name == "secure_prompt" for p in prompts)
diff --git a/tests/experimental/transforms/test_code_mode.py b/tests/experimental/transforms/test_code_mode.py
new file mode 100644
index 000000000..a57472fb4
--- /dev/null
+++ b/tests/experimental/transforms/test_code_mode.py
@@ -0,0 +1,691 @@
+import importlib
+import json
+from typing import Any
+
+import pytest
+from mcp.types import ImageContent, TextContent
+
+from fastmcp import FastMCP
+from fastmcp.exceptions import ToolError
+from fastmcp.experimental.transforms.code_mode import (
+ CodeMode,
+ GetSchemas,
+ GetToolCatalog,
+ MontySandboxProvider,
+ Search,
+ _ensure_async,
+)
+from fastmcp.server.context import Context
+from fastmcp.tools.tool import Tool, ToolResult
+
+
+def _unwrap_result(result: ToolResult) -> Any:
+ """Extract the logical return value from a ToolResult."""
+ if result.structured_content is not None:
+ return result.structured_content
+
+ text_blocks = [
+ content.text for content in result.content if isinstance(content, TextContent)
+ ]
+ if not text_blocks:
+ return None
+
+ if len(text_blocks) == 1:
+ try:
+ return json.loads(text_blocks[0])
+ except json.JSONDecodeError:
+ return text_blocks[0]
+
+ values: list[Any] = []
+ for text in text_blocks:
+ try:
+ values.append(json.loads(text))
+ except json.JSONDecodeError:
+ values.append(text)
+ return values
+
+
+def _unwrap_string_result(result: ToolResult) -> str:
+ """Extract a string result from a ToolResult.
+
+ String results are wrapped in ``{"result": "..."}`` by the
+ structured-output convention.
+ """
+ data = _unwrap_result(result)
+ if isinstance(data, dict) and "result" in data:
+ return data["result"]
+ assert isinstance(data, str)
+ return data
+
+
+class _UnsafeTestSandboxProvider:
+ """UNSAFE: Uses exec() for testing only. Never use in production."""
+
+ async def run(
+ self,
+ code: str,
+ *,
+ inputs: dict[str, Any] | None = None,
+ external_functions: dict[str, Any] | None = None,
+ ) -> Any:
+ namespace: dict[str, Any] = {}
+ if inputs:
+ namespace.update(inputs)
+ if external_functions:
+ namespace.update(
+ {key: _ensure_async(value) for key, value in external_functions.items()}
+ )
+
+ wrapped = "async def __test_main__():\n"
+ for line in code.splitlines():
+ wrapped += f" {line}\n"
+ if not code.strip():
+ wrapped += " return None\n"
+
+ exec(wrapped, namespace, namespace)
+ return await namespace["__test_main__"]()
+
+
+async def _run_tool(
+ server: FastMCP, name: str, arguments: dict[str, Any]
+) -> ToolResult:
+ return await server.call_tool(name, arguments)
+
+
+# ---------------------------------------------------------------------------
+# CodeMode core tests
+# ---------------------------------------------------------------------------
+
+
+async def test_code_mode_default_tools() -> None:
+ """Default CodeMode exposes search, get_schema, and execute."""
+ mcp = FastMCP("CodeMode Default")
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ listed_tools = await mcp.list_tools(run_middleware=False)
+ assert {tool.name for tool in listed_tools} == {"search", "get_schema", "execute"}
+
+
+async def test_code_mode_search_returns_lightweight_results() -> None:
+ """Default search returns tool names and descriptions, not full schemas."""
+ mcp = FastMCP("CodeMode Search")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square of a number."""
+ return x * x
+
+ @mcp.tool
+ def greet(name: str) -> str:
+ """Say hello to someone."""
+ return f"Hello, {name}!"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "square number"})
+ text = _unwrap_string_result(result)
+ assert "square" in text
+ assert "Compute the square" in text
+ # Should NOT contain full schema details
+ assert "inputSchema" not in text
+
+
+async def test_code_mode_get_schema_brief() -> None:
+ """get_schema with detail=brief returns names and descriptions only."""
+ mcp = FastMCP("CodeMode Schema Brief")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square of a number."""
+ return x * x
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(
+ mcp, "get_schema", {"tools": ["square"], "detail": "brief"}
+ )
+ text = _unwrap_string_result(result)
+ assert "square" in text
+ assert "Compute the square" in text
+ # brief should NOT include parameter details
+ assert "**Parameters**" not in text
+
+
+async def test_code_mode_get_schema_detailed() -> None:
+ """get_schema with detail=detailed returns markdown with parameter info."""
+ mcp = FastMCP("CodeMode Schema Detailed")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square of a number."""
+ return x * x
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(
+ mcp, "get_schema", {"tools": ["square"], "detail": "detailed"}
+ )
+ text = _unwrap_string_result(result)
+ assert "### square" in text
+ assert "Compute the square" in text
+ assert "**Parameters**" in text
+ assert "`x` (integer, required)" in text
+
+
+async def test_code_mode_get_schema_full() -> None:
+ """get_schema with detail=full returns JSON schema."""
+ mcp = FastMCP("CodeMode Schema Full")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square of a number."""
+ return x * x
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "get_schema", {"tools": ["square"], "detail": "full"})
+ text = _unwrap_string_result(result)
+ parsed = json.loads(text)
+ assert isinstance(parsed, list)
+ assert parsed[0]["name"] == "square"
+ assert "inputSchema" in parsed[0]
+
+
+async def test_code_mode_get_schema_default_is_detailed() -> None:
+ """get_schema defaults to detailed (markdown with parameters)."""
+ mcp = FastMCP("CodeMode Schema Default")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square of a number."""
+ return x * x
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "get_schema", {"tools": ["square"]})
+ text = _unwrap_string_result(result)
+ assert "### square" in text
+ assert "**Parameters**" in text
+
+
+async def test_code_mode_get_schema_not_found() -> None:
+ """get_schema reports tools that don't exist in the catalog."""
+ mcp = FastMCP("CodeMode Schema NotFound")
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "get_schema", {"tools": ["nonexistent"]})
+ text = _unwrap_string_result(result)
+ assert "not found" in text.lower()
+ assert "nonexistent" in text
+
+
+async def test_code_mode_get_schema_partial_match() -> None:
+ """get_schema returns schemas for found tools and reports missing ones."""
+ mcp = FastMCP("CodeMode Schema Partial")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square."""
+ return x * x
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "get_schema", {"tools": ["square", "nonexistent"]})
+ text = _unwrap_string_result(result)
+ assert "### square" in text
+ assert "nonexistent" in text
+
+
+async def test_code_mode_execute_works() -> None:
+ """Execute tool can call backend tools through the sandbox."""
+ mcp = FastMCP("CodeMode Execute")
+
+ @mcp.tool
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(
+ mcp, "execute", {"code": "return await call_tool('add', {'x': 2, 'y': 3})"}
+ )
+ assert _unwrap_result(result) == {"result": 5}
+
+
+# ---------------------------------------------------------------------------
+# Tool naming and configuration
+# ---------------------------------------------------------------------------
+
+
+async def test_code_mode_custom_execute_name() -> None:
+ mcp = FastMCP("CodeMode Custom Execute")
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(
+ CodeMode(
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ execute_tool_name="run_code",
+ )
+ )
+
+ listed = await mcp.list_tools(run_middleware=False)
+ names = {t.name for t in listed}
+ assert "run_code" in names
+ assert "execute" not in names
+
+
+async def test_code_mode_custom_execute_description() -> None:
+ mcp = FastMCP("CodeMode Custom Desc")
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(
+ CodeMode(
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ execute_description="Custom execute description",
+ )
+ )
+
+ listed = await mcp.list_tools(run_middleware=False)
+ by_name = {t.name: t for t in listed}
+ assert by_name["execute"].description == "Custom execute description"
+
+
+async def test_code_mode_default_execute_description() -> None:
+ mcp = FastMCP("CodeMode Defaults")
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ listed = await mcp.list_tools(run_middleware=False)
+ by_name = {t.name: t for t in listed}
+ desc = by_name["execute"].description or ""
+
+ assert "single block" in desc
+ assert "Use `return` to produce output." in desc
+ assert (
+ "Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope."
+ in desc
+ )
+
+
+# ---------------------------------------------------------------------------
+# Discovery tool customization
+# ---------------------------------------------------------------------------
+
+
+async def test_code_mode_no_discovery_tools() -> None:
+ """CodeMode with empty discovery_tools exposes only execute."""
+ mcp = FastMCP("CodeMode No Discovery")
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ listed = await mcp.list_tools(run_middleware=False)
+ assert {t.name for t in listed} == {"execute"}
+
+
+async def test_code_mode_custom_discovery_tool_function() -> None:
+ """A plain function can serve as a discovery tool factory."""
+ mcp = FastMCP("CodeMode Custom Discovery")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square."""
+ return x * x
+
+ def list_all(get_catalog: GetToolCatalog) -> Tool:
+ async def list_tools(
+ ctx: Context = None, # type: ignore[assignment]
+ ) -> str:
+ """List all available tools."""
+ tools = await get_catalog(ctx)
+ return ", ".join(t.name for t in tools)
+
+ return Tool.from_function(fn=list_tools, name="list_all")
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[list_all],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ listed = await mcp.list_tools(run_middleware=False)
+ assert {t.name for t in listed} == {"list_all", "execute"}
+
+ result = await _run_tool(mcp, "list_all", {})
+ text = _unwrap_string_result(result)
+ assert "square" in text
+
+
+async def test_code_mode_search_detailed() -> None:
+ """Search with detail='detailed' returns markdown with parameter info."""
+ mcp = FastMCP("CodeMode Search Detailed")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square."""
+ return x * x
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "square", "detail": "detailed"})
+ text = _unwrap_string_result(result)
+ assert "### square" in text
+ assert "Compute the square" in text
+ assert "**Parameters**" in text
+ assert "`x` (integer, required)" in text
+
+
+async def test_code_mode_search_tool_full_detail() -> None:
+ """Search with detail='full' includes JSON schemas."""
+ mcp = FastMCP("CodeMode Search Full")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square."""
+ return x * x
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[Search(default_detail="full")],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "search", {"query": "square"})
+ text = _unwrap_string_result(result)
+ parsed = json.loads(text)
+ assert isinstance(parsed, list)
+ assert parsed[0]["name"] == "square"
+ assert "inputSchema" in parsed[0]
+
+
+async def test_code_mode_custom_search_tool_name() -> None:
+ """Search and GetSchemas support custom names."""
+ mcp = FastMCP("CodeMode Custom Names")
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[
+ Search(name="find"),
+ GetSchemas(name="describe"),
+ ],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ listed = await mcp.list_tools(run_middleware=False)
+ assert {t.name for t in listed} == {"find", "describe", "execute"}
+
+
+def test_code_mode_rejects_discovery_execute_name_collision() -> None:
+ """CodeMode raises ValueError when a discovery tool collides with execute."""
+ cm = CodeMode(
+ discovery_tools=[Search(name="execute")],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ with pytest.raises(ValueError, match="collides"):
+ cm._build_discovery_tools()
+
+
+def test_code_mode_rejects_duplicate_discovery_names() -> None:
+ """CodeMode raises ValueError when discovery tools have duplicate names."""
+ cm = CodeMode(
+ discovery_tools=[Search(name="search"), Search(name="search")],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ with pytest.raises(ValueError, match="unique"):
+ cm._build_discovery_tools()
+
+
+# ---------------------------------------------------------------------------
+# Visibility and auth
+# ---------------------------------------------------------------------------
+
+
+async def test_code_mode_execute_respects_disabled_tool_visibility() -> None:
+ mcp = FastMCP("CodeMode Disabled")
+
+ @mcp.tool
+ def secret() -> str:
+ return "nope"
+
+ mcp.disable(names={"secret"}, components={"tool"})
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ with pytest.raises(ToolError, match=r"Unknown tool"):
+ await _run_tool(
+ mcp, "execute", {"code": "return await call_tool('secret', {})"}
+ )
+
+
+async def test_code_mode_search_respects_disabled_tool_visibility() -> None:
+ mcp = FastMCP("CodeMode Disabled Search")
+
+ @mcp.tool
+ def secret() -> str:
+ """A secret tool."""
+ return "nope"
+
+ mcp.disable(names={"secret"}, components={"tool"})
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "secret"})
+ text = _unwrap_string_result(result)
+ assert "secret" not in text or "No tools" in text
+
+
+async def test_code_mode_execute_respects_tool_auth() -> None:
+ mcp = FastMCP("CodeMode Auth")
+
+ @mcp.tool(auth=lambda _ctx: False)
+ def protected() -> str:
+ return "nope"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ with pytest.raises(ToolError, match=r"Unknown tool"):
+ await _run_tool(
+ mcp, "execute", {"code": "return await call_tool('protected', {})"}
+ )
+
+
+async def test_code_mode_search_respects_tool_auth() -> None:
+ mcp = FastMCP("CodeMode Auth Search")
+
+ @mcp.tool(auth=lambda _ctx: False)
+ def protected() -> str:
+ """A protected tool."""
+ return "nope"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "protected"})
+ text = _unwrap_string_result(result)
+ assert "protected" not in text or "No tools" in text
+
+
+async def test_code_mode_shadows_colliding_tool_names() -> None:
+ """Backend tools with the same name as meta-tools are shadowed."""
+ mcp = FastMCP("CodeMode Collision")
+
+ @mcp.tool
+ def search() -> str:
+ return "real search"
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ tools = await mcp.list_tools(run_middleware=False)
+ tool_names = {t.name for t in tools}
+ assert "execute" in tool_names
+
+ result = await _run_tool(
+ mcp, "execute", {"code": 'return await call_tool("ping", {})'}
+ )
+ assert _unwrap_result(result) == {"result": "pong"}
+
+
+# ---------------------------------------------------------------------------
+# get_tool pass-through
+# ---------------------------------------------------------------------------
+
+
+async def test_code_mode_get_tool_returns_meta_tools_and_passes_through() -> None:
+ """get_tool returns meta-tools by name and passes through backend tools."""
+ mcp = FastMCP("CodeMode GetTool")
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ search_tool = await mcp.get_tool("search")
+ assert search_tool is not None
+ assert search_tool.name == "search"
+
+ schema_tool = await mcp.get_tool("get_schema")
+ assert schema_tool is not None
+ assert schema_tool.name == "get_schema"
+
+ execute_tool = await mcp.get_tool("execute")
+ assert execute_tool is not None
+ assert execute_tool.name == "execute"
+
+ ping_tool = await mcp.get_tool("ping")
+ assert ping_tool is not None
+ assert ping_tool.name == "ping"
+
+
+# ---------------------------------------------------------------------------
+# Execute edge cases
+# ---------------------------------------------------------------------------
+
+
+async def test_code_mode_execute_non_text_content_stringified() -> None:
+ mcp = FastMCP("CodeMode NonText")
+
+ @mcp.tool
+ def image_tool() -> ImageContent:
+ return ImageContent(type="image", data="base64data", mimeType="image/png")
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(
+ mcp, "execute", {"code": "return await call_tool('image_tool', {})"}
+ )
+ unwrapped = _unwrap_result(result)
+ assert isinstance(unwrapped, str)
+ assert "base64data" in unwrapped
+
+
+async def test_code_mode_execute_multi_tool_chaining() -> None:
+ """Execute block can chain multiple call_tool() calls."""
+ mcp = FastMCP("CodeMode Chaining")
+
+ @mcp.tool
+ def double(x: int) -> int:
+ return x * 2
+
+ @mcp.tool
+ def add_one(x: int) -> int:
+ return x + 1
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(
+ mcp,
+ "execute",
+ {
+ "code": (
+ "a = await call_tool('double', {'x': 3})\n"
+ "b = await call_tool('add_one', {'x': a['result']})\n"
+ "return b"
+ )
+ },
+ )
+ assert _unwrap_result(result) == {"result": 7}
+
+
+async def test_code_mode_sandbox_error_surfaces_as_tool_error() -> None:
+ mcp = FastMCP("CodeMode Errors")
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ with pytest.raises(ToolError):
+ await _run_tool(mcp, "execute", {"code": "raise ValueError('boom')"})
+
+
+# ---------------------------------------------------------------------------
+# Sandbox provider tests
+# ---------------------------------------------------------------------------
+
+
+async def test_monty_provider_raises_informative_error_when_missing(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ provider = MontySandboxProvider()
+ real_import_module = importlib.import_module
+
+ def _fake_import_module(name: str, package: str | None = None):
+ if name == "pydantic_monty":
+ raise ModuleNotFoundError("No module named 'pydantic_monty'")
+ return real_import_module(name, package)
+
+ monkeypatch.setattr(importlib, "import_module", _fake_import_module)
+
+ with pytest.raises(ImportError, match=r"fastmcp\[code-mode\]"):
+ await provider.run("return 1")
+
+
+async def test_monty_provider_forwards_limits() -> None:
+ provider = MontySandboxProvider(limits={"max_duration_secs": 0.1})
+
+ with pytest.raises(Exception, match="time limit exceeded"):
+ await provider.run("x = 0\nfor _ in range(10**9):\n x += 1")
+
+
+async def test_monty_provider_no_limits_by_default() -> None:
+ provider = MontySandboxProvider()
+ result = await provider.run("return 1 + 2")
+ assert result == 3
diff --git a/tests/experimental/transforms/test_code_mode_discovery.py b/tests/experimental/transforms/test_code_mode_discovery.py
new file mode 100644
index 000000000..cdc45ba1f
--- /dev/null
+++ b/tests/experimental/transforms/test_code_mode_discovery.py
@@ -0,0 +1,604 @@
+import json
+from typing import Any
+
+from mcp.types import TextContent
+
+from fastmcp import FastMCP
+from fastmcp.experimental.transforms.code_mode import (
+ CodeMode,
+ GetTags,
+ ListTools,
+ Search,
+ _ensure_async,
+)
+from fastmcp.tools.tool import ToolResult
+
+
+def _unwrap_result(result: ToolResult) -> Any:
+ """Extract the logical return value from a ToolResult."""
+ if result.structured_content is not None:
+ return result.structured_content
+
+ text_blocks = [
+ content.text for content in result.content if isinstance(content, TextContent)
+ ]
+ if not text_blocks:
+ return None
+
+ if len(text_blocks) == 1:
+ try:
+ return json.loads(text_blocks[0])
+ except json.JSONDecodeError:
+ return text_blocks[0]
+
+ values: list[Any] = []
+ for text in text_blocks:
+ try:
+ values.append(json.loads(text))
+ except json.JSONDecodeError:
+ values.append(text)
+ return values
+
+
+def _unwrap_string_result(result: ToolResult) -> str:
+ """Extract a string result from a ToolResult."""
+ data = _unwrap_result(result)
+ if isinstance(data, dict) and "result" in data:
+ return data["result"]
+ assert isinstance(data, str)
+ return data
+
+
+class _UnsafeTestSandboxProvider:
+ """UNSAFE: Uses exec() for testing only. Never use in production."""
+
+ async def run(
+ self,
+ code: str,
+ *,
+ inputs: dict[str, Any] | None = None,
+ external_functions: dict[str, Any] | None = None,
+ ) -> Any:
+ namespace: dict[str, Any] = {}
+ if inputs:
+ namespace.update(inputs)
+ if external_functions:
+ namespace.update(
+ {key: _ensure_async(value) for key, value in external_functions.items()}
+ )
+
+ wrapped = "async def __test_main__():\n"
+ for line in code.splitlines():
+ wrapped += f" {line}\n"
+ if not code.strip():
+ wrapped += " return None\n"
+
+ exec(wrapped, namespace, namespace)
+ return await namespace["__test_main__"]()
+
+
+async def _run_tool(
+ server: FastMCP, name: str, arguments: dict[str, Any]
+) -> ToolResult:
+ return await server.call_tool(name, arguments)
+
+
+# ---------------------------------------------------------------------------
+# Tags discovery tool
+# ---------------------------------------------------------------------------
+
+
+async def test_categories_brief_shows_tag_counts() -> None:
+ mcp = FastMCP("Tags Brief")
+
+ @mcp.tool(tags={"math"})
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ @mcp.tool(tags={"math"})
+ def multiply(x: int, y: int) -> int:
+ return x * y
+
+ @mcp.tool(tags={"text"})
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[GetTags()],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "tags", {})
+ text = _unwrap_string_result(result)
+ assert "math (2 tools)" in text
+ assert "text (1 tool)" in text
+
+
+async def test_categories_full_lists_tools_per_tag() -> None:
+ mcp = FastMCP("Tags Full")
+
+ @mcp.tool(tags={"math"})
+ def add(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ @mcp.tool(tags={"text"})
+ def greet(name: str) -> str:
+ """Say hello."""
+ return f"Hello, {name}!"
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[GetTags(default_detail="full")],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "tags", {})
+ text = _unwrap_string_result(result)
+ assert "### math" in text
+ assert "- add: Add two numbers." in text
+ assert "### text" in text
+ assert "- greet: Say hello." in text
+
+
+async def test_categories_includes_untagged() -> None:
+ mcp = FastMCP("Tags Untagged")
+
+ @mcp.tool(tags={"math"})
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[GetTags()],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "tags", {})
+ text = _unwrap_string_result(result)
+ assert "math" in text
+ assert "untagged (1 tool)" in text
+
+
+async def test_categories_tool_in_multiple_tags() -> None:
+ mcp = FastMCP("Tags Multi-tag")
+
+ @mcp.tool(tags={"math", "core"})
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[GetTags(default_detail="full")],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "tags", {})
+ text = _unwrap_string_result(result)
+ assert "### core" in text
+ assert "### math" in text
+ # Tool appears under both tags
+ assert text.count("- add") == 2
+
+
+async def test_categories_detail_override_per_call() -> None:
+ """LLM can override default_detail on a per-call basis."""
+ mcp = FastMCP("Tags Override")
+
+ @mcp.tool(tags={"math"})
+ def add(x: int, y: int) -> int:
+ """Add numbers."""
+ return x + y
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[GetTags()], # default_detail="brief"
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ # Override to full
+ result = await _run_tool(mcp, "tags", {"detail": "full"})
+ text = _unwrap_string_result(result)
+ assert "### math" in text
+ assert "- add: Add numbers." in text
+
+
+async def test_get_tags_empty_catalog() -> None:
+ """GetTags with no tools returns 'No tools available.'."""
+ mcp = FastMCP("CodeMode Empty Tags")
+
+ mcp.disable(names={"ping"}, components={"tool"})
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[GetTags()],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "tags", {})
+ text = _unwrap_string_result(result)
+ assert "No tools available" in text
+
+
+# ---------------------------------------------------------------------------
+# Search with tags filtering
+# ---------------------------------------------------------------------------
+
+
+async def test_search_with_tags_filter() -> None:
+ mcp = FastMCP("Search Tags")
+
+ @mcp.tool(tags={"math"})
+ def add(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ @mcp.tool(tags={"text"})
+ def greet(name: str) -> str:
+ """Say hello."""
+ return f"Hello, {name}!"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "add hello", "tags": ["math"]})
+ text = _unwrap_string_result(result)
+ assert "add" in text
+ assert "greet" not in text
+
+
+async def test_search_with_tags_filter_no_matches() -> None:
+ mcp = FastMCP("Search Tags Empty")
+
+ @mcp.tool(tags={"math"})
+ def add(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "add", "tags": ["nonexistent"]})
+ text = _unwrap_string_result(result)
+ assert "add" not in text or "No tools" in text
+
+
+async def test_search_without_tags_returns_all() -> None:
+ """Search without tags parameter searches the full catalog."""
+ mcp = FastMCP("Search No Tags")
+
+ @mcp.tool(tags={"math"})
+ def add(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ @mcp.tool(tags={"text"})
+ def greet(name: str) -> str:
+ """Say hello."""
+ return f"Hello, {name}!"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "add hello"})
+ text = _unwrap_string_result(result)
+ assert "add" in text
+ assert "greet" in text
+
+
+async def test_search_with_untagged_filter() -> None:
+ """Search with tags=["untagged"] matches tools that have no tags."""
+ mcp = FastMCP("Search Untagged")
+
+ @mcp.tool(tags={"math"})
+ def add(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ @mcp.tool
+ def ping() -> str:
+ """Ping."""
+ return "pong"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "ping add", "tags": ["untagged"]})
+ text = _unwrap_string_result(result)
+ assert "ping" in text
+ assert "add" not in text
+
+
+async def test_search_default_detail_detailed_skips_get_schema() -> None:
+ """Two-stage pattern: Search(default_detail='detailed') returns schemas inline."""
+ mcp = FastMCP("CodeMode Two-Stage")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square."""
+ return x * x
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[Search(default_detail="detailed")],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "search", {"query": "square"})
+ text = _unwrap_string_result(result)
+ assert "### square" in text
+ assert "**Parameters**" in text
+ assert "`x` (integer, required)" in text
+
+
+async def test_search_full_detail_empty_results_returns_json() -> None:
+ """Search with detail=full and no matches returns valid JSON, not plain text."""
+ mcp = FastMCP("CodeMode Empty Full")
+
+ @mcp.tool(tags={"math"})
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(
+ mcp,
+ "search",
+ {"query": "nonexistent", "tags": ["nonexistent"], "detail": "full"},
+ )
+ text = _unwrap_string_result(result)
+ parsed = json.loads(text)
+ assert parsed == []
+
+
+async def test_get_schema_empty_tools_list() -> None:
+ """get_schema with an empty tools list returns no-match message."""
+ mcp = FastMCP("CodeMode Empty Schema")
+
+ @mcp.tool
+ def ping() -> str:
+ return "pong"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "get_schema", {"tools": []})
+ text = _unwrap_string_result(result)
+ assert "No tools matched" in text
+
+
+async def test_get_schema_full_partial_match_returns_valid_json() -> None:
+ """get_schema with detail=full and missing tools returns valid JSON."""
+ mcp = FastMCP("Schema Full Partial")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square."""
+ return x * x
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(
+ mcp, "get_schema", {"tools": ["square", "nonexistent"], "detail": "full"}
+ )
+ text = _unwrap_string_result(result)
+ parsed = json.loads(text)
+ assert isinstance(parsed, list)
+ assert parsed[0]["name"] == "square"
+ assert parsed[-1] == {"not_found": ["nonexistent"]}
+
+
+# ---------------------------------------------------------------------------
+# Search catalog size annotation
+# ---------------------------------------------------------------------------
+
+
+async def test_search_shows_catalog_size_when_results_are_subset() -> None:
+ """Search annotates results with 'N of M tools' when not all tools match."""
+ mcp = FastMCP("Search Annotation")
+
+ @mcp.tool
+ def add(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ @mcp.tool
+ def multiply(x: int, y: int) -> int:
+ """Multiply two numbers."""
+ return x * y
+
+ @mcp.tool
+ def greet(name: str) -> str:
+ """Say hello."""
+ return f"Hello, {name}!"
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "add numbers"})
+ text = _unwrap_string_result(result)
+ # Should show partial result count out of total catalog
+ assert "of 3 tools:" in text
+
+
+async def test_search_omits_annotation_when_all_tools_returned() -> None:
+ """Search does not annotate when results include every tool."""
+ mcp = FastMCP("Search All Match")
+
+ @mcp.tool
+ def add(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "add numbers"})
+ text = _unwrap_string_result(result)
+ assert "of" not in text or "tools:" not in text
+
+
+# ---------------------------------------------------------------------------
+# Search limit
+# ---------------------------------------------------------------------------
+
+
+async def test_search_limit_caps_results() -> None:
+ """Search with limit returns at most that many results."""
+ mcp = FastMCP("Search Limit")
+
+ @mcp.tool
+ def add(x: int, y: int) -> int:
+ """Add numbers."""
+ return x + y
+
+ @mcp.tool
+ def subtract(x: int, y: int) -> int:
+ """Subtract numbers."""
+ return x - y
+
+ @mcp.tool
+ def multiply(x: int, y: int) -> int:
+ """Multiply numbers."""
+ return x * y
+
+ mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
+
+ result = await _run_tool(mcp, "search", {"query": "numbers", "limit": 1})
+ text = _unwrap_string_result(result)
+ assert "1 of 3 tools:" in text
+ # Only one tool line (starts with "- ")
+ tool_lines = [line for line in text.splitlines() if line.startswith("- ")]
+ assert len(tool_lines) == 1
+
+
+async def test_search_default_limit_from_constructor() -> None:
+ """Search(default_limit=N) caps results by default."""
+ mcp = FastMCP("Search Default Limit")
+
+ @mcp.tool
+ def a() -> str:
+ """Tool A."""
+ return "a"
+
+ @mcp.tool
+ def b() -> str:
+ """Tool B."""
+ return "b"
+
+ @mcp.tool
+ def c() -> str:
+ """Tool C."""
+ return "c"
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[Search(default_limit=2)],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "search", {"query": "tool"})
+ text = _unwrap_string_result(result)
+ assert "2 of 3 tools:" in text
+
+
+# ---------------------------------------------------------------------------
+# ListTools discovery tool
+# ---------------------------------------------------------------------------
+
+
+async def test_list_tools_brief() -> None:
+ """ListTools at brief detail lists all tool names and descriptions."""
+ mcp = FastMCP("ListTools Brief")
+
+ @mcp.tool
+ def add(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ @mcp.tool
+ def multiply(x: int, y: int) -> int:
+ """Multiply two numbers."""
+ return x * y
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[ListTools()],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "list_tools", {})
+ text = _unwrap_string_result(result)
+ assert "add" in text
+ assert "multiply" in text
+ assert "Add two numbers" in text
+ # brief should not include parameter details
+ assert "**Parameters**" not in text
+
+
+async def test_list_tools_detailed() -> None:
+ """ListTools at detailed shows parameter schemas."""
+ mcp = FastMCP("ListTools Detailed")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square."""
+ return x * x
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[ListTools(default_detail="detailed")],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "list_tools", {})
+ text = _unwrap_string_result(result)
+ assert "### square" in text
+ assert "**Parameters**" in text
+ assert "`x` (integer, required)" in text
+
+
+async def test_list_tools_full_returns_json() -> None:
+ """ListTools at full returns valid JSON with schemas."""
+ mcp = FastMCP("ListTools Full")
+
+ @mcp.tool
+ def ping() -> str:
+ """Ping."""
+ return "pong"
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[ListTools()],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "list_tools", {"detail": "full"})
+ text = _unwrap_string_result(result)
+ parsed = json.loads(text)
+ assert isinstance(parsed, list)
+ assert parsed[0]["name"] == "ping"
+
+
+async def test_list_tools_empty_catalog() -> None:
+ """ListTools with no tools returns no-match message."""
+ mcp = FastMCP("ListTools Empty")
+
+ mcp.add_transform(
+ CodeMode(
+ discovery_tools=[ListTools()],
+ sandbox_provider=_UnsafeTestSandboxProvider(),
+ )
+ )
+
+ result = await _run_tool(mcp, "list_tools", {})
+ text = _unwrap_string_result(result)
+ assert "No tools matched" in text
diff --git a/tests/experimental/transforms/test_code_mode_serialization.py b/tests/experimental/transforms/test_code_mode_serialization.py
new file mode 100644
index 000000000..a589ad790
--- /dev/null
+++ b/tests/experimental/transforms/test_code_mode_serialization.py
@@ -0,0 +1,197 @@
+from typing import Any
+
+import pytest
+
+from fastmcp import FastMCP
+from fastmcp.server.transforms.search.base import (
+ _schema_section,
+ _schema_type,
+ serialize_tools_for_output_markdown,
+)
+
+# ---------------------------------------------------------------------------
+# _schema_type unit tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "schema,expected",
+ [
+ ({"type": "string"}, "string"),
+ ({"type": "integer"}, "integer"),
+ ({"type": "boolean"}, "boolean"),
+ ({"type": "null"}, "null"),
+ ({"type": "array", "items": {"type": "string"}}, "string[]"),
+ ({"type": "array", "items": {"type": "integer"}}, "integer[]"),
+ ({"type": "array"}, "any[]"),
+ ({"$ref": "#/$defs/Foo"}, "object"),
+ ({"properties": {"x": {"type": "int"}}}, "object"),
+ ({}, "any"),
+ (None, "any"),
+ ("not a dict", "any"),
+ ],
+)
+def test_schema_type_basic(schema: Any, expected: str) -> None:
+ assert _schema_type(schema) == expected
+
+
+@pytest.mark.parametrize(
+ "schema,expected",
+ [
+ ({"anyOf": [{"type": "string"}, {"type": "null"}]}, "string?"),
+ ({"anyOf": [{"type": "string"}, {"type": "integer"}]}, "string | integer"),
+ (
+ {"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]},
+ "string | integer?",
+ ),
+ ({"anyOf": [{"type": "null"}]}, "null"),
+ ({"anyOf": []}, "any"),
+ ({"oneOf": [{"type": "string"}, {"type": "null"}]}, "string?"),
+ ({"oneOf": [{"type": "string"}, {"type": "integer"}]}, "string | integer"),
+ ({"allOf": [{"type": "object"}]}, "object"),
+ ({"allOf": [{"$ref": "#/$defs/Foo"}, {"$ref": "#/$defs/Bar"}]}, "object"),
+ ],
+)
+def test_schema_type_unions(schema: Any, expected: str) -> None:
+ assert _schema_type(schema) == expected
+
+
+# ---------------------------------------------------------------------------
+# _schema_section unit tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "schema,expected_lines",
+ [
+ (None, ["**Parameters**", "- `value` (any)"]),
+ ("string", ["**Parameters**", "- `value` (any)"]),
+ ({"type": "string"}, ["**Parameters**", "- `value` (string)"]),
+ (
+ {"type": "object", "properties": {}},
+ ["**Parameters**", "*(no parameters)*"],
+ ),
+ ],
+)
+def test_schema_section_fallbacks(schema: Any, expected_lines: list[str]) -> None:
+ assert _schema_section(schema, "Parameters") == expected_lines
+
+
+def test_schema_section_lists_fields_with_required_marker() -> None:
+ schema = {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "age": {"type": "integer"},
+ },
+ "required": ["name"],
+ }
+ lines = _schema_section(schema, "Parameters")
+ assert lines[0] == "**Parameters**"
+ assert "- `name` (string, required)" in lines
+ assert "- `age` (integer)" in lines
+
+
+# ---------------------------------------------------------------------------
+# serialize_tools_for_output_markdown unit tests
+# ---------------------------------------------------------------------------
+
+
+def test_serialize_tools_for_output_markdown_empty_list() -> None:
+ assert serialize_tools_for_output_markdown([]) == "No tools matched the query."
+
+
+async def test_serialize_tools_for_output_markdown_basic_tool() -> None:
+ mcp = FastMCP("MD Basic")
+
+ @mcp.tool
+ def square(x: int) -> int:
+ """Compute the square of a number."""
+ return x * x
+
+ tools = await mcp.list_tools()
+ result = serialize_tools_for_output_markdown(tools)
+
+ assert "### square" in result
+ assert "Compute the square of a number." in result
+ assert "**Parameters**" in result
+ assert "`x` (integer, required)" in result
+
+
+async def test_serialize_tools_for_output_markdown_omits_output_section_when_no_schema() -> (
+ None
+):
+ mcp = FastMCP("MD No Output")
+
+ @mcp.tool
+ def ping() -> None:
+ pass
+
+ tools = await mcp.list_tools()
+ result = serialize_tools_for_output_markdown(tools)
+
+ assert "**Returns**" not in result
+
+
+async def test_serialize_tools_for_output_markdown_includes_output_section_when_schema_present() -> (
+ None
+):
+ mcp = FastMCP("MD With Output")
+
+ @mcp.tool
+ def double(x: int) -> int:
+ return x * 2
+
+ tools = await mcp.list_tools()
+ result = serialize_tools_for_output_markdown(tools)
+
+ assert "**Returns**" in result
+
+
+async def test_serialize_tools_for_output_markdown_omits_description_when_absent() -> (
+ None
+):
+ mcp = FastMCP("MD No Desc")
+
+ @mcp.tool
+ def ping() -> None:
+ pass
+
+ tools = await mcp.list_tools()
+ result = serialize_tools_for_output_markdown(tools)
+
+ assert "### ping" in result
+
+
+async def test_serialize_tools_for_output_markdown_optional_field_uses_question_mark() -> (
+ None
+):
+ mcp = FastMCP("MD Optional")
+
+ @mcp.tool
+ def greet(name: str, greeting: str | None = None) -> str:
+ return f"{greeting or 'Hello'}, {name}!"
+
+ tools = await mcp.list_tools()
+ result = serialize_tools_for_output_markdown(tools)
+
+ assert "`greeting` (string?)" in result
+
+
+async def test_serialize_tools_for_output_markdown_multiple_tools_separated() -> None:
+ mcp = FastMCP("MD Multi")
+
+ @mcp.tool
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ @mcp.tool
+ def subtract(a: int, b: int) -> int:
+ return a - b
+
+ tools = await mcp.list_tools()
+ result = serialize_tools_for_output_markdown(tools)
+
+ assert "### add" in result
+ assert "### subtract" in result
+ assert "\n\n" in result
diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py
index ed0b37376..1f43ce1d5 100644
--- a/tests/resources/test_resource_template.py
+++ b/tests/resources/test_resource_template.py
@@ -747,343 +747,3 @@ class TestContextHandling:
# read() returns the raw value
result = await resource.read()
assert result == "item: 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"},
- )
-
- # read() returns raw dict
- result = await resource.read()
- assert isinstance(result, dict)
- assert result["page"] == 5
- assert result["type"] == "int"
-
- 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"},
- )
- # read() returns raw dict
- result = await resource.read()
- assert isinstance(result, dict)
- assert result["enabled"] is True
-
- # Test false value
- resource = await template.create_resource(
- "config://feature?enabled=false",
- {"name": "feature", "enabled": "false"},
- )
- result = await resource.read()
- assert isinstance(result, dict)
- assert result["enabled"] is False
-
- 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"},
- )
-
- # read() returns raw dict
- result = await resource.read()
- assert isinstance(result, dict)
- assert result["threshold"] == 0.95
- assert result["type"] == "float"
-
-
-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"},
- )
-
- # read() returns raw dict
- result = await resource.read()
- assert isinstance(result, dict)
- assert result["format"] == "json"
- assert result["verbose"] is False
-
- 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"},
- )
-
- # read() returns raw dict
- result = await resource.read()
- assert isinstance(result, dict)
- assert result["format"] == "json" # default
- assert result["limit"] == 20 # provided
- assert result["offset"] == 0 # 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"},
- )
-
- # read() returns raw dict
- result = await resource.read()
- assert isinstance(result, dict)
- assert result["path"] == "src/test/data.txt"
- assert result["encoding"] == "utf-8" # default
- assert result["lines"] == 50 # provided
-
-
-class TestResourceTemplateFieldDefaults:
- """Test resource templates with Field() defaults."""
-
- async def test_field_with_default(self):
- """Test that Field(default=...) correctly provides default values in resource templates."""
- from pydantic import Field
-
- def get_data(
- id: str = Field(description="Resource ID"),
- format: str = Field(default="json", description="Output format"),
- ) -> str:
- return f"id={id}, format={format}"
-
- template = ResourceTemplate.from_function(
- fn=get_data,
- uri_template="data://{id}{?format}",
- name="test",
- )
-
- # Test with only required parameter
- resource = await template.create_resource("data://123", {"id": "123"})
- result = await resource.read()
- assert result == "id=123, format=json"
-
- # Test with override
- resource = await template.create_resource(
- "data://123?format=xml", {"id": "123", "format": "xml"}
- )
- result = await resource.read()
- assert result == "id=123, format=xml"
-
- async def test_multiple_field_defaults(self):
- """Test multiple query parameters with Field() defaults."""
- from typing import Any
-
- from pydantic import Field
-
- def fetch_data(
- resource_id: str = Field(description="Resource ID"),
- limit: int = Field(default=10, description="Result limit"),
- offset: int = Field(default=0, description="Result offset"),
- format: str = Field(default="json", description="Output format"),
- ) -> dict[str, Any]:
- return {
- "resource_id": resource_id,
- "limit": limit,
- "offset": offset,
- "format": format,
- }
-
- template = ResourceTemplate.from_function(
- fn=fetch_data,
- uri_template="api://{resource_id}{?limit,offset,format}",
- name="test",
- )
-
- # Test with only required parameter - all defaults should apply
- resource1 = await template.create_resource(
- "api://user123", {"resource_id": "user123"}
- )
- result1 = await resource1.read()
- assert isinstance(result1, dict)
- assert result1["resource_id"] == "user123"
- assert result1["limit"] == 10
- assert result1["offset"] == 0
- assert result1["format"] == "json"
-
- # Test with some overrides
- resource2 = await template.create_resource(
- "api://user123?limit=50&format=xml",
- {"resource_id": "user123", "limit": "50", "format": "xml"},
- )
- result2 = await resource2.read()
- assert isinstance(result2, dict)
- assert result2["resource_id"] == "user123"
- assert result2["limit"] == 50 # overridden
- assert result2["offset"] == 0 # default
- assert result2["format"] == "xml" # overridden
diff --git a/tests/resources/test_resource_template_query_params.py b/tests/resources/test_resource_template_query_params.py
new file mode 100644
index 000000000..d68025f66
--- /dev/null
+++ b/tests/resources/test_resource_template_query_params.py
@@ -0,0 +1,343 @@
+import pytest
+
+from fastmcp.resources import ResourceTemplate
+
+
+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"},
+ )
+
+ # read() returns raw dict
+ result = await resource.read()
+ assert isinstance(result, dict)
+ assert result["page"] == 5
+ assert result["type"] == "int"
+
+ 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"},
+ )
+ # read() returns raw dict
+ result = await resource.read()
+ assert isinstance(result, dict)
+ assert result["enabled"] is True
+
+ # Test false value
+ resource = await template.create_resource(
+ "config://feature?enabled=false",
+ {"name": "feature", "enabled": "false"},
+ )
+ result = await resource.read()
+ assert isinstance(result, dict)
+ assert result["enabled"] is False
+
+ 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"},
+ )
+
+ # read() returns raw dict
+ result = await resource.read()
+ assert isinstance(result, dict)
+ assert result["threshold"] == 0.95
+ assert result["type"] == "float"
+
+
+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"},
+ )
+
+ # read() returns raw dict
+ result = await resource.read()
+ assert isinstance(result, dict)
+ assert result["format"] == "json"
+ assert result["verbose"] is False
+
+ 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"},
+ )
+
+ # read() returns raw dict
+ result = await resource.read()
+ assert isinstance(result, dict)
+ assert result["format"] == "json" # default
+ assert result["limit"] == 20 # provided
+ assert result["offset"] == 0 # 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"},
+ )
+
+ # read() returns raw dict
+ result = await resource.read()
+ assert isinstance(result, dict)
+ assert result["path"] == "src/test/data.txt"
+ assert result["encoding"] == "utf-8" # default
+ assert result["lines"] == 50 # provided
+
+
+class TestResourceTemplateFieldDefaults:
+ """Test resource templates with Field() defaults."""
+
+ async def test_field_with_default(self):
+ """Test that Field(default=...) correctly provides default values in resource templates."""
+ from pydantic import Field
+
+ def get_data(
+ id: str = Field(description="Resource ID"),
+ format: str = Field(default="json", description="Output format"),
+ ) -> str:
+ return f"id={id}, format={format}"
+
+ template = ResourceTemplate.from_function(
+ fn=get_data,
+ uri_template="data://{id}{?format}",
+ name="test",
+ )
+
+ # Test with only required parameter
+ resource = await template.create_resource("data://123", {"id": "123"})
+ result = await resource.read()
+ assert result == "id=123, format=json"
+
+ # Test with override
+ resource = await template.create_resource(
+ "data://123?format=xml", {"id": "123", "format": "xml"}
+ )
+ result = await resource.read()
+ assert result == "id=123, format=xml"
+
+ async def test_multiple_field_defaults(self):
+ """Test multiple query parameters with Field() defaults."""
+ from typing import Any
+
+ from pydantic import Field
+
+ def fetch_data(
+ resource_id: str = Field(description="Resource ID"),
+ limit: int = Field(default=10, description="Result limit"),
+ offset: int = Field(default=0, description="Result offset"),
+ format: str = Field(default="json", description="Output format"),
+ ) -> dict[str, Any]:
+ return {
+ "resource_id": resource_id,
+ "limit": limit,
+ "offset": offset,
+ "format": format,
+ }
+
+ template = ResourceTemplate.from_function(
+ fn=fetch_data,
+ uri_template="api://{resource_id}{?limit,offset,format}",
+ name="test",
+ )
+
+ # Test with only required parameter - all defaults should apply
+ resource1 = await template.create_resource(
+ "api://user123", {"resource_id": "user123"}
+ )
+ result1 = await resource1.read()
+ assert isinstance(result1, dict)
+ assert result1["resource_id"] == "user123"
+ assert result1["limit"] == 10
+ assert result1["offset"] == 0
+ assert result1["format"] == "json"
+
+ # Test with some overrides
+ resource2 = await template.create_resource(
+ "api://user123?limit=50&format=xml",
+ {"resource_id": "user123", "limit": "50", "format": "xml"},
+ )
+ result2 = await resource2.read()
+ assert isinstance(result2, dict)
+ assert result2["resource_id"] == "user123"
+ assert result2["limit"] == 50 # overridden
+ assert result2["offset"] == 0 # default
+ assert result2["format"] == "xml" # overridden
diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py
index c64902aab..3d01c926d 100644
--- a/tests/server/auth/providers/test_azure.py
+++ b/tests/server/auth/providers/test_azure.py
@@ -8,12 +8,8 @@ from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
-from fastmcp.server.auth.providers.azure import (
- OIDC_SCOPES,
- AzureJWTVerifier,
- AzureProvider,
-)
-from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
+from fastmcp.server.auth.providers.azure import AzureProvider
+from fastmcp.server.auth.providers.jwt import JWTVerifier
@pytest.fixture
@@ -738,680 +734,3 @@ class TestAzureProvider:
# Should have 3 items (read deduplicated, plus offline_access)
assert len(result) == 3
assert result.count("api://my-api/read") == 1
-
-
-class TestOIDCScopeHandling:
- """Tests for OIDC scope handling in Azure provider.
-
- Azure access tokens do NOT include OIDC scopes (openid, profile, email,
- offline_access) in the `scp` claim - they're only used during authorization.
- These tests verify that:
- 1. OIDC scopes are never prefixed with identifier_uri
- 2. OIDC scopes are filtered from token validation
- 3. OIDC scopes are still advertised to clients via valid_scopes
- """
-
- def test_oidc_scopes_constant(self, memory_storage: MemoryStore):
- """Verify OIDC_SCOPES contains the standard OIDC scopes."""
- assert OIDC_SCOPES == {"openid", "profile", "email", "offline_access"}
-
- def test_prefix_scopes_does_not_prefix_oidc_scopes(
- self, memory_storage: MemoryStore
- ):
- """Test that _prefix_scopes_for_azure never prefixes OIDC scopes."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- # All OIDC scopes should pass through unchanged
- result = provider._prefix_scopes_for_azure(
- ["openid", "profile", "email", "offline_access"]
- )
-
- assert result == ["openid", "profile", "email", "offline_access"]
-
- def test_prefix_scopes_mixed_oidc_and_custom(self, memory_storage: MemoryStore):
- """Test prefixing with a mix of OIDC and custom scopes."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- result = provider._prefix_scopes_for_azure(
- ["read", "openid", "write", "profile"]
- )
-
- # Custom scopes should be prefixed, OIDC scopes should not
- assert "api://my-api/read" in result
- assert "api://my-api/write" in result
- assert "openid" in result
- assert "profile" in result
- # Verify OIDC scopes are NOT prefixed
- assert "api://my-api/openid" not in result
- assert "api://my-api/profile" not in result
-
- def test_prefix_scopes_dot_notation_gets_prefixed(
- self, memory_storage: MemoryStore
- ):
- """Test that dot-notation scopes get prefixed (use additional_authorize_scopes for Graph)."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- # Dot-notation scopes ARE prefixed - use additional_authorize_scopes for Graph
- # or fully-qualified format like https://graph.microsoft.com/User.Read
- result = provider._prefix_scopes_for_azure(["my.scope", "admin.read"])
-
- assert result == ["api://my-api/my.scope", "api://my-api/admin.read"]
-
- def test_prefix_scopes_fully_qualified_graph_not_prefixed(
- self, memory_storage: MemoryStore
- ):
- """Test that fully-qualified Graph scopes are not prefixed."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- result = provider._prefix_scopes_for_azure(
- [
- "https://graph.microsoft.com/User.Read",
- "https://graph.microsoft.com/Mail.Send",
- ]
- )
-
- # Fully-qualified URIs pass through unchanged
- assert result == [
- "https://graph.microsoft.com/User.Read",
- "https://graph.microsoft.com/Mail.Send",
- ]
-
- def test_required_scopes_with_oidc_filters_validation(
- self, memory_storage: MemoryStore
- ):
- """Test that OIDC scopes in required_scopes are filtered from token validation."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read", "openid", "profile"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- # Token validator should only require non-OIDC scopes
- assert provider._token_validator.required_scopes == ["read"]
-
- def test_required_scopes_all_oidc_results_in_no_validation(
- self, memory_storage: MemoryStore
- ):
- """Test that if all required_scopes are OIDC, no scope validation occurs."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["openid", "profile"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- # Token validator should have empty required scopes (all were OIDC)
- assert provider._token_validator.required_scopes == []
-
- def test_valid_scopes_includes_oidc_scopes(self, memory_storage: MemoryStore):
- """Test that valid_scopes advertises OIDC scopes to clients."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read", "openid", "profile"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- # required_scopes (used for validation) excludes OIDC scopes
- assert provider.required_scopes == ["read"]
- # But valid_scopes (advertised to clients) includes all scopes
- assert provider.client_registration_options is not None
- assert provider.client_registration_options.valid_scopes == [
- "read",
- "openid",
- "profile",
- ]
-
- def test_prepare_scopes_for_refresh_handles_oidc_scopes(
- self, memory_storage: MemoryStore
- ):
- """Test that token refresh correctly handles OIDC scopes."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- # Simulate stored scopes that include OIDC scopes
- result = provider._prepare_scopes_for_upstream_refresh(
- ["read", "openid", "profile"]
- )
-
- # Custom scope should be prefixed, OIDC scopes should not
- assert "api://my-api/read" in result
- assert "openid" in result
- assert "profile" in result
- assert "api://my-api/openid" not in result
- assert "api://my-api/profile" not in result
-
-
-class TestAzureTokenExchangeScopes:
- """Tests for Azure provider's token exchange scope handling.
-
- Azure requires scopes to be sent during the authorization code exchange.
- The provider overrides _prepare_scopes_for_token_exchange to return
- properly prefixed scopes.
- """
-
- def test_prepare_scopes_returns_prefixed_scopes(self, memory_storage: MemoryStore):
- """Test that _prepare_scopes_for_token_exchange returns prefixed scopes."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read", "write"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- scopes = provider._prepare_scopes_for_token_exchange(["read", "write"])
- assert len(scopes) > 0
- assert "api://my-api/read" in scopes
- assert "api://my-api/write" in scopes
-
- def test_prepare_scopes_includes_additional_oidc_scopes(
- self, memory_storage: MemoryStore
- ):
- """Test that _prepare_scopes_for_token_exchange includes OIDC scopes."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read"],
- additional_authorize_scopes=["openid", "profile", "offline_access"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- scopes = provider._prepare_scopes_for_token_exchange(["read"])
- assert len(scopes) > 0
- assert "api://my-api/read" in scopes
- assert "openid" in scopes
- assert "profile" in scopes
- assert "offline_access" in scopes
-
- def test_prepare_scopes_excludes_other_api_scopes(
- self, memory_storage: MemoryStore
- ):
- """Test token exchange excludes other API scopes (Azure AADSTS28000).
-
- Azure only allows ONE resource per token exchange. Other API scopes
- are requested during authorization but excluded from token exchange.
- """
- provider = AzureProvider(
- client_id="00000000-1111-2222-3333-444444444444",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- required_scopes=["user_impersonation"],
- additional_authorize_scopes=[
- "openid",
- "profile",
- "offline_access",
- "api://aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/user_impersonation",
- "api://11111111-2222-3333-4444-555555555555/user_impersonation",
- ],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- scopes = provider._prepare_scopes_for_token_exchange(["user_impersonation"])
- assert len(scopes) > 0
- # Primary API scope should be prefixed with the provider's identifier_uri
- assert "api://00000000-1111-2222-3333-444444444444/user_impersonation" in scopes
- # OIDC scopes should be included
- assert "openid" in scopes
- assert "profile" in scopes
- assert "offline_access" in scopes
- # Other API scopes should NOT be included (Azure multi-resource limitation)
- assert not any("api://aaaaaaaa" in s for s in scopes)
- assert not any("api://11111111" in s for s in scopes)
-
- def test_prepare_scopes_deduplicates_scopes(self, memory_storage: MemoryStore):
- """Test that duplicate scopes are deduplicated."""
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read"],
- additional_authorize_scopes=["api://my-api/read", "openid"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- # Pass a scope that will be prefixed to match one in additional_authorize_scopes
- scopes = provider._prepare_scopes_for_token_exchange(["read"])
- assert len(scopes) > 0
- # Should be deduplicated - api://my-api/read appears only once
- assert scopes.count("api://my-api/read") == 1
- assert "openid" in scopes
-
- def test_extra_token_params_does_not_contain_scope(
- self, memory_storage: MemoryStore
- ):
- """Test that extra_token_params doesn't contain scope to avoid TypeError.
-
- Previously, Azure provider set extra_token_params={"scope": ...} during init.
- This caused a TypeError in exchange_refresh_token because it passes both
- scope=... AND **self._extra_token_params, resulting in:
- "got multiple values for keyword argument 'scope'"
-
- The fix uses the _prepare_scopes_for_token_exchange hook instead.
- """
- provider = AzureProvider(
- client_id="test_client",
- client_secret="test_secret",
- tenant_id="test-tenant",
- base_url="https://myserver.com",
- identifier_uri="api://my-api",
- required_scopes=["read", "write"],
- additional_authorize_scopes=["openid", "profile", "offline_access"],
- jwt_signing_key="test-secret",
- client_storage=memory_storage,
- )
-
- # extra_token_params should NOT contain "scope" to avoid TypeError during refresh
- assert "scope" not in provider._extra_token_params
-
- # Instead, scopes should be provided via the hook methods
- exchange_scopes = provider._prepare_scopes_for_token_exchange(["read", "write"])
- assert len(exchange_scopes) > 0
-
- refresh_scopes = provider._prepare_scopes_for_upstream_refresh(
- ["read", "write"]
- )
- assert len(refresh_scopes) > 0
-
-
-class TestAzureJWTVerifier:
- """Tests for AzureJWTVerifier pre-configured JWT verifier."""
-
- def test_auto_configures_from_client_and_tenant(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="my-tenant-id",
- required_scopes=["access_as_user"],
- )
- assert (
- verifier.jwks_uri
- == "https://login.microsoftonline.com/my-tenant-id/discovery/v2.0/keys"
- )
- assert verifier.issuer == "https://login.microsoftonline.com/my-tenant-id/v2.0"
- assert verifier.audience == "my-client-id"
- assert verifier.algorithm == "RS256"
- assert verifier.required_scopes == ["access_as_user"]
-
- async def test_validates_short_form_scopes(self):
- key_pair = RSAKeyPair.generate()
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="my-tenant-id",
- required_scopes=["access_as_user"],
- )
- # Override to use our test key instead of JWKS
- verifier.public_key = key_pair.public_key
- verifier.jwks_uri = None
-
- token = key_pair.create_token(
- subject="test-user",
- issuer="https://login.microsoftonline.com/my-tenant-id/v2.0",
- audience="my-client-id",
- additional_claims={"scp": "access_as_user"},
- )
- result = await verifier.load_access_token(token)
- assert result is not None
- assert "access_as_user" in result.scopes
-
- def test_scopes_supported_returns_prefixed_form(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="my-tenant-id",
- required_scopes=["read", "write"],
- )
- assert verifier.scopes_supported == [
- "api://my-client-id/read",
- "api://my-client-id/write",
- ]
-
- def test_already_prefixed_scopes_pass_through(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="my-tenant-id",
- required_scopes=["api://my-client-id/read"],
- )
- assert verifier.scopes_supported == ["api://my-client-id/read"]
-
- def test_oidc_scopes_not_prefixed(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="my-tenant-id",
- required_scopes=["openid", "read"],
- )
- assert verifier.scopes_supported == ["openid", "api://my-client-id/read"]
-
- def test_custom_identifier_uri(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="my-tenant-id",
- required_scopes=["read"],
- identifier_uri="api://custom-uri",
- )
- assert verifier.scopes_supported == ["api://custom-uri/read"]
-
- def test_custom_base_authority_for_gov_cloud(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="my-tenant-id",
- required_scopes=["read"],
- base_authority="login.microsoftonline.us",
- )
- assert (
- verifier.jwks_uri
- == "https://login.microsoftonline.us/my-tenant-id/discovery/v2.0/keys"
- )
- assert verifier.issuer == "https://login.microsoftonline.us/my-tenant-id/v2.0"
-
- def test_scopes_supported_empty_when_no_required_scopes(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="my-tenant-id",
- )
- assert verifier.scopes_supported == []
-
- def test_default_identifier_uri_uses_client_id(self):
- verifier = AzureJWTVerifier(
- client_id="abc-123",
- tenant_id="my-tenant-id",
- required_scopes=["read"],
- )
- assert verifier.scopes_supported == ["api://abc-123/read"]
-
- def test_multi_tenant_organizations_skips_issuer(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="organizations",
- )
- assert verifier.issuer is None
-
- def test_multi_tenant_consumers_skips_issuer(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="consumers",
- )
- assert verifier.issuer is None
-
- def test_multi_tenant_common_skips_issuer(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="common",
- )
- assert verifier.issuer is None
-
- def test_specific_tenant_sets_issuer(self):
- verifier = AzureJWTVerifier(
- client_id="my-client-id",
- tenant_id="12345678-1234-1234-1234-123456789012",
- )
- assert (
- verifier.issuer
- == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0"
- )
-
-
-class TestAzureOBOIntegration:
- """Tests for azure.identity OBO integration (get_obo_credential, EntraOBOToken)."""
-
- async def test_get_obo_credential_returns_configured_credential(self):
- """Test that get_obo_credential returns a properly configured credential."""
- from unittest.mock import MagicMock, patch
-
- provider = AzureProvider(
- client_id="test-client-id",
- client_secret="test-client-secret",
- tenant_id="test-tenant-id",
- base_url="https://myserver.com",
- required_scopes=["read"],
- jwt_signing_key="test-secret",
- )
-
- mock_credential = MagicMock()
- with patch(
- "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential
- ) as mock_class:
- credential = await provider.get_obo_credential(
- user_assertion="user-token-123"
- )
-
- mock_class.assert_called_once_with(
- tenant_id="test-tenant-id",
- client_id="test-client-id",
- client_secret="test-client-secret",
- user_assertion="user-token-123",
- authority="https://login.microsoftonline.com",
- )
- assert credential is mock_credential
-
- async def test_get_obo_credential_caches_by_assertion(self):
- """Test that the same assertion returns the cached credential."""
- from unittest.mock import MagicMock, patch
-
- provider = AzureProvider(
- client_id="test-client-id",
- client_secret="test-client-secret",
- tenant_id="test-tenant-id",
- base_url="https://myserver.com",
- required_scopes=["read"],
- jwt_signing_key="test-secret",
- )
-
- mock_credential = MagicMock()
- with patch(
- "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential
- ) as mock_class:
- first = await provider.get_obo_credential(user_assertion="same-token")
- second = await provider.get_obo_credential(user_assertion="same-token")
-
- assert first is second
- mock_class.assert_called_once()
-
- async def test_get_obo_credential_different_assertions_get_different_credentials(
- self,
- ):
- """Test that different assertions produce different credentials."""
- from unittest.mock import MagicMock, patch
-
- provider = AzureProvider(
- client_id="test-client-id",
- client_secret="test-client-secret",
- tenant_id="test-tenant-id",
- base_url="https://myserver.com",
- required_scopes=["read"],
- jwt_signing_key="test-secret",
- )
-
- creds = [MagicMock(), MagicMock()]
- with patch("azure.identity.aio.OnBehalfOfCredential", side_effect=creds):
- first = await provider.get_obo_credential(user_assertion="token-a")
- second = await provider.get_obo_credential(user_assertion="token-b")
-
- assert first is not second
- assert first is creds[0]
- assert second is creds[1]
-
- async def test_get_obo_credential_evicts_oldest_when_over_capacity(self):
- """Test that credentials are evicted LRU-style when cache is full."""
- from unittest.mock import AsyncMock, MagicMock, patch
-
- provider = AzureProvider(
- client_id="test-client-id",
- client_secret="test-client-secret",
- tenant_id="test-tenant-id",
- base_url="https://myserver.com",
- required_scopes=["read"],
- jwt_signing_key="test-secret",
- )
- provider._obo_max_credentials = 2
-
- creds = [MagicMock(close=AsyncMock()) for _ in range(3)]
- with patch("azure.identity.aio.OnBehalfOfCredential", side_effect=creds):
- await provider.get_obo_credential(user_assertion="token-1")
- await provider.get_obo_credential(user_assertion="token-2")
- await provider.get_obo_credential(user_assertion="token-3")
-
- assert len(provider._obo_credentials) == 2
- creds[0].close.assert_awaited_once()
- # token-1's credential was evicted
- assert (
- await provider.get_obo_credential(user_assertion="token-2") is creds[1]
- )
- assert (
- await provider.get_obo_credential(user_assertion="token-3") is creds[2]
- )
-
- async def test_close_obo_credentials(self):
- """Test that close_obo_credentials closes all cached credentials."""
- from unittest.mock import AsyncMock, MagicMock, patch
-
- provider = AzureProvider(
- client_id="test-client-id",
- client_secret="test-client-secret",
- tenant_id="test-tenant-id",
- base_url="https://myserver.com",
- required_scopes=["read"],
- jwt_signing_key="test-secret",
- )
-
- creds = [MagicMock(close=AsyncMock()) for _ in range(2)]
- with patch("azure.identity.aio.OnBehalfOfCredential", side_effect=creds):
- await provider.get_obo_credential(user_assertion="token-a")
- await provider.get_obo_credential(user_assertion="token-b")
-
- await provider.close_obo_credentials()
-
- assert len(provider._obo_credentials) == 0
- for cred in creds:
- cred.close.assert_awaited_once()
-
- async def test_get_obo_credential_with_custom_authority(self):
- """Test that get_obo_credential uses custom base_authority."""
- from unittest.mock import MagicMock, patch
-
- provider = AzureProvider(
- client_id="test-client-id",
- client_secret="test-client-secret",
- tenant_id="gov-tenant-id",
- base_url="https://myserver.com",
- required_scopes=["read"],
- base_authority="login.microsoftonline.us",
- jwt_signing_key="test-secret",
- )
-
- mock_credential = MagicMock()
- with patch(
- "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential
- ) as mock_class:
- await provider.get_obo_credential(user_assertion="user-token")
-
- call_kwargs = mock_class.call_args[1]
- assert call_kwargs["authority"] == "https://login.microsoftonline.us"
-
- def test_tenant_and_authority_stored_as_attributes(self):
- """Test that tenant_id and base_authority are stored for OBO credential creation."""
- provider = AzureProvider(
- client_id="test-client-id",
- client_secret="test-client-secret",
- tenant_id="my-tenant",
- base_url="https://myserver.com",
- required_scopes=["read"],
- base_authority="login.microsoftonline.us",
- jwt_signing_key="test-secret",
- )
-
- assert provider._tenant_id == "my-tenant"
- assert provider._base_authority == "login.microsoftonline.us"
-
- def test_entra_obo_token_is_importable(self):
- """Test that EntraOBOToken can be imported."""
- from fastmcp.server.auth.providers.azure import EntraOBOToken
-
- assert EntraOBOToken is not None
-
- def test_entra_obo_token_creates_dependency(self):
- """Test that EntraOBOToken creates a dependency with scopes."""
- from fastmcp.server.auth.providers.azure import EntraOBOToken, _EntraOBOToken
-
- dep = EntraOBOToken(["https://graph.microsoft.com/User.Read"])
- assert isinstance(dep, _EntraOBOToken)
- assert dep.scopes == ["https://graph.microsoft.com/User.Read"]
-
- def test_entra_obo_token_is_dependency_instance(self):
- """Test that EntraOBOToken is a Dependency instance."""
- try:
- from docket.dependencies import Dependency
- except ImportError:
- from fastmcp._vendor.docket_di import Dependency
-
- from fastmcp.server.auth.providers.azure import _EntraOBOToken
-
- dep = _EntraOBOToken(["scope"])
- assert isinstance(dep, Dependency)
diff --git a/tests/server/auth/providers/test_azure_scopes.py b/tests/server/auth/providers/test_azure_scopes.py
new file mode 100644
index 000000000..9f90def35
--- /dev/null
+++ b/tests/server/auth/providers/test_azure_scopes.py
@@ -0,0 +1,690 @@
+"""Tests for Azure provider scope handling, JWT verifier, and OBO integration."""
+
+import pytest
+from key_value.aio.stores.memory import MemoryStore
+
+from fastmcp.server.auth.providers.azure import (
+ OIDC_SCOPES,
+ AzureJWTVerifier,
+ AzureProvider,
+)
+from fastmcp.server.auth.providers.jwt import RSAKeyPair
+
+
+@pytest.fixture
+def memory_storage() -> MemoryStore:
+ """Provide a MemoryStore for tests to avoid SQLite initialization on Windows."""
+ return MemoryStore()
+
+
+class TestOIDCScopeHandling:
+ """Tests for OIDC scope handling in Azure provider.
+
+ Azure access tokens do NOT include OIDC scopes (openid, profile, email,
+ offline_access) in the `scp` claim - they're only used during authorization.
+ These tests verify that:
+ 1. OIDC scopes are never prefixed with identifier_uri
+ 2. OIDC scopes are filtered from token validation
+ 3. OIDC scopes are still advertised to clients via valid_scopes
+ """
+
+ def test_oidc_scopes_constant(self, memory_storage: MemoryStore):
+ """Verify OIDC_SCOPES contains the standard OIDC scopes."""
+ assert OIDC_SCOPES == {"openid", "profile", "email", "offline_access"}
+
+ def test_prefix_scopes_does_not_prefix_oidc_scopes(
+ self, memory_storage: MemoryStore
+ ):
+ """Test that _prefix_scopes_for_azure never prefixes OIDC scopes."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ # All OIDC scopes should pass through unchanged
+ result = provider._prefix_scopes_for_azure(
+ ["openid", "profile", "email", "offline_access"]
+ )
+
+ assert result == ["openid", "profile", "email", "offline_access"]
+
+ def test_prefix_scopes_mixed_oidc_and_custom(self, memory_storage: MemoryStore):
+ """Test prefixing with a mix of OIDC and custom scopes."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ result = provider._prefix_scopes_for_azure(
+ ["read", "openid", "write", "profile"]
+ )
+
+ # Custom scopes should be prefixed, OIDC scopes should not
+ assert "api://my-api/read" in result
+ assert "api://my-api/write" in result
+ assert "openid" in result
+ assert "profile" in result
+ # Verify OIDC scopes are NOT prefixed
+ assert "api://my-api/openid" not in result
+ assert "api://my-api/profile" not in result
+
+ def test_prefix_scopes_dot_notation_gets_prefixed(
+ self, memory_storage: MemoryStore
+ ):
+ """Test that dot-notation scopes get prefixed (use additional_authorize_scopes for Graph)."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ # Dot-notation scopes ARE prefixed - use additional_authorize_scopes for Graph
+ # or fully-qualified format like https://graph.microsoft.com/User.Read
+ result = provider._prefix_scopes_for_azure(["my.scope", "admin.read"])
+
+ assert result == ["api://my-api/my.scope", "api://my-api/admin.read"]
+
+ def test_prefix_scopes_fully_qualified_graph_not_prefixed(
+ self, memory_storage: MemoryStore
+ ):
+ """Test that fully-qualified Graph scopes are not prefixed."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ result = provider._prefix_scopes_for_azure(
+ [
+ "https://graph.microsoft.com/User.Read",
+ "https://graph.microsoft.com/Mail.Send",
+ ]
+ )
+
+ # Fully-qualified URIs pass through unchanged
+ assert result == [
+ "https://graph.microsoft.com/User.Read",
+ "https://graph.microsoft.com/Mail.Send",
+ ]
+
+ def test_required_scopes_with_oidc_filters_validation(
+ self, memory_storage: MemoryStore
+ ):
+ """Test that OIDC scopes in required_scopes are filtered from token validation."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read", "openid", "profile"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ # Token validator should only require non-OIDC scopes
+ assert provider._token_validator.required_scopes == ["read"]
+
+ def test_required_scopes_all_oidc_results_in_no_validation(
+ self, memory_storage: MemoryStore
+ ):
+ """Test that if all required_scopes are OIDC, no scope validation occurs."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["openid", "profile"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ # Token validator should have empty required scopes (all were OIDC)
+ assert provider._token_validator.required_scopes == []
+
+ def test_valid_scopes_includes_oidc_scopes(self, memory_storage: MemoryStore):
+ """Test that valid_scopes advertises OIDC scopes to clients."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read", "openid", "profile"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ # required_scopes (used for validation) excludes OIDC scopes
+ assert provider.required_scopes == ["read"]
+ # But valid_scopes (advertised to clients) includes all scopes
+ assert provider.client_registration_options is not None
+ assert provider.client_registration_options.valid_scopes == [
+ "read",
+ "openid",
+ "profile",
+ ]
+
+ def test_prepare_scopes_for_refresh_handles_oidc_scopes(
+ self, memory_storage: MemoryStore
+ ):
+ """Test that token refresh correctly handles OIDC scopes."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ # Simulate stored scopes that include OIDC scopes
+ result = provider._prepare_scopes_for_upstream_refresh(
+ ["read", "openid", "profile"]
+ )
+
+ # Custom scope should be prefixed, OIDC scopes should not
+ assert "api://my-api/read" in result
+ assert "openid" in result
+ assert "profile" in result
+ assert "api://my-api/openid" not in result
+ assert "api://my-api/profile" not in result
+
+
+class TestAzureTokenExchangeScopes:
+ """Tests for Azure provider's token exchange scope handling.
+
+ Azure requires scopes to be sent during the authorization code exchange.
+ The provider overrides _prepare_scopes_for_token_exchange to return
+ properly prefixed scopes.
+ """
+
+ def test_prepare_scopes_returns_prefixed_scopes(self, memory_storage: MemoryStore):
+ """Test that _prepare_scopes_for_token_exchange returns prefixed scopes."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read", "write"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ scopes = provider._prepare_scopes_for_token_exchange(["read", "write"])
+ assert len(scopes) > 0
+ assert "api://my-api/read" in scopes
+ assert "api://my-api/write" in scopes
+
+ def test_prepare_scopes_includes_additional_oidc_scopes(
+ self, memory_storage: MemoryStore
+ ):
+ """Test that _prepare_scopes_for_token_exchange includes OIDC scopes."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ additional_authorize_scopes=["openid", "profile", "offline_access"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ scopes = provider._prepare_scopes_for_token_exchange(["read"])
+ assert len(scopes) > 0
+ assert "api://my-api/read" in scopes
+ assert "openid" in scopes
+ assert "profile" in scopes
+ assert "offline_access" in scopes
+
+ def test_prepare_scopes_excludes_other_api_scopes(
+ self, memory_storage: MemoryStore
+ ):
+ """Test token exchange excludes other API scopes (Azure AADSTS28000).
+
+ Azure only allows ONE resource per token exchange. Other API scopes
+ are requested during authorization but excluded from token exchange.
+ """
+ provider = AzureProvider(
+ client_id="00000000-1111-2222-3333-444444444444",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ required_scopes=["user_impersonation"],
+ additional_authorize_scopes=[
+ "openid",
+ "profile",
+ "offline_access",
+ "api://aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/user_impersonation",
+ "api://11111111-2222-3333-4444-555555555555/user_impersonation",
+ ],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ scopes = provider._prepare_scopes_for_token_exchange(["user_impersonation"])
+ assert len(scopes) > 0
+ # Primary API scope should be prefixed with the provider's identifier_uri
+ assert "api://00000000-1111-2222-3333-444444444444/user_impersonation" in scopes
+ # OIDC scopes should be included
+ assert "openid" in scopes
+ assert "profile" in scopes
+ assert "offline_access" in scopes
+ # Other API scopes should NOT be included (Azure multi-resource limitation)
+ assert not any("api://aaaaaaaa" in s for s in scopes)
+ assert not any("api://11111111" in s for s in scopes)
+
+ def test_prepare_scopes_deduplicates_scopes(self, memory_storage: MemoryStore):
+ """Test that duplicate scopes are deduplicated."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ additional_authorize_scopes=["api://my-api/read", "openid"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ # Pass a scope that will be prefixed to match one in additional_authorize_scopes
+ scopes = provider._prepare_scopes_for_token_exchange(["read"])
+ assert len(scopes) > 0
+ # Should be deduplicated - api://my-api/read appears only once
+ assert scopes.count("api://my-api/read") == 1
+ assert "openid" in scopes
+
+ def test_extra_token_params_does_not_contain_scope(
+ self, memory_storage: MemoryStore
+ ):
+ """Test that extra_token_params doesn't contain scope to avoid TypeError.
+
+ Previously, Azure provider set extra_token_params={"scope": ...} during init.
+ This caused a TypeError in exchange_refresh_token because it passes both
+ scope=... AND **self._extra_token_params, resulting in:
+ "got multiple values for keyword argument 'scope'"
+
+ The fix uses the _prepare_scopes_for_token_exchange hook instead.
+ """
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ base_url="https://myserver.com",
+ identifier_uri="api://my-api",
+ required_scopes=["read", "write"],
+ additional_authorize_scopes=["openid", "profile", "offline_access"],
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ # extra_token_params should NOT contain "scope" to avoid TypeError during refresh
+ assert "scope" not in provider._extra_token_params
+
+ # Instead, scopes should be provided via the hook methods
+ exchange_scopes = provider._prepare_scopes_for_token_exchange(["read", "write"])
+ assert len(exchange_scopes) > 0
+
+ refresh_scopes = provider._prepare_scopes_for_upstream_refresh(
+ ["read", "write"]
+ )
+ assert len(refresh_scopes) > 0
+
+
+class TestAzureJWTVerifier:
+ """Tests for AzureJWTVerifier pre-configured JWT verifier."""
+
+ def test_auto_configures_from_client_and_tenant(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["access_as_user"],
+ )
+ assert (
+ verifier.jwks_uri
+ == "https://login.microsoftonline.com/my-tenant-id/discovery/v2.0/keys"
+ )
+ assert verifier.issuer == "https://login.microsoftonline.com/my-tenant-id/v2.0"
+ assert verifier.audience == "my-client-id"
+ assert verifier.algorithm == "RS256"
+ assert verifier.required_scopes == ["access_as_user"]
+
+ async def test_validates_short_form_scopes(self):
+ key_pair = RSAKeyPair.generate()
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["access_as_user"],
+ )
+ # Override to use our test key instead of JWKS
+ verifier.public_key = key_pair.public_key
+ verifier.jwks_uri = None
+
+ token = key_pair.create_token(
+ subject="test-user",
+ issuer="https://login.microsoftonline.com/my-tenant-id/v2.0",
+ audience="my-client-id",
+ additional_claims={"scp": "access_as_user"},
+ )
+ result = await verifier.load_access_token(token)
+ assert result is not None
+ assert "access_as_user" in result.scopes
+
+ def test_scopes_supported_returns_prefixed_form(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["read", "write"],
+ )
+ assert verifier.scopes_supported == [
+ "api://my-client-id/read",
+ "api://my-client-id/write",
+ ]
+
+ def test_already_prefixed_scopes_pass_through(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["api://my-client-id/read"],
+ )
+ assert verifier.scopes_supported == ["api://my-client-id/read"]
+
+ def test_oidc_scopes_not_prefixed(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["openid", "read"],
+ )
+ assert verifier.scopes_supported == ["openid", "api://my-client-id/read"]
+
+ def test_custom_identifier_uri(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["read"],
+ identifier_uri="api://custom-uri",
+ )
+ assert verifier.scopes_supported == ["api://custom-uri/read"]
+
+ def test_custom_base_authority_for_gov_cloud(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["read"],
+ base_authority="login.microsoftonline.us",
+ )
+ assert (
+ verifier.jwks_uri
+ == "https://login.microsoftonline.us/my-tenant-id/discovery/v2.0/keys"
+ )
+ assert verifier.issuer == "https://login.microsoftonline.us/my-tenant-id/v2.0"
+
+ def test_scopes_supported_empty_when_no_required_scopes(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ )
+ assert verifier.scopes_supported == []
+
+ def test_default_identifier_uri_uses_client_id(self):
+ verifier = AzureJWTVerifier(
+ client_id="abc-123",
+ tenant_id="my-tenant-id",
+ required_scopes=["read"],
+ )
+ assert verifier.scopes_supported == ["api://abc-123/read"]
+
+ def test_multi_tenant_organizations_skips_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="organizations",
+ )
+ assert verifier.issuer is None
+
+ def test_multi_tenant_consumers_skips_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="consumers",
+ )
+ assert verifier.issuer is None
+
+ def test_multi_tenant_common_skips_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="common",
+ )
+ assert verifier.issuer is None
+
+ def test_specific_tenant_sets_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="12345678-1234-1234-1234-123456789012",
+ )
+ assert (
+ verifier.issuer
+ == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0"
+ )
+
+
+class TestAzureOBOIntegration:
+ """Tests for azure.identity OBO integration (get_obo_credential, EntraOBOToken)."""
+
+ async def test_get_obo_credential_returns_configured_credential(self):
+ """Test that get_obo_credential returns a properly configured credential."""
+ from unittest.mock import MagicMock, patch
+
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ tenant_id="test-tenant-id",
+ base_url="https://myserver.com",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+
+ mock_credential = MagicMock()
+ with patch(
+ "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential
+ ) as mock_class:
+ credential = await provider.get_obo_credential(
+ user_assertion="user-token-123"
+ )
+
+ mock_class.assert_called_once_with(
+ tenant_id="test-tenant-id",
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ user_assertion="user-token-123",
+ authority="https://login.microsoftonline.com",
+ )
+ assert credential is mock_credential
+
+ async def test_get_obo_credential_caches_by_assertion(self):
+ """Test that the same assertion returns the cached credential."""
+ from unittest.mock import MagicMock, patch
+
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ tenant_id="test-tenant-id",
+ base_url="https://myserver.com",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+
+ mock_credential = MagicMock()
+ with patch(
+ "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential
+ ) as mock_class:
+ first = await provider.get_obo_credential(user_assertion="same-token")
+ second = await provider.get_obo_credential(user_assertion="same-token")
+
+ assert first is second
+ mock_class.assert_called_once()
+
+ async def test_get_obo_credential_different_assertions_get_different_credentials(
+ self,
+ ):
+ """Test that different assertions produce different credentials."""
+ from unittest.mock import MagicMock, patch
+
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ tenant_id="test-tenant-id",
+ base_url="https://myserver.com",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+
+ creds = [MagicMock(), MagicMock()]
+ with patch("azure.identity.aio.OnBehalfOfCredential", side_effect=creds):
+ first = await provider.get_obo_credential(user_assertion="token-a")
+ second = await provider.get_obo_credential(user_assertion="token-b")
+
+ assert first is not second
+ assert first is creds[0]
+ assert second is creds[1]
+
+ async def test_get_obo_credential_evicts_oldest_when_over_capacity(self):
+ """Test that credentials are evicted LRU-style when cache is full."""
+ from unittest.mock import AsyncMock, MagicMock, patch
+
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ tenant_id="test-tenant-id",
+ base_url="https://myserver.com",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+ provider._obo_max_credentials = 2
+
+ creds = [MagicMock(close=AsyncMock()) for _ in range(3)]
+ with patch("azure.identity.aio.OnBehalfOfCredential", side_effect=creds):
+ await provider.get_obo_credential(user_assertion="token-1")
+ await provider.get_obo_credential(user_assertion="token-2")
+ await provider.get_obo_credential(user_assertion="token-3")
+
+ assert len(provider._obo_credentials) == 2
+ creds[0].close.assert_awaited_once()
+ # token-1's credential was evicted
+ assert (
+ await provider.get_obo_credential(user_assertion="token-2") is creds[1]
+ )
+ assert (
+ await provider.get_obo_credential(user_assertion="token-3") is creds[2]
+ )
+
+ async def test_close_obo_credentials(self):
+ """Test that close_obo_credentials closes all cached credentials."""
+ from unittest.mock import AsyncMock, MagicMock, patch
+
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ tenant_id="test-tenant-id",
+ base_url="https://myserver.com",
+ required_scopes=["read"],
+ jwt_signing_key="test-secret",
+ )
+
+ creds = [MagicMock(close=AsyncMock()) for _ in range(2)]
+ with patch("azure.identity.aio.OnBehalfOfCredential", side_effect=creds):
+ await provider.get_obo_credential(user_assertion="token-a")
+ await provider.get_obo_credential(user_assertion="token-b")
+
+ await provider.close_obo_credentials()
+
+ assert len(provider._obo_credentials) == 0
+ for cred in creds:
+ cred.close.assert_awaited_once()
+
+ async def test_get_obo_credential_with_custom_authority(self):
+ """Test that get_obo_credential uses custom base_authority."""
+ from unittest.mock import MagicMock, patch
+
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ tenant_id="gov-tenant-id",
+ base_url="https://myserver.com",
+ required_scopes=["read"],
+ base_authority="login.microsoftonline.us",
+ jwt_signing_key="test-secret",
+ )
+
+ mock_credential = MagicMock()
+ with patch(
+ "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential
+ ) as mock_class:
+ await provider.get_obo_credential(user_assertion="user-token")
+
+ call_kwargs = mock_class.call_args[1]
+ assert call_kwargs["authority"] == "https://login.microsoftonline.us"
+
+ def test_tenant_and_authority_stored_as_attributes(self):
+ """Test that tenant_id and base_authority are stored for OBO credential creation."""
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="test-client-secret",
+ tenant_id="my-tenant",
+ base_url="https://myserver.com",
+ required_scopes=["read"],
+ base_authority="login.microsoftonline.us",
+ jwt_signing_key="test-secret",
+ )
+
+ assert provider._tenant_id == "my-tenant"
+ assert provider._base_authority == "login.microsoftonline.us"
+
+ def test_entra_obo_token_is_importable(self):
+ """Test that EntraOBOToken can be imported."""
+ from fastmcp.server.auth.providers.azure import EntraOBOToken
+
+ assert EntraOBOToken is not None
+
+ def test_entra_obo_token_creates_dependency(self):
+ """Test that EntraOBOToken creates a dependency with scopes."""
+ from fastmcp.server.auth.providers.azure import EntraOBOToken, _EntraOBOToken
+
+ dep = EntraOBOToken(["https://graph.microsoft.com/User.Read"])
+ assert isinstance(dep, _EntraOBOToken)
+ assert dep.scopes == ["https://graph.microsoft.com/User.Read"]
+
+ def test_entra_obo_token_is_dependency_instance(self):
+ """Test that EntraOBOToken is a Dependency instance."""
+ from fastmcp.dependencies import Dependency
+ from fastmcp.server.auth.providers.azure import _EntraOBOToken
+
+ dep = _EntraOBOToken(["scope"])
+ assert isinstance(dep, Dependency)
diff --git a/tests/server/auth/providers/test_http_client.py b/tests/server/auth/providers/test_http_client.py
new file mode 100644
index 000000000..34c118f38
--- /dev/null
+++ b/tests/server/auth/providers/test_http_client.py
@@ -0,0 +1,365 @@
+"""Tests for http_client parameter on token verifiers.
+
+Verifies that all token verifiers accept an optional httpx.AsyncClient for
+connection pooling (issues #3287 and #3293).
+"""
+
+import time
+
+import httpx
+import pytest
+from pytest_httpx import HTTPXMock
+
+from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
+from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
+
+
+class TestIntrospectionHttpClient:
+ """Test http_client parameter on IntrospectionTokenVerifier."""
+
+ @pytest.fixture
+ def shared_client(self) -> httpx.AsyncClient:
+ return httpx.AsyncClient(timeout=30)
+
+ def test_stores_http_client(self, shared_client: httpx.AsyncClient):
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/introspect",
+ client_id="test",
+ client_secret="secret",
+ http_client=shared_client,
+ )
+ assert verifier._http_client is shared_client
+
+ def test_default_http_client_is_none(self):
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/introspect",
+ client_id="test",
+ client_secret="secret",
+ )
+ assert verifier._http_client is None
+
+ async def test_uses_provided_client(
+ self, shared_client: httpx.AsyncClient, httpx_mock: HTTPXMock
+ ):
+ """When http_client is provided, it should be used for requests."""
+ httpx_mock.add_response(
+ url="https://auth.example.com/introspect",
+ method="POST",
+ json={
+ "active": True,
+ "client_id": "user-1",
+ "scope": "read",
+ "exp": int(time.time()) + 3600,
+ },
+ )
+
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/introspect",
+ client_id="test",
+ client_secret="secret",
+ http_client=shared_client,
+ )
+
+ result = await verifier.verify_token("tok")
+ assert result is not None
+ assert result.client_id == "user-1"
+
+ async def test_client_not_closed_after_call(
+ self, shared_client: httpx.AsyncClient, httpx_mock: HTTPXMock
+ ):
+ """User-provided client must not be closed by the verifier."""
+ httpx_mock.add_response(
+ url="https://auth.example.com/introspect",
+ method="POST",
+ json={
+ "active": True,
+ "client_id": "user-1",
+ "scope": "read",
+ "exp": int(time.time()) + 3600,
+ },
+ )
+
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/introspect",
+ client_id="test",
+ client_secret="secret",
+ http_client=shared_client,
+ )
+
+ await verifier.verify_token("tok")
+ # Client should still be open β not closed by the verifier
+ assert not shared_client.is_closed
+
+ async def test_reuses_client_across_calls(
+ self, shared_client: httpx.AsyncClient, httpx_mock: HTTPXMock
+ ):
+ """Same client instance should be reused across multiple verify_token calls."""
+ for _ in range(3):
+ httpx_mock.add_response(
+ url="https://auth.example.com/introspect",
+ method="POST",
+ json={
+ "active": True,
+ "client_id": "user-1",
+ "scope": "read",
+ "exp": int(time.time()) + 3600,
+ },
+ )
+
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/introspect",
+ client_id="test",
+ client_secret="secret",
+ http_client=shared_client,
+ )
+
+ for _ in range(3):
+ result = await verifier.verify_token("tok")
+ assert result is not None
+
+ assert not shared_client.is_closed
+
+
+class TestJWTVerifierHttpClient:
+ """Test http_client parameter on JWTVerifier."""
+
+ @pytest.fixture(scope="class")
+ def rsa_key_pair(self) -> RSAKeyPair:
+ return RSAKeyPair.generate()
+
+ @pytest.fixture
+ def shared_client(self) -> httpx.AsyncClient:
+ return httpx.AsyncClient(timeout=30)
+
+ def test_stores_http_client(self, shared_client: httpx.AsyncClient):
+ verifier = JWTVerifier(
+ jwks_uri="https://auth.example.com/.well-known/jwks.json",
+ http_client=shared_client,
+ )
+ assert verifier._http_client is shared_client
+
+ def test_default_http_client_is_none(self):
+ verifier = JWTVerifier(
+ jwks_uri="https://auth.example.com/.well-known/jwks.json",
+ )
+ assert verifier._http_client is None
+
+ async def test_jwks_fetch_uses_provided_client(
+ self,
+ rsa_key_pair: RSAKeyPair,
+ shared_client: httpx.AsyncClient,
+ httpx_mock: HTTPXMock,
+ ):
+ """When http_client is provided, JWKS fetches should use it."""
+ from authlib.jose import JsonWebKey
+
+ # Build a JWKS response from the RSA key pair
+ public_key_obj = JsonWebKey.import_key(rsa_key_pair.public_key)
+ jwk_dict = dict(public_key_obj.as_dict())
+ jwk_dict["kid"] = "test-key-1"
+ jwk_dict["use"] = "sig"
+ jwk_dict["alg"] = "RS256"
+
+ httpx_mock.add_response(
+ url="https://auth.example.com/.well-known/jwks.json",
+ json={"keys": [jwk_dict]},
+ )
+
+ verifier = JWTVerifier(
+ jwks_uri="https://auth.example.com/.well-known/jwks.json",
+ issuer="https://auth.example.com",
+ http_client=shared_client,
+ )
+
+ token = rsa_key_pair.create_token(
+ issuer="https://auth.example.com",
+ kid="test-key-1",
+ )
+
+ result = await verifier.verify_token(token)
+ assert result is not None
+ assert not shared_client.is_closed
+
+ def test_ssrf_safe_rejects_http_client_with_jwks(
+ self,
+ shared_client: httpx.AsyncClient,
+ ):
+ """ssrf_safe=True and http_client cannot be used together with JWKS."""
+ with pytest.raises(ValueError, match="cannot be used with ssrf_safe=True"):
+ JWTVerifier(
+ jwks_uri="https://auth.example.com/.well-known/jwks.json",
+ ssrf_safe=True,
+ http_client=shared_client,
+ )
+
+ def test_ssrf_safe_allows_http_client_with_static_key(
+ self,
+ rsa_key_pair: RSAKeyPair,
+ shared_client: httpx.AsyncClient,
+ ):
+ """ssrf_safe with http_client is allowed when using static public_key (no HTTP)."""
+ # This should NOT raise β static key means no JWKS fetching
+ verifier = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ ssrf_safe=True,
+ http_client=shared_client,
+ )
+ assert verifier._http_client is shared_client
+ assert verifier.ssrf_safe is True
+
+
+class TestGitHubHttpClient:
+ """Test http_client parameter on GitHubTokenVerifier."""
+
+ def test_stores_http_client(self):
+ from fastmcp.server.auth.providers.github import GitHubTokenVerifier
+
+ client = httpx.AsyncClient()
+ verifier = GitHubTokenVerifier(http_client=client)
+ assert verifier._http_client is client
+
+ async def test_uses_provided_client(self, httpx_mock: HTTPXMock):
+ from fastmcp.server.auth.providers.github import GitHubTokenVerifier
+
+ client = httpx.AsyncClient()
+ httpx_mock.add_response(
+ url="https://api.github.com/user",
+ json={"id": 123, "login": "testuser"},
+ )
+ httpx_mock.add_response(
+ url="https://api.github.com/user/repos",
+ headers={"x-oauth-scopes": "user,repo"},
+ json=[],
+ )
+
+ verifier = GitHubTokenVerifier(http_client=client)
+ result = await verifier.verify_token("ghp_test")
+ assert result is not None
+ assert not client.is_closed
+
+
+class TestDiscordHttpClient:
+ """Test http_client parameter on DiscordTokenVerifier."""
+
+ def test_stores_http_client(self):
+ from fastmcp.server.auth.providers.discord import DiscordTokenVerifier
+
+ client = httpx.AsyncClient()
+ verifier = DiscordTokenVerifier(http_client=client)
+ assert verifier._http_client is client
+
+
+class TestGoogleHttpClient:
+ """Test http_client parameter on GoogleTokenVerifier."""
+
+ def test_stores_http_client(self):
+ from fastmcp.server.auth.providers.google import GoogleTokenVerifier
+
+ client = httpx.AsyncClient()
+ verifier = GoogleTokenVerifier(http_client=client)
+ assert verifier._http_client is client
+
+
+class TestWorkOSHttpClient:
+ """Test http_client parameter on WorkOSTokenVerifier."""
+
+ def test_stores_http_client(self):
+ from fastmcp.server.auth.providers.workos import WorkOSTokenVerifier
+
+ client = httpx.AsyncClient()
+ verifier = WorkOSTokenVerifier(
+ authkit_domain="https://test.authkit.app",
+ http_client=client,
+ )
+ assert verifier._http_client is client
+
+
+class TestProviderHttpClientPassthrough:
+ """Test that convenience providers pass http_client to their verifiers."""
+
+ def test_github_provider_threads_http_client(self):
+ from fastmcp.server.auth.providers.github import (
+ GitHubProvider,
+ GitHubTokenVerifier,
+ )
+
+ client = httpx.AsyncClient()
+ provider = GitHubProvider(
+ client_id="test",
+ client_secret="secret",
+ base_url="https://example.com",
+ http_client=client,
+ )
+ # OAuthProxy stores token verifier as _token_validator
+ verifier = provider._token_validator
+ assert isinstance(verifier, GitHubTokenVerifier)
+ assert verifier._http_client is client
+
+ def test_discord_provider_threads_http_client(self):
+ from fastmcp.server.auth.providers.discord import (
+ DiscordProvider,
+ DiscordTokenVerifier,
+ )
+
+ client = httpx.AsyncClient()
+ provider = DiscordProvider(
+ client_id="test",
+ client_secret="secret",
+ base_url="https://example.com",
+ http_client=client,
+ )
+ verifier = provider._token_validator
+ assert isinstance(verifier, DiscordTokenVerifier)
+ assert verifier._http_client is client
+
+ def test_google_provider_threads_http_client(self):
+ from fastmcp.server.auth.providers.google import (
+ GoogleProvider,
+ GoogleTokenVerifier,
+ )
+
+ client = httpx.AsyncClient()
+ provider = GoogleProvider(
+ client_id="test",
+ client_secret="secret",
+ base_url="https://example.com",
+ http_client=client,
+ )
+ verifier = provider._token_validator
+ assert isinstance(verifier, GoogleTokenVerifier)
+ assert verifier._http_client is client
+
+ def test_workos_provider_threads_http_client(self):
+ from fastmcp.server.auth.providers.workos import (
+ WorkOSProvider,
+ WorkOSTokenVerifier,
+ )
+
+ client = httpx.AsyncClient()
+ provider = WorkOSProvider(
+ client_id="test",
+ client_secret="secret",
+ authkit_domain="https://test.authkit.app",
+ base_url="https://example.com",
+ http_client=client,
+ )
+ verifier = provider._token_validator
+ assert isinstance(verifier, WorkOSTokenVerifier)
+ assert verifier._http_client is client
+
+ def test_azure_provider_threads_http_client(self):
+ from fastmcp.server.auth.providers.azure import AzureProvider
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
+
+ client = httpx.AsyncClient()
+ provider = AzureProvider(
+ client_id="test-client-id",
+ client_secret="secret",
+ tenant_id="test-tenant-id",
+ required_scopes=["read"],
+ base_url="https://example.com",
+ http_client=client,
+ )
+ verifier = provider._token_validator
+ assert isinstance(verifier, JWTVerifier)
+ assert verifier._http_client is client
diff --git a/tests/server/auth/providers/test_introspection.py b/tests/server/auth/providers/test_introspection.py
index 901412eb9..793570987 100644
--- a/tests/server/auth/providers/test_introspection.py
+++ b/tests/server/auth/providers/test_introspection.py
@@ -523,6 +523,438 @@ class TestIntrospectionTokenVerifier:
assert "client_secret=" not in body
+class TestIntrospectionCaching:
+ """Test in-memory caching for token introspection."""
+
+ @pytest.fixture
+ def verifier_with_cache(self) -> IntrospectionTokenVerifier:
+ """Create verifier with caching enabled."""
+ return IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/oauth/introspect",
+ client_id="test-client",
+ client_secret="test-secret",
+ cache_ttl_seconds=300, # 5 minutes
+ max_cache_size=100,
+ )
+
+ @pytest.fixture
+ def verifier_no_cache(self) -> IntrospectionTokenVerifier:
+ """Create verifier with caching disabled."""
+ return IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/oauth/introspect",
+ client_id="test-client",
+ client_secret="test-secret",
+ cache_ttl_seconds=0, # Disabled
+ )
+
+ def test_default_cache_settings(self):
+ """Test that caching is disabled by default."""
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/oauth/introspect",
+ client_id="test-client",
+ client_secret="test-secret",
+ )
+ assert verifier._cache_ttl == 0 # Disabled by default
+ assert verifier._max_cache_size == 10000
+
+ def test_custom_cache_settings(self):
+ """Test that cache settings can be customized."""
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/oauth/introspect",
+ client_id="test-client",
+ client_secret="test-secret",
+ cache_ttl_seconds=60,
+ max_cache_size=500,
+ )
+ assert verifier._cache_ttl == 60
+ assert verifier._max_cache_size == 500
+
+ def test_cache_disabled_with_zero_ttl(self):
+ """Test that cache is disabled when TTL is 0 or None."""
+ # Explicit 0
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/oauth/introspect",
+ client_id="test-client",
+ client_secret="test-secret",
+ cache_ttl_seconds=0,
+ )
+ assert verifier._cache_ttl == 0
+
+ # Explicit None (same as default)
+ verifier2 = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/oauth/introspect",
+ client_id="test-client",
+ client_secret="test-secret",
+ cache_ttl_seconds=None,
+ )
+ assert verifier2._cache_ttl == 0
+
+ async def test_cache_disabled_with_zero_or_negative_max_size(
+ self, httpx_mock: HTTPXMock
+ ):
+ """Test that cache is disabled when max_cache_size is 0 or negative."""
+ # Add two responses for the two verifiers
+ for _ in range(2):
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={
+ "active": True,
+ "client_id": "user-123",
+ "scope": "read",
+ },
+ )
+
+ # Zero max_cache_size should disable caching (not raise StopIteration)
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/oauth/introspect",
+ client_id="test-client",
+ client_secret="test-secret",
+ cache_ttl_seconds=300,
+ max_cache_size=0,
+ )
+ result = await verifier.verify_token("test-token")
+ assert result is not None
+ assert result.client_id == "user-123"
+
+ # Negative max_cache_size should also disable caching
+ verifier2 = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/oauth/introspect",
+ client_id="test-client",
+ client_secret="test-secret",
+ cache_ttl_seconds=300,
+ max_cache_size=-1,
+ )
+ result2 = await verifier2.verify_token("test-token")
+ assert result2 is not None
+
+ async def test_cache_hit_returns_cached_result(
+ self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
+ ):
+ """Test that cached valid tokens are returned without introspection call."""
+ # First call - introspection endpoint called
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={
+ "active": True,
+ "client_id": "user-123",
+ "scope": "read write",
+ "exp": int(time.time()) + 3600,
+ },
+ )
+
+ # First verification
+ result1 = await verifier_with_cache.verify_token("test-token")
+ assert result1 is not None
+ assert result1.client_id == "user-123"
+
+ # Verify one request was made
+ requests = httpx_mock.get_requests()
+ assert len(requests) == 1
+
+ # Second verification - should use cache, no new request
+ result2 = await verifier_with_cache.verify_token("test-token")
+ assert result2 is not None
+ assert result2.client_id == "user-123"
+
+ # Still only one request
+ requests = httpx_mock.get_requests()
+ assert len(requests) == 1
+
+ async def test_cache_returns_defensive_copy(
+ self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
+ ):
+ """Test that cached tokens are defensive copies (mutations don't leak)."""
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={
+ "active": True,
+ "client_id": "user-123",
+ "scope": "read write",
+ "exp": int(time.time()) + 3600,
+ "custom_claim": "original",
+ },
+ )
+
+ # First verification
+ result1 = await verifier_with_cache.verify_token("test-token")
+ assert result1 is not None
+ assert result1.claims["custom_claim"] == "original"
+
+ # Mutate the result (simulating request-path code adding derived claims)
+ result1.claims["custom_claim"] = "mutated"
+ result1.claims["new_claim"] = "injected"
+ result1.scopes.append("admin")
+
+ # Second verification - should get clean copy, not mutated one
+ result2 = await verifier_with_cache.verify_token("test-token")
+ assert result2 is not None
+ assert result2.claims["custom_claim"] == "original"
+ assert "new_claim" not in result2.claims
+ assert "admin" not in result2.scopes
+
+ # Verify they are different object instances
+ assert result1 is not result2
+
+ async def test_inactive_tokens_not_cached(
+ self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
+ ):
+ """Test that inactive tokens are NOT cached (may become valid later)."""
+ # Add two responses - inactive tokens should trigger re-introspection
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": False},
+ )
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": False},
+ )
+
+ # First verification
+ result1 = await verifier_with_cache.verify_token("inactive-token")
+ assert result1 is None
+
+ # Verify one request was made
+ requests = httpx_mock.get_requests()
+ assert len(requests) == 1
+
+ # Second verification - should NOT use cache, makes another request
+ result2 = await verifier_with_cache.verify_token("inactive-token")
+ assert result2 is None
+
+ # Two requests made (inactive tokens not cached)
+ requests = httpx_mock.get_requests()
+ assert len(requests) == 2
+
+ async def test_cache_disabled_makes_every_call(
+ self, verifier_no_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
+ ):
+ """Test that with caching disabled, every call makes a request."""
+ # Add multiple responses for the same token
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": True, "client_id": "user-123"},
+ )
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": True, "client_id": "user-123"},
+ )
+
+ # First call
+ await verifier_no_cache.verify_token("test-token")
+
+ # Second call - should also make a request
+ await verifier_no_cache.verify_token("test-token")
+
+ # Two requests were made
+ requests = httpx_mock.get_requests()
+ assert len(requests) == 2
+
+ async def test_different_tokens_are_cached_separately(
+ self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
+ ):
+ """Test that different tokens have separate cache entries."""
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": True, "client_id": "user-1"},
+ )
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": True, "client_id": "user-2"},
+ )
+
+ # Verify two different tokens
+ result1 = await verifier_with_cache.verify_token("token-1")
+ result2 = await verifier_with_cache.verify_token("token-2")
+
+ assert result1 is not None
+ assert result1.client_id == "user-1"
+ assert result2 is not None
+ assert result2.client_id == "user-2"
+
+ # Two requests were made
+ requests = httpx_mock.get_requests()
+ assert len(requests) == 2
+
+ # Verify both again - no new requests
+ await verifier_with_cache.verify_token("token-1")
+ await verifier_with_cache.verify_token("token-2")
+
+ requests = httpx_mock.get_requests()
+ assert len(requests) == 2
+
+ async def test_http_errors_are_not_cached(
+ self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
+ ):
+ """Test that HTTP errors are not cached (transient failures)."""
+ # First call - HTTP error
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ status_code=500,
+ text="Internal Server Error",
+ )
+ # Second call - success
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": True, "client_id": "user-123"},
+ )
+
+ # First verification - fails
+ result1 = await verifier_with_cache.verify_token("test-token")
+ assert result1 is None
+
+ # Second verification - should retry since error wasn't cached
+ result2 = await verifier_with_cache.verify_token("test-token")
+ assert result2 is not None
+ assert result2.client_id == "user-123"
+
+ # Two requests were made
+ requests = httpx_mock.get_requests()
+ assert len(requests) == 2
+
+ async def test_timeout_errors_are_not_cached(
+ self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
+ ):
+ """Test that timeout errors are not cached (transient failures)."""
+ from httpx import TimeoutException
+
+ # First call - timeout
+ httpx_mock.add_exception(
+ TimeoutException("Request timed out"),
+ url="https://auth.example.com/oauth/introspect",
+ )
+ # Second call - success
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": True, "client_id": "user-123"},
+ )
+
+ # First verification - times out
+ result1 = await verifier_with_cache.verify_token("test-token")
+ assert result1 is None
+
+ # Second verification - should retry since timeout wasn't cached
+ result2 = await verifier_with_cache.verify_token("test-token")
+ assert result2 is not None
+
+ # Two requests were made
+ requests = httpx_mock.get_requests()
+ assert len(requests) == 2
+
+ def test_token_hashing(self, verifier_with_cache: IntrospectionTokenVerifier):
+ """Test that tokens are hashed consistently."""
+ hash1 = verifier_with_cache._hash_token("test-token")
+ hash2 = verifier_with_cache._hash_token("test-token")
+ hash3 = verifier_with_cache._hash_token("different-token")
+
+ # Same token produces same hash
+ assert hash1 == hash2
+ # Different tokens produce different hashes
+ assert hash1 != hash3
+ # Hash is a hex string (SHA-256 = 64 chars)
+ assert len(hash1) == 64
+
+ async def test_cache_respects_token_expiration(
+ self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
+ ):
+ """Test that cache respects token's exp claim for TTL."""
+ # Token expiring in 60 seconds (shorter than cache TTL of 300)
+ short_exp = int(time.time()) + 60
+
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={
+ "active": True,
+ "client_id": "user-123",
+ "exp": short_exp,
+ },
+ )
+
+ await verifier_with_cache.verify_token("test-token")
+
+ # Check that cache entry uses the shorter expiration
+ cache_key = verifier_with_cache._hash_token("test-token")
+ entry = verifier_with_cache._cache[cache_key]
+ # Cache expiration should be at or before token expiration
+ assert entry.expires_at <= short_exp
+
+ async def test_expired_cache_entry_triggers_new_introspection(
+ self, httpx_mock: HTTPXMock
+ ):
+ """Test that expired cache entries are evicted and a new call is made."""
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/oauth/introspect",
+ client_id="test-client",
+ client_secret="test-secret",
+ cache_ttl_seconds=1, # 1 second TTL
+ )
+
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": True, "client_id": "user-123"},
+ )
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": True, "client_id": "user-123"},
+ )
+
+ # First call β caches the result
+ await verifier.verify_token("test-token")
+ assert len(httpx_mock.get_requests()) == 1
+
+ # Expire the cache entry manually
+ cache_key = verifier._hash_token("test-token")
+ verifier._cache[cache_key].expires_at = time.time() - 1
+
+ # Second call β cache miss, new introspection
+ await verifier.verify_token("test-token")
+ assert len(httpx_mock.get_requests()) == 2
+
+ async def test_cache_eviction_at_max_size(self, httpx_mock: HTTPXMock):
+ """Test that cache evicts entries when max size is reached."""
+ verifier = IntrospectionTokenVerifier(
+ introspection_url="https://auth.example.com/oauth/introspect",
+ client_id="test-client",
+ client_secret="test-secret",
+ cache_ttl_seconds=300,
+ max_cache_size=2,
+ )
+
+ for i in range(3):
+ httpx_mock.add_response(
+ url="https://auth.example.com/oauth/introspect",
+ method="POST",
+ json={"active": True, "client_id": f"user-{i}"},
+ )
+
+ # Fill cache to capacity
+ await verifier.verify_token("token-0")
+ await verifier.verify_token("token-1")
+ assert len(verifier._cache) == 2
+
+ # Third token should evict the oldest entry
+ await verifier.verify_token("token-2")
+ assert len(verifier._cache) == 2
+
+ # token-0 should have been evicted (FIFO)
+ hash_0 = verifier._hash_token("token-0")
+ assert hash_0 not in verifier._cache
+
+
class TestIntrospectionTokenVerifierIntegration:
"""Integration tests with FastMCP server."""
diff --git a/tests/server/auth/providers/test_propelauth.py b/tests/server/auth/providers/test_propelauth.py
new file mode 100644
index 000000000..9d70496fc
--- /dev/null
+++ b/tests/server/auth/providers/test_propelauth.py
@@ -0,0 +1,336 @@
+"""Tests for PropelAuthProvider."""
+
+from typing import cast
+from unittest.mock import AsyncMock
+
+import httpx
+import pytest
+from pydantic import SecretStr
+
+from fastmcp import Client, FastMCP
+from fastmcp.server.auth import AccessToken
+from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
+from fastmcp.server.auth.providers.propelauth import (
+ PropelAuthProvider,
+ PropelAuthTokenIntrospectionOverrides,
+)
+from fastmcp.utilities.tests import run_server_async
+
+
+class TestPropelAuthProvider:
+ """Test PropelAuth's auth provider."""
+
+ def test_init_with_only_required_params(self):
+ """Test PropelAuthProvider initialization with only required params."""
+ provider = PropelAuthProvider(
+ auth_url="https://auth.example.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="https://example.com",
+ )
+
+ # Verify the provider is configured correctly
+ assert len(provider.authorization_servers) == 1
+ assert (
+ str(provider.authorization_servers[0])
+ == "https://auth.example.com/oauth/2.1"
+ )
+ assert str(provider.base_url) == "https://example.com/"
+
+ # Verify token verifier is configured correctly
+ assert isinstance(provider.token_verifier, IntrospectionTokenVerifier)
+ assert (
+ provider.token_verifier.introspection_url
+ == "https://auth.example.com/oauth/2.1/introspect"
+ )
+ assert provider.token_verifier.client_id == "client_id_123"
+ assert provider.token_verifier.client_secret == "client_secret_123"
+
+ def test_auth_url_trailing_slash_normalization(self):
+ """Test that trailing slash on auth_url is stripped before building URLs."""
+ provider = PropelAuthProvider(
+ auth_url="https://auth.example.com/",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="https://example.com",
+ )
+
+ assert isinstance(provider.token_verifier, IntrospectionTokenVerifier)
+ assert len(provider.authorization_servers) == 1
+ assert (
+ str(provider.authorization_servers[0])
+ == "https://auth.example.com/oauth/2.1"
+ )
+ assert (
+ provider.token_verifier.introspection_url
+ == "https://auth.example.com/oauth/2.1/introspect"
+ )
+
+ def test_required_scopes_passed_to_verifier(self):
+ """Test that required_scopes are passed through to the token verifier."""
+ provider = PropelAuthProvider(
+ auth_url="https://auth.example.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="https://example.com",
+ required_scopes=["read", "write"],
+ )
+
+ assert isinstance(provider.token_verifier, IntrospectionTokenVerifier)
+ assert provider.token_verifier.required_scopes == ["read", "write"]
+
+ def test_introspection_client_secret_as_secret_str(self):
+ """Test that SecretStr client_secret is unwrapped correctly."""
+ provider = PropelAuthProvider(
+ auth_url="https://auth.example.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret=SecretStr("my_secret"),
+ base_url="https://example.com",
+ )
+
+ assert isinstance(provider.token_verifier, IntrospectionTokenVerifier)
+ assert provider.token_verifier.client_secret == "my_secret"
+
+ def test_authorization_servers_configuration(self):
+ """Test that authorization_servers contains the correct PropelAuth URL."""
+ provider = PropelAuthProvider(
+ auth_url="https://auth.propelauth.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="https://example.com",
+ )
+
+ assert len(provider.authorization_servers) == 1
+ assert (
+ str(provider.authorization_servers[0])
+ == "https://auth.propelauth.com/oauth/2.1"
+ )
+
+ def test_token_introspection_overrides_timeout(self):
+ """Test that timeout_seconds override is passed to the verifier."""
+ provider = PropelAuthProvider(
+ auth_url="https://auth.example.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="https://example.com",
+ token_introspection_overrides={"timeout_seconds": 30},
+ )
+
+ assert isinstance(provider.token_verifier, IntrospectionTokenVerifier)
+ assert provider.token_verifier.timeout_seconds == 30
+
+ def test_token_introspection_overrides_cache(self):
+ """Test that cache overrides are passed to the verifier."""
+ provider = PropelAuthProvider(
+ auth_url="https://auth.example.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="https://example.com",
+ token_introspection_overrides={
+ "cache_ttl_seconds": 300,
+ "max_cache_size": 500,
+ },
+ )
+
+ assert isinstance(provider.token_verifier, IntrospectionTokenVerifier)
+ assert provider.token_verifier._cache_ttl == 300
+ assert provider.token_verifier._max_cache_size == 500
+
+ def test_token_introspection_overrides_http_client(self):
+ """Test that http_client override is passed to the verifier."""
+ client = httpx.AsyncClient()
+ provider = PropelAuthProvider(
+ auth_url="https://auth.example.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="https://example.com",
+ token_introspection_overrides={"http_client": client},
+ )
+
+ assert isinstance(provider.token_verifier, IntrospectionTokenVerifier)
+ assert provider.token_verifier._http_client is client
+
+ def test_token_introspection_overrides_ignores_unknown_keys(self):
+ """Test that unknown override keys are silently ignored."""
+ provider = PropelAuthProvider(
+ auth_url="https://auth.example.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="https://example.com",
+ # This won't typecheck without casting, since it shouldn't be allowed
+ token_introspection_overrides=cast(
+ PropelAuthTokenIntrospectionOverrides, {"unknown_key": "value"}
+ ),
+ )
+
+ assert isinstance(provider.token_verifier, IntrospectionTokenVerifier)
+ assert provider.token_verifier.timeout_seconds == 10
+
+ def test_token_introspection_overrides_ignores_disallowed_known_keys(self):
+ """Test that known IntrospectionTokenVerifier keys not in the allow list are ignored."""
+ provider = PropelAuthProvider(
+ auth_url="https://auth.example.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="https://example.com",
+ # This won't typecheck without casting, since it shouldn't be allowed
+ token_introspection_overrides=cast(
+ PropelAuthTokenIntrospectionOverrides, {"client_id": "sneaky_override"}
+ ),
+ )
+
+ assert isinstance(provider.token_verifier, IntrospectionTokenVerifier)
+ assert provider.token_verifier.client_id == "client_id_123"
+
+
+class TestPropelAuthResourceChecking:
+ """Test audience (aud) checking when resource is configured."""
+
+ def _make_provider(self, resource: str | None = None) -> PropelAuthProvider:
+ return PropelAuthProvider(
+ auth_url="https://auth.example.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="https://example.com",
+ resource=resource,
+ )
+
+ def _make_access_token(self, aud: str) -> AccessToken:
+ return AccessToken(
+ token="test-token",
+ client_id="client_id_123",
+ scopes=[],
+ claims={"active": True, "sub": "user-1", "aud": aud},
+ )
+
+ async def test_no_resource_skips_aud_check(self, monkeypatch: pytest.MonkeyPatch):
+ """When resource is not configured, tokens are accepted without aud checking."""
+ provider = self._make_provider(resource=None)
+ token = self._make_access_token(aud="https://anything.example.com")
+ monkeypatch.setattr(
+ provider.token_verifier, "verify_token", AsyncMock(return_value=token)
+ )
+
+ result = await provider.verify_token("test-token")
+ assert result is token
+
+ async def test_aud_matches_resource(self, monkeypatch: pytest.MonkeyPatch):
+ """Token is accepted when aud matches the configured resource."""
+ provider = self._make_provider(resource="https://api.example.com/mcp")
+ token = self._make_access_token(aud="https://api.example.com/mcp")
+ monkeypatch.setattr(
+ provider.token_verifier, "verify_token", AsyncMock(return_value=token)
+ )
+
+ result = await provider.verify_token("test-token")
+ assert result is token
+
+ async def test_aud_does_not_match_resource(self, monkeypatch: pytest.MonkeyPatch):
+ """Token is rejected when aud doesn't match the configured resource."""
+ provider = self._make_provider(resource="https://api.example.com/mcp")
+ token = self._make_access_token(aud="https://other-server.example.com/mcp")
+ monkeypatch.setattr(
+ provider.token_verifier, "verify_token", AsyncMock(return_value=token)
+ )
+
+ result = await provider.verify_token("test-token")
+ assert result is None
+
+ async def test_inner_verifier_returns_none(self, monkeypatch: pytest.MonkeyPatch):
+ """When the inner verifier rejects the token, None is returned without aud checking."""
+ provider = self._make_provider(resource="https://api.example.com/mcp")
+ monkeypatch.setattr(
+ provider.token_verifier, "verify_token", AsyncMock(return_value=None)
+ )
+
+ result = await provider.verify_token("test-token")
+ assert result is None
+
+
+@pytest.fixture
+async def mcp_server_url():
+ """Start MCP server with PropelAuth authentication."""
+ mcp = FastMCP(
+ auth=PropelAuthProvider(
+ auth_url="https://auth.example.com",
+ introspection_client_id="client_id_123",
+ introspection_client_secret="client_secret_123",
+ base_url="http://localhost:4321",
+ )
+ )
+
+ @mcp.tool
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ async with run_server_async(mcp, transport="http") as url:
+ yield url
+
+
+class TestPropelAuthProviderIntegration:
+ 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_metadata_route_forwards_propelauth_response(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ mcp_server_url: str,
+ ) -> None:
+ """Ensure PropelAuth metadata route proxies upstream JSON."""
+
+ metadata_payload = {
+ "issuer": "https://auth.example.com",
+ "token_endpoint": "https://auth.example.com/oauth/2.1/token",
+ "authorization_endpoint": "https://auth.example.com/oauth/2.1/authorize",
+ }
+
+ class DummyResponse:
+ status_code = 200
+
+ def __init__(self, data: dict[str, str]):
+ self._data = data
+
+ def json(self):
+ return self._data
+
+ def raise_for_status(self):
+ return None
+
+ class DummyAsyncClient:
+ last_url: str | None = None
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def get(self, url: str):
+ DummyAsyncClient.last_url = url
+ return DummyResponse(metadata_payload)
+
+ real_httpx_client = httpx.AsyncClient
+
+ monkeypatch.setattr(
+ "fastmcp.server.auth.providers.propelauth.httpx.AsyncClient",
+ DummyAsyncClient,
+ )
+
+ base_url = mcp_server_url.rsplit("/mcp", 1)[0]
+ async with real_httpx_client() as client:
+ response = await client.get(
+ f"{base_url}/.well-known/oauth-authorization-server"
+ )
+
+ assert response.status_code == 200
+ assert response.json() == metadata_payload
+ assert (
+ DummyAsyncClient.last_url
+ == "https://auth.example.com/.well-known/oauth-authorization-server/oauth/2.1"
+ )
diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py
index 4bd0dff9a..f6e86aeef 100644
--- a/tests/server/auth/test_authorization.py
+++ b/tests/server/auth/test_authorization.py
@@ -9,6 +9,7 @@ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from fastmcp import FastMCP
from fastmcp.client import Client
+from fastmcp.exceptions import AuthorizationError
from fastmcp.server.auth import (
AccessToken,
AuthContext,
@@ -156,7 +157,6 @@ class TestRunAuthChecks:
async def test_authorization_error_propagates(self):
"""AuthorizationError from auth check should propagate with custom message."""
- from fastmcp.exceptions import AuthorizationError
def custom_auth_check(ctx: AuthContext) -> bool:
raise AuthorizationError("Custom denial reason")
@@ -177,8 +177,6 @@ class TestRunAuthChecks:
async def test_authorization_error_stops_chain(self):
"""AuthorizationError should stop the check chain and propagate."""
- from fastmcp.exceptions import AuthorizationError
-
call_order = []
def check_1(ctx: AuthContext) -> bool:
@@ -242,7 +240,6 @@ class TestRunAuthChecks:
async def test_async_check_authorization_error_propagates(self):
"""Async checks that raise AuthorizationError should propagate."""
- from fastmcp.exceptions import AuthorizationError
async def async_denial(ctx: AuthContext) -> bool:
raise AuthorizationError("Async denial")
@@ -456,6 +453,88 @@ class TestAuthMiddleware:
finally:
auth_context_var.reset(tok)
+ async def test_middleware_skips_tool_on_authorization_error(self):
+ def deny_blocked_tool(ctx: AuthContext) -> bool:
+ if ctx.component.name == "blocked_tool":
+ raise AuthorizationError(f"deny {ctx.component.name}")
+ return True
+
+ mcp = FastMCP(middleware=[AuthMiddleware(auth=deny_blocked_tool)])
+
+ @mcp.tool
+ def blocked_tool() -> str:
+ return "blocked"
+
+ @mcp.tool
+ def allowed_tool() -> str:
+ return "allowed"
+
+ result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest())
+ assert [tool.name for tool in result.tools] == ["allowed_tool"]
+
+ async def test_middleware_skips_resource_on_authorization_error(self):
+ def deny_blocked_resource(ctx: AuthContext) -> bool:
+ if ctx.component.name == "blocked_resource":
+ raise AuthorizationError(f"deny {ctx.component.name}")
+ return True
+
+ mcp = FastMCP(middleware=[AuthMiddleware(auth=deny_blocked_resource)])
+
+ @mcp.resource("resource://blocked")
+ def blocked_resource() -> str:
+ return "blocked"
+
+ @mcp.resource("resource://allowed")
+ def allowed_resource() -> str:
+ return "allowed"
+
+ result = await mcp._list_resources_mcp(mcp_types.ListResourcesRequest())
+ assert [str(resource.uri) for resource in result.resources] == [
+ "resource://allowed"
+ ]
+
+ async def test_middleware_skips_resource_template_on_authorization_error(self):
+ def deny_blocked_resource_template(ctx: AuthContext) -> bool:
+ if ctx.component.name == "blocked_resource_template":
+ raise AuthorizationError(f"deny {ctx.component.name}")
+ return True
+
+ mcp = FastMCP(middleware=[AuthMiddleware(auth=deny_blocked_resource_template)])
+
+ @mcp.resource("resource://blocked/{item}")
+ def blocked_resource_template(item: str) -> str:
+ return item
+
+ @mcp.resource("resource://allowed/{item}")
+ def allowed_resource_template(item: str) -> str:
+ return item
+
+ result = await mcp._list_resource_templates_mcp(
+ mcp_types.ListResourceTemplatesRequest()
+ )
+ assert [template.uriTemplate for template in result.resourceTemplates] == [
+ "resource://allowed/{item}"
+ ]
+
+ async def test_middleware_skips_prompt_on_authorization_error(self):
+ def deny_blocked_prompt(ctx: AuthContext) -> bool:
+ if ctx.component.name == "blocked_prompt":
+ raise AuthorizationError(f"deny {ctx.component.name}")
+ return True
+
+ mcp = FastMCP(middleware=[AuthMiddleware(auth=deny_blocked_prompt)])
+
+ @mcp.prompt
+ def blocked_prompt() -> str:
+ return "blocked"
+
+ @mcp.prompt
+ def allowed_prompt() -> str:
+ return "allowed"
+
+ result = await mcp._list_prompts_mcp(mcp_types.ListPromptsRequest())
+ assert [prompt.name for prompt in result.prompts] == ["allowed_prompt"]
+
# =============================================================================
# Integration tests with Client
diff --git a/tests/server/auth/test_cimd.py b/tests/server/auth/test_cimd.py
index 111d863c7..3ed99857a 100644
--- a/tests/server/auth/test_cimd.py
+++ b/tests/server/auth/test_cimd.py
@@ -3,20 +3,17 @@
from __future__ import annotations
import time
-from unittest.mock import AsyncMock, patch
+from unittest.mock import patch
import pytest
from pydantic import AnyHttpUrl, ValidationError
from fastmcp.server.auth.cimd import (
- CIMDAssertionValidator,
- CIMDClientManager,
CIMDDocument,
CIMDFetcher,
CIMDFetchError,
CIMDValidationError,
)
-from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient
# Standard public IP used for DNS mocking in tests
TEST_PUBLIC_IP = "93.184.216.34"
@@ -543,667 +540,3 @@ class TestCIMDFetcherHTTP:
with pytest.raises(CIMDValidationError) as exc_info:
await fetcher.fetch(url)
assert "Invalid CIMD document" in str(exc_info.value)
-
-
-class TestCIMDAssertionValidator:
- """Tests for CIMDAssertionValidator (private_key_jwt support)."""
-
- @pytest.fixture
- def validator(self):
- """Create a CIMDAssertionValidator for testing."""
- return CIMDAssertionValidator()
-
- @pytest.fixture
- def key_pair(self):
- """Generate RSA key pair for testing."""
- from fastmcp.server.auth.providers.jwt import RSAKeyPair
-
- return RSAKeyPair.generate()
-
- @pytest.fixture
- def jwks(self, key_pair):
- """Create JWKS from key pair."""
- import base64
-
- from cryptography.hazmat.backends import default_backend
- from cryptography.hazmat.primitives import serialization
-
- # Load public key
- public_key = serialization.load_pem_public_key(
- key_pair.public_key.encode(), backend=default_backend()
- )
-
- # Get RSA public numbers
- from cryptography.hazmat.primitives.asymmetric import rsa
-
- if isinstance(public_key, rsa.RSAPublicKey):
- numbers = public_key.public_numbers()
-
- # Convert to JWK format
- return {
- "keys": [
- {
- "kty": "RSA",
- "kid": "test-key-1",
- "use": "sig",
- "alg": "RS256",
- "n": base64.urlsafe_b64encode(
- numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")
- )
- .rstrip(b"=")
- .decode(),
- "e": base64.urlsafe_b64encode(
- numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")
- )
- .rstrip(b"=")
- .decode(),
- }
- ]
- }
-
- @pytest.fixture
- def cimd_doc_with_jwks_uri(self):
- """Create CIMD document with jwks_uri."""
- return CIMDDocument(
- client_id=AnyHttpUrl("https://example.com/client.json"),
- redirect_uris=["http://localhost:3000/callback"],
- token_endpoint_auth_method="private_key_jwt",
- jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
- )
-
- @pytest.fixture
- def cimd_doc_with_inline_jwks(self, jwks):
- """Create CIMD document with inline JWKS."""
- return CIMDDocument(
- client_id=AnyHttpUrl("https://example.com/client.json"),
- redirect_uris=["http://localhost:3000/callback"],
- token_endpoint_auth_method="private_key_jwt",
- jwks=jwks,
- )
-
- async def test_valid_assertion_with_jwks_uri(
- self, validator, key_pair, cimd_doc_with_jwks_uri, httpx_mock
- ):
- """Test that valid JWT assertion passes validation (jwks_uri)."""
- client_id = "https://example.com/client.json"
- token_endpoint = "https://oauth.example.com/token"
-
- # Mock JWKS endpoint
- import base64
-
- from cryptography.hazmat.backends import default_backend
- from cryptography.hazmat.primitives import serialization
-
- public_key = serialization.load_pem_public_key(
- key_pair.public_key.encode(), backend=default_backend()
- )
- from cryptography.hazmat.primitives.asymmetric import rsa
-
- assert isinstance(public_key, rsa.RSAPublicKey)
- numbers = public_key.public_numbers()
-
- jwks = {
- "keys": [
- {
- "kty": "RSA",
- "kid": "test-key-1",
- "use": "sig",
- "alg": "RS256",
- "n": base64.urlsafe_b64encode(
- numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")
- )
- .rstrip(b"=")
- .decode(),
- "e": base64.urlsafe_b64encode(
- numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")
- )
- .rstrip(b"=")
- .decode(),
- }
- ]
- }
-
- # Mock DNS resolution for SSRF-safe fetch
- with patch(
- "fastmcp.server.auth.ssrf.resolve_hostname",
- return_value=[TEST_PUBLIC_IP],
- ):
- httpx_mock.add_response(json=jwks)
-
- # Create valid assertion (use short lifetime for security compliance)
- assertion = key_pair.create_token(
- subject=client_id,
- issuer=client_id,
- audience=token_endpoint,
- additional_claims={"jti": "unique-jti-123"},
- expires_in_seconds=60, # 1 minute (max allowed is 300s)
- kid="test-key-1",
- )
-
- # Should validate successfully
- assert await validator.validate_assertion(
- assertion, client_id, token_endpoint, cimd_doc_with_jwks_uri
- )
-
- async def test_valid_assertion_with_inline_jwks(
- self, validator, key_pair, cimd_doc_with_inline_jwks
- ):
- """Test that valid JWT assertion passes validation (inline JWKS)."""
- client_id = "https://example.com/client.json"
- token_endpoint = "https://oauth.example.com/token"
-
- # Create valid assertion (use short lifetime for security compliance)
- assertion = key_pair.create_token(
- subject=client_id,
- issuer=client_id,
- audience=token_endpoint,
- additional_claims={"jti": "unique-jti-456"},
- expires_in_seconds=60, # 1 minute (max allowed is 300s)
- kid="test-key-1",
- )
-
- # Should validate successfully
- assert await validator.validate_assertion(
- assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
- )
-
- async def test_rejects_wrong_issuer(
- self, validator, key_pair, cimd_doc_with_inline_jwks
- ):
- """Test that wrong issuer is rejected."""
- client_id = "https://example.com/client.json"
- token_endpoint = "https://oauth.example.com/token"
-
- # Create assertion with wrong issuer
- assertion = key_pair.create_token(
- subject=client_id,
- issuer="https://attacker.com", # Wrong!
- audience=token_endpoint,
- additional_claims={"jti": "unique-jti-789"},
- expires_in_seconds=60,
- kid="test-key-1",
- )
-
- with pytest.raises(ValueError) as exc_info:
- await validator.validate_assertion(
- assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
- )
- assert "Invalid JWT assertion" in str(exc_info.value)
-
- async def test_rejects_wrong_audience(
- self, validator, key_pair, cimd_doc_with_inline_jwks
- ):
- """Test that wrong audience is rejected."""
- client_id = "https://example.com/client.json"
- token_endpoint = "https://oauth.example.com/token"
-
- # Create assertion with wrong audience
- assertion = key_pair.create_token(
- subject=client_id,
- issuer=client_id,
- audience="https://wrong-endpoint.com/token", # Wrong!
- additional_claims={"jti": "unique-jti-abc"},
- expires_in_seconds=60,
- kid="test-key-1",
- )
-
- with pytest.raises(ValueError) as exc_info:
- await validator.validate_assertion(
- assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
- )
- assert "Invalid JWT assertion" in str(exc_info.value)
-
- async def test_rejects_wrong_subject(
- self, validator, key_pair, cimd_doc_with_inline_jwks
- ):
- """Test that wrong subject claim is rejected."""
- client_id = "https://example.com/client.json"
- token_endpoint = "https://oauth.example.com/token"
-
- # Create assertion with wrong subject
- assertion = key_pair.create_token(
- subject="https://different-client.com", # Wrong!
- issuer=client_id,
- audience=token_endpoint,
- additional_claims={"jti": "unique-jti-def"},
- expires_in_seconds=60,
- kid="test-key-1",
- )
-
- with pytest.raises(ValueError) as exc_info:
- await validator.validate_assertion(
- assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
- )
- assert "sub claim must be" in str(exc_info.value)
-
- async def test_rejects_missing_jti(
- self, validator, key_pair, cimd_doc_with_inline_jwks
- ):
- """Test that missing jti claim is rejected."""
- client_id = "https://example.com/client.json"
- token_endpoint = "https://oauth.example.com/token"
-
- # Create assertion without jti
- assertion = key_pair.create_token(
- subject=client_id,
- issuer=client_id,
- audience=token_endpoint,
- # No jti!
- expires_in_seconds=60,
- kid="test-key-1",
- )
-
- with pytest.raises(ValueError) as exc_info:
- await validator.validate_assertion(
- assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
- )
- assert "jti claim" in str(exc_info.value)
-
- async def test_rejects_replayed_jti(
- self, validator, key_pair, cimd_doc_with_inline_jwks
- ):
- """Test that replayed JTI is detected and rejected."""
- client_id = "https://example.com/client.json"
- token_endpoint = "https://oauth.example.com/token"
-
- # Create assertion
- assertion = key_pair.create_token(
- subject=client_id,
- issuer=client_id,
- audience=token_endpoint,
- additional_claims={"jti": "replayed-jti"},
- expires_in_seconds=60,
- kid="test-key-1",
- )
-
- # First use should succeed
- assert await validator.validate_assertion(
- assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
- )
-
- # Second use with same jti should fail (replay attack)
- with pytest.raises(ValueError) as exc_info:
- await validator.validate_assertion(
- assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
- )
- assert "replay" in str(exc_info.value).lower()
-
- async def test_rejects_expired_token(
- self, validator, key_pair, cimd_doc_with_inline_jwks
- ):
- """Test that expired tokens are rejected."""
- client_id = "https://example.com/client.json"
- token_endpoint = "https://oauth.example.com/token"
-
- # Create expired assertion (expired 1 hour ago)
- assertion = key_pair.create_token(
- subject=client_id,
- issuer=client_id,
- audience=token_endpoint,
- additional_claims={"jti": "expired-jti"},
- expires_in_seconds=-3600, # Negative = expired
- kid="test-key-1",
- )
-
- with pytest.raises(ValueError) as exc_info:
- await validator.validate_assertion(
- assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
- )
- assert "Invalid JWT assertion" in str(exc_info.value)
-
-
-class TestCIMDClientManager:
- """Tests for CIMDClientManager."""
-
- @pytest.fixture
- def manager(self):
- """Create a CIMDClientManager for testing."""
- return CIMDClientManager(enable_cimd=True)
-
- @pytest.fixture
- def disabled_manager(self):
- """Create a disabled CIMDClientManager for testing."""
- return CIMDClientManager(enable_cimd=False)
-
- @pytest.fixture
- def mock_dns(self):
- """Mock DNS resolution to return test public IP."""
- with patch(
- "fastmcp.server.auth.ssrf.resolve_hostname",
- return_value=[TEST_PUBLIC_IP],
- ):
- yield
-
- def test_is_cimd_client_id_enabled(self, manager):
- """Test CIMD URL detection when enabled."""
- assert manager.is_cimd_client_id("https://example.com/client.json")
- assert not manager.is_cimd_client_id("regular-client-id")
-
- def test_is_cimd_client_id_disabled(self, disabled_manager):
- """Test CIMD URL detection when disabled."""
- assert not disabled_manager.is_cimd_client_id("https://example.com/client.json")
- assert not disabled_manager.is_cimd_client_id("regular-client-id")
-
- async def test_get_client_success(self, manager, httpx_mock, mock_dns):
- """Test successful CIMD client creation."""
- url = "https://example.com/client.json"
- doc_data = {
- "client_id": url,
- "client_name": "Test App",
- "redirect_uris": ["http://localhost:3000/callback"],
- "token_endpoint_auth_method": "none",
- }
- httpx_mock.add_response(
- json=doc_data,
- headers={"content-length": "200"},
- )
-
- client = await manager.get_client(url)
- assert client is not None
- assert client.client_id == url
- assert client.client_name == "Test App"
- # Verify it uses proxy's patterns (None by default), not document's redirect_uris
- assert client.allowed_redirect_uri_patterns is None
-
- async def test_get_client_disabled(self, disabled_manager):
- """Test that get_client returns None when disabled."""
- client = await disabled_manager.get_client("https://example.com/client.json")
- assert client is None
-
- async def test_get_client_fetch_failure(self, manager, httpx_mock, mock_dns):
- """Test that get_client returns None on fetch failure."""
- url = "https://example.com/client.json"
- httpx_mock.add_response(status_code=404)
-
- client = await manager.get_client(url)
- assert client is None
-
- # Trust policy and consent bypass tests removed - functionality removed from CIMD
-
-
-class TestCIMDClientManagerGetClientOptions:
- """Tests for CIMDClientManager.get_client with default_scope and allowed patterns."""
-
- @pytest.fixture
- def mock_dns(self):
- """Mock DNS resolution to return test public IP."""
- with patch(
- "fastmcp.server.auth.ssrf.resolve_hostname",
- return_value=[TEST_PUBLIC_IP],
- ):
- yield
-
- async def test_default_scope_applied_when_doc_has_no_scope(
- self, httpx_mock, mock_dns
- ):
- """When the CIMD document omits scope, the manager's default_scope is used."""
-
- url = "https://example.com/client.json"
- doc_data = {
- "client_id": url,
- "client_name": "Test App",
- "redirect_uris": ["http://localhost:3000/callback"],
- "token_endpoint_auth_method": "none",
- # No scope field
- }
- httpx_mock.add_response(
- json=doc_data,
- headers={"content-length": "200"},
- )
-
- manager = CIMDClientManager(
- enable_cimd=True,
- default_scope="read write admin",
- )
- client = await manager.get_client(url)
- assert client is not None
- assert client.scope == "read write admin"
-
- async def test_doc_scope_takes_precedence_over_default(self, httpx_mock, mock_dns):
- """When the CIMD document specifies scope, it wins over the default."""
-
- url = "https://example.com/client.json"
- doc_data = {
- "client_id": url,
- "client_name": "Test App",
- "redirect_uris": ["http://localhost:3000/callback"],
- "token_endpoint_auth_method": "none",
- "scope": "custom-scope",
- }
- httpx_mock.add_response(
- json=doc_data,
- headers={"content-length": "200"},
- )
-
- manager = CIMDClientManager(
- enable_cimd=True,
- default_scope="default-scope",
- )
- client = await manager.get_client(url)
- assert client is not None
- assert client.scope == "custom-scope"
-
- async def test_allowed_redirect_uri_patterns_stored_on_client(
- self, httpx_mock, mock_dns
- ):
- """Proxy's allowed_redirect_uri_patterns are forwarded to the created client."""
-
- url = "https://example.com/client.json"
- doc_data = {
- "client_id": url,
- "client_name": "Test App",
- "redirect_uris": ["http://localhost:*/callback"],
- "token_endpoint_auth_method": "none",
- }
- httpx_mock.add_response(
- json=doc_data,
- headers={"content-length": "200"},
- )
-
- patterns = ["http://localhost:*", "https://app.example.com/*"]
- manager = CIMDClientManager(
- enable_cimd=True,
- allowed_redirect_uri_patterns=patterns,
- )
- client = await manager.get_client(url)
- assert client is not None
- assert client.allowed_redirect_uri_patterns == patterns
-
- async def test_cimd_document_attached_to_client(self, httpx_mock, mock_dns):
- """The fetched CIMDDocument is attached to the created client."""
-
- url = "https://example.com/client.json"
- doc_data = {
- "client_id": url,
- "client_name": "Attached Doc App",
- "redirect_uris": ["http://localhost:3000/callback"],
- "token_endpoint_auth_method": "none",
- }
- httpx_mock.add_response(
- json=doc_data,
- headers={"content-length": "200"},
- )
-
- manager = CIMDClientManager(enable_cimd=True)
- client = await manager.get_client(url)
- assert client is not None
- assert client.cimd_document is not None
- assert client.cimd_document.client_name == "Attached Doc App"
- assert str(client.cimd_document.client_id) == url
-
-
-class TestCIMDClientManagerValidatePrivateKeyJwt:
- """Tests for CIMDClientManager.validate_private_key_jwt wrapper."""
-
- @pytest.fixture
- def manager(self):
- return CIMDClientManager(enable_cimd=True)
-
- async def test_missing_cimd_document_raises(self, manager):
- """validate_private_key_jwt raises ValueError if client has no cimd_document."""
-
- client = ProxyDCRClient(
- client_id="https://example.com/client.json",
- client_secret=None,
- redirect_uris=None,
- cimd_document=None,
- )
- with pytest.raises(ValueError, match="must have CIMD document"):
- await manager.validate_private_key_jwt(
- "fake.jwt.token",
- client,
- "https://oauth.example.com/token",
- )
-
- async def test_wrong_auth_method_raises(self, manager):
- """validate_private_key_jwt raises ValueError if auth method is not private_key_jwt."""
-
- cimd_doc = CIMDDocument(
- client_id=AnyHttpUrl("https://example.com/client.json"),
- redirect_uris=["http://localhost:3000/callback"],
- token_endpoint_auth_method="none", # Not private_key_jwt
- )
- client = ProxyDCRClient(
- client_id="https://example.com/client.json",
- client_secret=None,
- redirect_uris=None,
- cimd_document=cimd_doc,
- )
- with pytest.raises(ValueError, match="private_key_jwt"):
- await manager.validate_private_key_jwt(
- "fake.jwt.token",
- client,
- "https://oauth.example.com/token",
- )
-
- async def test_success_delegates_to_assertion_validator(self, manager):
- """On success, validate_private_key_jwt delegates to the assertion validator."""
-
- cimd_doc = CIMDDocument(
- client_id=AnyHttpUrl("https://example.com/client.json"),
- redirect_uris=["http://localhost:3000/callback"],
- token_endpoint_auth_method="private_key_jwt",
- jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
- )
- client = ProxyDCRClient(
- client_id="https://example.com/client.json",
- client_secret=None,
- redirect_uris=None,
- cimd_document=cimd_doc,
- )
-
- manager._assertion_validator.validate_assertion = AsyncMock(return_value=True)
-
- result = await manager.validate_private_key_jwt(
- "test.jwt.assertion",
- client,
- "https://oauth.example.com/token",
- )
- assert result is True
- manager._assertion_validator.validate_assertion.assert_awaited_once_with(
- "test.jwt.assertion",
- "https://example.com/client.json",
- "https://oauth.example.com/token",
- cimd_doc,
- )
-
-
-class TestCIMDRedirectUriEnforcement:
- """Tests for CIMD redirect_uri validation security.
-
- Verifies that CIMD clients enforce BOTH:
- 1. CIMD document's redirect_uris
- 2. Proxy's allowed_redirect_uri_patterns
- """
-
- @pytest.fixture
- def mock_dns(self):
- """Mock DNS resolution to return test public IP."""
- with patch(
- "fastmcp.server.auth.ssrf.resolve_hostname",
- return_value=[TEST_PUBLIC_IP],
- ):
- yield
-
- async def test_cimd_redirect_uris_enforced(self, httpx_mock, mock_dns):
- """Test that CIMD document redirect_uris are enforced.
-
- Even if proxy patterns allow http://localhost:*, a CIMD client
- should only accept URIs declared in its document.
- """
- from mcp.shared.auth import InvalidRedirectUriError
- from pydantic import AnyUrl
-
- url = "https://example.com/client.json"
- doc_data = {
- "client_id": url,
- "client_name": "Test App",
- # CIMD only declares port 3000
- "redirect_uris": ["http://localhost:3000/callback"],
- "token_endpoint_auth_method": "none",
- }
- httpx_mock.add_response(
- json=doc_data,
- headers={"content-length": "200"},
- )
-
- # Proxy allows any localhost port
- manager = CIMDClientManager(
- enable_cimd=True,
- allowed_redirect_uri_patterns=["http://localhost:*"],
- )
- client = await manager.get_client(url)
- assert client is not None
-
- # Declared URI should work
- validated = client.validate_redirect_uri(
- AnyUrl("http://localhost:3000/callback")
- )
- assert str(validated) == "http://localhost:3000/callback"
-
- # Different port should fail (not in CIMD redirect_uris)
- with pytest.raises(InvalidRedirectUriError):
- client.validate_redirect_uri(AnyUrl("http://localhost:4000/callback"))
-
- async def test_proxy_patterns_also_checked(self, httpx_mock, mock_dns):
- """Test that proxy patterns are checked even for CIMD clients.
-
- A CIMD client should not be able to use a redirect_uri that's
- in its document but not allowed by proxy patterns.
- """
- from mcp.shared.auth import InvalidRedirectUriError
- from pydantic import AnyUrl
-
- url = "https://example.com/client.json"
- doc_data = {
- "client_id": url,
- "client_name": "Test App",
- # CIMD declares both localhost and external URI
- "redirect_uris": [
- "http://localhost:3000/callback",
- "https://evil.com/callback",
- ],
- "token_endpoint_auth_method": "none",
- }
- httpx_mock.add_response(
- json=doc_data,
- headers={"content-length": "200"},
- )
-
- # Proxy only allows localhost
- manager = CIMDClientManager(
- enable_cimd=True,
- allowed_redirect_uri_patterns=["http://localhost:*"],
- )
- client = await manager.get_client(url)
- assert client is not None
-
- # Localhost should work (in CIMD and matches pattern)
- validated = client.validate_redirect_uri(
- AnyUrl("http://localhost:3000/callback")
- )
- assert str(validated) == "http://localhost:3000/callback"
-
- # Evil.com should fail (in CIMD but doesn't match proxy patterns)
- with pytest.raises(InvalidRedirectUriError):
- client.validate_redirect_uri(AnyUrl("https://evil.com/callback"))
diff --git a/tests/server/auth/test_cimd_validators.py b/tests/server/auth/test_cimd_validators.py
new file mode 100644
index 000000000..995854ba2
--- /dev/null
+++ b/tests/server/auth/test_cimd_validators.py
@@ -0,0 +1,682 @@
+"""Unit tests for CIMD assertion validators, client manager, and redirect URI enforcement."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from pydantic import AnyHttpUrl
+
+from fastmcp.server.auth.cimd import (
+ CIMDAssertionValidator,
+ CIMDClientManager,
+ CIMDDocument,
+)
+from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient
+
+# Standard public IP used for DNS mocking in tests
+TEST_PUBLIC_IP = "93.184.216.34"
+
+
+class TestCIMDAssertionValidator:
+ """Tests for CIMDAssertionValidator (private_key_jwt support)."""
+
+ @pytest.fixture
+ def validator(self):
+ """Create a CIMDAssertionValidator for testing."""
+ return CIMDAssertionValidator()
+
+ @pytest.fixture
+ def key_pair(self):
+ """Generate RSA key pair for testing."""
+ from fastmcp.server.auth.providers.jwt import RSAKeyPair
+
+ return RSAKeyPair.generate()
+
+ @pytest.fixture
+ def jwks(self, key_pair):
+ """Create JWKS from key pair."""
+ import base64
+
+ from cryptography.hazmat.backends import default_backend
+ from cryptography.hazmat.primitives import serialization
+
+ # Load public key
+ public_key = serialization.load_pem_public_key(
+ key_pair.public_key.encode(), backend=default_backend()
+ )
+
+ # Get RSA public numbers
+ from cryptography.hazmat.primitives.asymmetric import rsa
+
+ if isinstance(public_key, rsa.RSAPublicKey):
+ numbers = public_key.public_numbers()
+
+ # Convert to JWK format
+ return {
+ "keys": [
+ {
+ "kty": "RSA",
+ "kid": "test-key-1",
+ "use": "sig",
+ "alg": "RS256",
+ "n": base64.urlsafe_b64encode(
+ numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")
+ )
+ .rstrip(b"=")
+ .decode(),
+ "e": base64.urlsafe_b64encode(
+ numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")
+ )
+ .rstrip(b"=")
+ .decode(),
+ }
+ ]
+ }
+
+ @pytest.fixture
+ def cimd_doc_with_jwks_uri(self):
+ """Create CIMD document with jwks_uri."""
+ return CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="private_key_jwt",
+ jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
+ )
+
+ @pytest.fixture
+ def cimd_doc_with_inline_jwks(self, jwks):
+ """Create CIMD document with inline JWKS."""
+ return CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="private_key_jwt",
+ jwks=jwks,
+ )
+
+ async def test_valid_assertion_with_jwks_uri(
+ self, validator, key_pair, cimd_doc_with_jwks_uri, httpx_mock
+ ):
+ """Test that valid JWT assertion passes validation (jwks_uri)."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Mock JWKS endpoint
+ import base64
+
+ from cryptography.hazmat.backends import default_backend
+ from cryptography.hazmat.primitives import serialization
+
+ public_key = serialization.load_pem_public_key(
+ key_pair.public_key.encode(), backend=default_backend()
+ )
+ from cryptography.hazmat.primitives.asymmetric import rsa
+
+ assert isinstance(public_key, rsa.RSAPublicKey)
+ numbers = public_key.public_numbers()
+
+ jwks = {
+ "keys": [
+ {
+ "kty": "RSA",
+ "kid": "test-key-1",
+ "use": "sig",
+ "alg": "RS256",
+ "n": base64.urlsafe_b64encode(
+ numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")
+ )
+ .rstrip(b"=")
+ .decode(),
+ "e": base64.urlsafe_b64encode(
+ numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")
+ )
+ .rstrip(b"=")
+ .decode(),
+ }
+ ]
+ }
+
+ # Mock DNS resolution for SSRF-safe fetch
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ httpx_mock.add_response(json=jwks)
+
+ # Create valid assertion (use short lifetime for security compliance)
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience=token_endpoint,
+ additional_claims={"jti": "unique-jti-123"},
+ expires_in_seconds=60, # 1 minute (max allowed is 300s)
+ kid="test-key-1",
+ )
+
+ # Should validate successfully
+ assert await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_jwks_uri
+ )
+
+ async def test_valid_assertion_with_inline_jwks(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that valid JWT assertion passes validation (inline JWKS)."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create valid assertion (use short lifetime for security compliance)
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience=token_endpoint,
+ additional_claims={"jti": "unique-jti-456"},
+ expires_in_seconds=60, # 1 minute (max allowed is 300s)
+ kid="test-key-1",
+ )
+
+ # Should validate successfully
+ assert await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+
+ async def test_rejects_wrong_issuer(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that wrong issuer is rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create assertion with wrong issuer
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer="https://attacker.com", # Wrong!
+ audience=token_endpoint,
+ additional_claims={"jti": "unique-jti-789"},
+ expires_in_seconds=60,
+ kid="test-key-1",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "Invalid JWT assertion" in str(exc_info.value)
+
+ async def test_rejects_wrong_audience(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that wrong audience is rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create assertion with wrong audience
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience="https://wrong-endpoint.com/token", # Wrong!
+ additional_claims={"jti": "unique-jti-abc"},
+ expires_in_seconds=60,
+ kid="test-key-1",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "Invalid JWT assertion" in str(exc_info.value)
+
+ async def test_rejects_wrong_subject(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that wrong subject claim is rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create assertion with wrong subject
+ assertion = key_pair.create_token(
+ subject="https://different-client.com", # Wrong!
+ issuer=client_id,
+ audience=token_endpoint,
+ additional_claims={"jti": "unique-jti-def"},
+ expires_in_seconds=60,
+ kid="test-key-1",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "sub claim must be" in str(exc_info.value)
+
+ async def test_rejects_missing_jti(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that missing jti claim is rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create assertion without jti
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience=token_endpoint,
+ # No jti!
+ expires_in_seconds=60,
+ kid="test-key-1",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "jti claim" in str(exc_info.value)
+
+ async def test_rejects_replayed_jti(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that replayed JTI is detected and rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create assertion
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience=token_endpoint,
+ additional_claims={"jti": "replayed-jti"},
+ expires_in_seconds=60,
+ kid="test-key-1",
+ )
+
+ # First use should succeed
+ assert await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+
+ # Second use with same jti should fail (replay attack)
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "replay" in str(exc_info.value).lower()
+
+ async def test_rejects_expired_token(
+ self, validator, key_pair, cimd_doc_with_inline_jwks
+ ):
+ """Test that expired tokens are rejected."""
+ client_id = "https://example.com/client.json"
+ token_endpoint = "https://oauth.example.com/token"
+
+ # Create expired assertion (expired 1 hour ago)
+ assertion = key_pair.create_token(
+ subject=client_id,
+ issuer=client_id,
+ audience=token_endpoint,
+ additional_claims={"jti": "expired-jti"},
+ expires_in_seconds=-3600, # Negative = expired
+ kid="test-key-1",
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ await validator.validate_assertion(
+ assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
+ )
+ assert "Invalid JWT assertion" in str(exc_info.value)
+
+
+class TestCIMDClientManager:
+ """Tests for CIMDClientManager."""
+
+ @pytest.fixture
+ def manager(self):
+ """Create a CIMDClientManager for testing."""
+ return CIMDClientManager(enable_cimd=True)
+
+ @pytest.fixture
+ def disabled_manager(self):
+ """Create a disabled CIMDClientManager for testing."""
+ return CIMDClientManager(enable_cimd=False)
+
+ @pytest.fixture
+ def mock_dns(self):
+ """Mock DNS resolution to return test public IP."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ yield
+
+ def test_is_cimd_client_id_enabled(self, manager):
+ """Test CIMD URL detection when enabled."""
+ assert manager.is_cimd_client_id("https://example.com/client.json")
+ assert not manager.is_cimd_client_id("regular-client-id")
+
+ def test_is_cimd_client_id_disabled(self, disabled_manager):
+ """Test CIMD URL detection when disabled."""
+ assert not disabled_manager.is_cimd_client_id("https://example.com/client.json")
+ assert not disabled_manager.is_cimd_client_id("regular-client-id")
+
+ async def test_get_client_success(self, manager, httpx_mock, mock_dns):
+ """Test successful CIMD client creation."""
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ client = await manager.get_client(url)
+ assert client is not None
+ assert client.client_id == url
+ assert client.client_name == "Test App"
+ # Verify it uses proxy's patterns (None by default), not document's redirect_uris
+ assert client.allowed_redirect_uri_patterns is None
+
+ async def test_get_client_disabled(self, disabled_manager):
+ """Test that get_client returns None when disabled."""
+ client = await disabled_manager.get_client("https://example.com/client.json")
+ assert client is None
+
+ async def test_get_client_fetch_failure(self, manager, httpx_mock, mock_dns):
+ """Test that get_client returns None on fetch failure."""
+ url = "https://example.com/client.json"
+ httpx_mock.add_response(status_code=404)
+
+ client = await manager.get_client(url)
+ assert client is None
+
+ # Trust policy and consent bypass tests removed - functionality removed from CIMD
+
+
+class TestCIMDClientManagerGetClientOptions:
+ """Tests for CIMDClientManager.get_client with default_scope and allowed patterns."""
+
+ @pytest.fixture
+ def mock_dns(self):
+ """Mock DNS resolution to return test public IP."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ yield
+
+ async def test_default_scope_applied_when_doc_has_no_scope(
+ self, httpx_mock, mock_dns
+ ):
+ """When the CIMD document omits scope, the manager's default_scope is used."""
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ # No scope field
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ manager = CIMDClientManager(
+ enable_cimd=True,
+ default_scope="read write admin",
+ )
+ client = await manager.get_client(url)
+ assert client is not None
+ assert client.scope == "read write admin"
+
+ async def test_doc_scope_takes_precedence_over_default(self, httpx_mock, mock_dns):
+ """When the CIMD document specifies scope, it wins over the default."""
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ "scope": "custom-scope",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ manager = CIMDClientManager(
+ enable_cimd=True,
+ default_scope="default-scope",
+ )
+ client = await manager.get_client(url)
+ assert client is not None
+ assert client.scope == "custom-scope"
+
+ async def test_allowed_redirect_uri_patterns_stored_on_client(
+ self, httpx_mock, mock_dns
+ ):
+ """Proxy's allowed_redirect_uri_patterns are forwarded to the created client."""
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ "redirect_uris": ["http://localhost:*/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ patterns = ["http://localhost:*", "https://app.example.com/*"]
+ manager = CIMDClientManager(
+ enable_cimd=True,
+ allowed_redirect_uri_patterns=patterns,
+ )
+ client = await manager.get_client(url)
+ assert client is not None
+ assert client.allowed_redirect_uri_patterns == patterns
+
+ async def test_cimd_document_attached_to_client(self, httpx_mock, mock_dns):
+ """The fetched CIMDDocument is attached to the created client."""
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Attached Doc App",
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ manager = CIMDClientManager(enable_cimd=True)
+ client = await manager.get_client(url)
+ assert client is not None
+ assert client.cimd_document is not None
+ assert client.cimd_document.client_name == "Attached Doc App"
+ assert str(client.cimd_document.client_id) == url
+
+
+class TestCIMDClientManagerValidatePrivateKeyJwt:
+ """Tests for CIMDClientManager.validate_private_key_jwt wrapper."""
+
+ @pytest.fixture
+ def manager(self):
+ return CIMDClientManager(enable_cimd=True)
+
+ async def test_missing_cimd_document_raises(self, manager):
+ """validate_private_key_jwt raises ValueError if client has no cimd_document."""
+
+ client = ProxyDCRClient(
+ client_id="https://example.com/client.json",
+ client_secret=None,
+ redirect_uris=None,
+ cimd_document=None,
+ )
+ with pytest.raises(ValueError, match="must have CIMD document"):
+ await manager.validate_private_key_jwt(
+ "fake.jwt.token",
+ client,
+ "https://oauth.example.com/token",
+ )
+
+ async def test_wrong_auth_method_raises(self, manager):
+ """validate_private_key_jwt raises ValueError if auth method is not private_key_jwt."""
+
+ cimd_doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="none", # Not private_key_jwt
+ )
+ client = ProxyDCRClient(
+ client_id="https://example.com/client.json",
+ client_secret=None,
+ redirect_uris=None,
+ cimd_document=cimd_doc,
+ )
+ with pytest.raises(ValueError, match="private_key_jwt"):
+ await manager.validate_private_key_jwt(
+ "fake.jwt.token",
+ client,
+ "https://oauth.example.com/token",
+ )
+
+ async def test_success_delegates_to_assertion_validator(self, manager):
+ """On success, validate_private_key_jwt delegates to the assertion validator."""
+
+ cimd_doc = CIMDDocument(
+ client_id=AnyHttpUrl("https://example.com/client.json"),
+ redirect_uris=["http://localhost:3000/callback"],
+ token_endpoint_auth_method="private_key_jwt",
+ jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
+ )
+ client = ProxyDCRClient(
+ client_id="https://example.com/client.json",
+ client_secret=None,
+ redirect_uris=None,
+ cimd_document=cimd_doc,
+ )
+
+ manager._assertion_validator.validate_assertion = AsyncMock(return_value=True)
+
+ result = await manager.validate_private_key_jwt(
+ "test.jwt.assertion",
+ client,
+ "https://oauth.example.com/token",
+ )
+ assert result is True
+ manager._assertion_validator.validate_assertion.assert_awaited_once_with(
+ "test.jwt.assertion",
+ "https://example.com/client.json",
+ "https://oauth.example.com/token",
+ cimd_doc,
+ )
+
+
+class TestCIMDRedirectUriEnforcement:
+ """Tests for CIMD redirect_uri validation security.
+
+ Verifies that CIMD clients enforce BOTH:
+ 1. CIMD document's redirect_uris
+ 2. Proxy's allowed_redirect_uri_patterns
+ """
+
+ @pytest.fixture
+ def mock_dns(self):
+ """Mock DNS resolution to return test public IP."""
+ with patch(
+ "fastmcp.server.auth.ssrf.resolve_hostname",
+ return_value=[TEST_PUBLIC_IP],
+ ):
+ yield
+
+ async def test_cimd_redirect_uris_enforced(self, httpx_mock, mock_dns):
+ """Test that CIMD document redirect_uris are enforced.
+
+ Even if proxy patterns allow http://localhost:*, a CIMD client
+ should only accept URIs declared in its document.
+ """
+ from mcp.shared.auth import InvalidRedirectUriError
+ from pydantic import AnyUrl
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ # CIMD only declares port 3000
+ "redirect_uris": ["http://localhost:3000/callback"],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ # Proxy allows any localhost port
+ manager = CIMDClientManager(
+ enable_cimd=True,
+ allowed_redirect_uri_patterns=["http://localhost:*"],
+ )
+ client = await manager.get_client(url)
+ assert client is not None
+
+ # Declared URI should work
+ validated = client.validate_redirect_uri(
+ AnyUrl("http://localhost:3000/callback")
+ )
+ assert str(validated) == "http://localhost:3000/callback"
+
+ # Different port should fail (not in CIMD redirect_uris)
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("http://localhost:4000/callback"))
+
+ async def test_proxy_patterns_also_checked(self, httpx_mock, mock_dns):
+ """Test that proxy patterns are checked even for CIMD clients.
+
+ A CIMD client should not be able to use a redirect_uri that's
+ in its document but not allowed by proxy patterns.
+ """
+ from mcp.shared.auth import InvalidRedirectUriError
+ from pydantic import AnyUrl
+
+ url = "https://example.com/client.json"
+ doc_data = {
+ "client_id": url,
+ "client_name": "Test App",
+ # CIMD declares both localhost and external URI
+ "redirect_uris": [
+ "http://localhost:3000/callback",
+ "https://evil.com/callback",
+ ],
+ "token_endpoint_auth_method": "none",
+ }
+ httpx_mock.add_response(
+ json=doc_data,
+ headers={"content-length": "200"},
+ )
+
+ # Proxy only allows localhost
+ manager = CIMDClientManager(
+ enable_cimd=True,
+ allowed_redirect_uri_patterns=["http://localhost:*"],
+ )
+ client = await manager.get_client(url)
+ assert client is not None
+
+ # Localhost should work (in CIMD and matches pattern)
+ validated = client.validate_redirect_uri(
+ AnyUrl("http://localhost:3000/callback")
+ )
+ assert str(validated) == "http://localhost:3000/callback"
+
+ # Evil.com should fail (in CIMD but doesn't match proxy patterns)
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("https://evil.com/callback"))
diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py
index bced42a1f..19b0f9370 100644
--- a/tests/server/auth/test_jwt_provider.py
+++ b/tests/server/auth/test_jwt_provider.py
@@ -2,12 +2,10 @@ from collections.abc import AsyncGenerator
from typing import Any
from unittest.mock import patch
-import httpx
import pytest
from pytest_httpx import HTTPXMock
-from fastmcp import Client, FastMCP
-from fastmcp.client.auth.bearer import BearerAuth
+from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, JWTVerifier, RSAKeyPair
from fastmcp.utilities.tests import run_server_async
@@ -576,546 +574,3 @@ class TestBearerTokenJWKS:
access_token = await jwks_provider.load_access_token(token)
assert access_token is None
-
-
-class TestBearerToken:
- def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
- """Test provider initialization with public key."""
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key, issuer="https://test.example.com"
- )
-
- assert provider.issuer == "https://test.example.com"
- assert provider.public_key is not None
- assert provider.jwks_uri is None
-
- def test_initialization_with_jwks_uri(self):
- """Test provider initialization with JWKS URI."""
- provider = JWTVerifier(
- jwks_uri="https://test.example.com/.well-known/jwks.json",
- issuer="https://test.example.com",
- )
-
- assert provider.issuer == "https://test.example.com"
- assert provider.jwks_uri == "https://test.example.com/.well-known/jwks.json"
- assert provider.public_key is None
-
- def test_initialization_requires_key_or_uri(self):
- """Test that either public_key or jwks_uri is required."""
- with pytest.raises(
- ValueError, match="Either public_key or jwks_uri must be provided"
- ):
- JWTVerifier(issuer="https://test.example.com")
-
- def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair):
- """Test that both public_key and jwks_uri cannot be provided."""
- with pytest.raises(
- ValueError, match="Provide either public_key or jwks_uri, not both"
- ):
- JWTVerifier(
- public_key=rsa_key_pair.public_key,
- jwks_uri="https://test.example.com/.well-known/jwks.json",
- issuer="https://test.example.com",
- )
-
- async def test_valid_token_validation(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test validation of a valid token."""
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- scopes=["read", "write"],
- )
-
- access_token = await bearer_provider.load_access_token(token)
-
- assert access_token is not None
- assert access_token.client_id == "test-user"
- assert "read" in access_token.scopes
- assert "write" in access_token.scopes
- assert access_token.expires_at is not None
-
- async def test_expired_token_rejection(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test rejection of expired tokens."""
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- expires_in_seconds=-3600, # Expired 1 hour ago
- )
-
- access_token = await bearer_provider.load_access_token(token)
- assert access_token is None
-
- async def test_invalid_issuer_rejection(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test rejection of tokens with invalid issuer."""
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://evil.example.com", # Wrong issuer
- audience="https://api.example.com",
- )
-
- access_token = await bearer_provider.load_access_token(token)
- assert access_token is None
-
- async def test_invalid_audience_rejection(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test rejection of tokens with invalid audience."""
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://wrong-api.example.com", # Wrong audience
- )
-
- access_token = await bearer_provider.load_access_token(token)
- assert access_token is None
-
- async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
- """Test that issuer validation is skipped when provider has no issuer configured."""
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key,
- issuer=None, # No issuer validation
- )
-
- token = rsa_key_pair.create_token(
- subject="test-user", issuer="https://any.example.com"
- )
-
- access_token = await provider.load_access_token(token)
- assert access_token is not None
-
- async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
- """Test that audience validation is skipped when provider has no audience configured."""
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key,
- issuer="https://test.example.com",
- audience=None, # No audience validation
- )
-
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://any-api.example.com",
- )
-
- access_token = await provider.load_access_token(token)
- assert access_token is not None
-
- async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
- """Test validation with multiple audiences in token."""
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key,
- issuer="https://test.example.com",
- audience="https://api.example.com",
- )
-
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- additional_claims={
- "aud": ["https://api.example.com", "https://other-api.example.com"]
- },
- )
-
- access_token = await provider.load_access_token(token)
- assert access_token is not None
-
- async def test_provider_with_multiple_expected_audiences(
- self, rsa_key_pair: RSAKeyPair
- ):
- """Test provider configured with multiple expected audiences."""
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key,
- issuer="https://test.example.com",
- audience=["https://api.example.com", "https://other-api.example.com"],
- )
-
- # Token with single audience that matches one of the expected
- token1 = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- )
- access_token1 = await provider.load_access_token(token1)
- assert access_token1 is not None
-
- # Token with multiple audiences, one of which matches
- token2 = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- additional_claims={
- "aud": ["https://api.example.com", "https://third-party.example.com"]
- },
- )
- access_token2 = await provider.load_access_token(token2)
- assert access_token2 is not None
-
- # Token with audience that doesn't match any expected
- token3 = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://wrong-api.example.com",
- )
- access_token3 = await provider.load_access_token(token3)
- assert access_token3 is None
-
- @pytest.mark.parametrize(
- ("iss", "expected"),
- [
- ("https://test.example.com", True),
- ("https://other-issuer.example.com", True),
- ("https://wrong-issuer.example.com", False),
- ],
- )
- async def test_provider_with_multiple_expected_issuers(
- self, rsa_key_pair: RSAKeyPair, iss: str, expected: bool
- ):
- """Provider accepts any issuer from the configured list."""
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key,
- issuer=["https://test.example.com", "https://other-issuer.example.com"],
- audience="https://api.example.com",
- )
- token = rsa_key_pair.create_token(
- subject="test-user", issuer=iss, audience="https://api.example.com"
- )
- access_token = await provider.load_access_token(token)
- assert (access_token is not None) is expected
-
- async def test_scope_extraction_string(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test scope extraction from space-separated string."""
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- scopes=["read", "write", "admin"],
- )
-
- access_token = await bearer_provider.load_access_token(token)
-
- assert access_token is not None
- assert set(access_token.scopes) == {"read", "write", "admin"}
-
- async def test_scope_extraction_list(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test scope extraction from list format."""
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- additional_claims={"scope": ["read", "write"]}, # List format
- )
-
- access_token = await bearer_provider.load_access_token(token)
-
- assert access_token is not None
- assert set(access_token.scopes) == {"read", "write"}
-
- async def test_no_scopes(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test token with no scopes."""
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- # No scopes
- )
-
- access_token = await bearer_provider.load_access_token(token)
-
- assert access_token is not None
- assert access_token.scopes == []
-
- async def test_scp_claim_extraction_string(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test scope extraction from 'scp' claim with space-separated string."""
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- additional_claims={"scp": "read write admin"}, # 'scp' claim as string
- )
-
- access_token = await bearer_provider.load_access_token(token)
-
- assert access_token is not None
- assert set(access_token.scopes) == {"read", "write", "admin"}
-
- async def test_scp_claim_extraction_list(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test scope extraction from 'scp' claim with list format."""
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- additional_claims={
- "scp": ["read", "write", "admin"]
- }, # 'scp' claim as list
- )
-
- access_token = await bearer_provider.load_access_token(token)
-
- assert access_token is not None
- assert set(access_token.scopes) == {"read", "write", "admin"}
-
- async def test_scope_precedence_over_scp(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test that 'scope' claim takes precedence over 'scp' claim when both are present."""
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- additional_claims={
- "scope": "read write", # Standard OAuth2 claim
- "scp": "admin delete", # Should be ignored when 'scope' is present
- },
- )
-
- access_token = await bearer_provider.load_access_token(token)
-
- assert access_token is not None
- assert set(access_token.scopes) == {"read", "write"} # Only 'scope' claim used
-
- async def test_malformed_token_rejection(self, bearer_provider: JWTVerifier):
- """Test rejection of malformed tokens."""
- malformed_tokens = [
- "not.a.jwt",
- "too.many.parts.here.invalid",
- "invalid-token",
- "",
- "header.body", # Missing signature
- ]
-
- for token in malformed_tokens:
- access_token = await bearer_provider.load_access_token(token)
- assert access_token is None
-
- async def test_invalid_signature_rejection(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test rejection of tokens with invalid signatures."""
- # Create a token with a different key pair
- other_key_pair = RSAKeyPair.generate()
- token = other_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- )
-
- access_token = await bearer_provider.load_access_token(token)
- assert access_token is None
-
- async def test_client_id_fallback(
- self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
- ):
- """Test client_id extraction with fallback logic."""
- # Test with explicit client_id claim
- token = rsa_key_pair.create_token(
- subject="user123",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- additional_claims={"client_id": "app456"},
- )
-
- access_token = await bearer_provider.load_access_token(token)
- assert access_token is not None
- assert access_token.client_id == "app456" # Should prefer client_id over sub
-
- async def test_string_issuer_validation(self, rsa_key_pair: RSAKeyPair):
- """Test that string (non-URL) issuers are supported per RFC 7519."""
- # Create provider with string issuer
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key,
- issuer="my-service", # String issuer, not a URL
- )
-
- # Create token with matching string issuer
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="my-service", # Same string issuer
- )
-
- access_token = await provider.load_access_token(token)
- assert access_token is not None
- assert access_token.client_id == "test-user"
-
- async def test_string_issuer_mismatch_rejection(self, rsa_key_pair: RSAKeyPair):
- """Test that mismatched string issuers are rejected."""
- # Create provider with one string issuer
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key,
- issuer="my-service",
- )
-
- # Create token with different string issuer
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="other-service", # Different string issuer
- )
-
- access_token = await provider.load_access_token(token)
- assert access_token is None
-
- async def test_url_issuer_still_works(self, rsa_key_pair: RSAKeyPair):
- """Test that URL issuers still work after the fix."""
- # Create provider with URL issuer
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key,
- issuer="https://my-auth-server.com", # URL issuer
- )
-
- # Create token with matching URL issuer
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://my-auth-server.com", # Same URL issuer
- )
-
- access_token = await provider.load_access_token(token)
- assert access_token is not None
- assert access_token.client_id == "test-user"
-
-
-class TestFastMCPBearerAuth:
- def test_bearer_auth(self):
- mcp = FastMCP(
- auth=JWTVerifier(issuer="https://test.example.com", public_key="abc")
- )
- assert isinstance(mcp.auth, JWTVerifier)
-
- 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, mcp_server_url: str, bearer_token):
- async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client:
- tools = await client.list_tools() # noqa: F841
- assert tools
-
- async def test_invalid_token_raises_401(self, mcp_server_url: str):
- with pytest.raises(httpx.HTTPStatusError) as exc_info:
- async with Client(mcp_server_url, auth=BearerAuth("invalid")) 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_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair):
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- expires_in_seconds=-3600,
- )
-
- with pytest.raises(httpx.HTTPStatusError) as exc_info:
- async with Client(mcp_server_url, auth=BearerAuth(token)) 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_token_with_bad_signature(self, mcp_server_url: str):
- rsa_key_pair = RSAKeyPair.generate()
- token = rsa_key_pair.create_token()
-
- with pytest.raises(httpx.HTTPStatusError) as exc_info:
- async with Client(mcp_server_url, auth=BearerAuth(token)) 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_token_with_insufficient_scopes(self, rsa_key_pair: RSAKeyPair):
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- scopes=["read"],
- )
-
- server = create_mcp_server(
- public_key=rsa_key_pair.public_key,
- auth_kwargs=dict(required_scopes=["read", "write"]),
- )
-
- async with run_server_async(server, transport="http") as mcp_server_url:
- with pytest.raises(httpx.HTTPStatusError) as exc_info:
- async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
- tools = await client.list_tools() # noqa: F841
- # JWTVerifier returns 401 when verify_token returns None (invalid token)
- # This is correct behavior - when TokenVerifier.verify_token returns None,
- # it indicates the token is invalid (not just insufficient permissions)
- assert isinstance(exc_info.value, httpx.HTTPStatusError)
- assert exc_info.value.response.status_code == 401
- assert "tools" not in locals()
-
- async def test_token_with_sufficient_scopes(self, rsa_key_pair: RSAKeyPair):
- token = rsa_key_pair.create_token(
- subject="test-user",
- issuer="https://test.example.com",
- audience="https://api.example.com",
- scopes=["read", "write"],
- )
-
- server = create_mcp_server(
- public_key=rsa_key_pair.public_key,
- auth_kwargs=dict(required_scopes=["read", "write"]),
- )
-
- async with run_server_async(server, transport="http") as mcp_server_url:
- async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
- tools = await client.list_tools()
- assert tools
-
-
-class TestJWTVerifierImport:
- """Test JWT token verifier can be imported and created."""
-
- def test_jwt_verifier_requires_pyjwt(self):
- """Test that JWTVerifier raises helpful error without PyJWT."""
- # Since PyJWT is likely installed in test environment, we'll just test construction
- from fastmcp.server.auth.providers.jwt import JWTVerifier
-
- # This should work if PyJWT is available
- try:
- verifier = JWTVerifier(public_key="dummy-key")
- assert verifier.public_key == "dummy-key"
- assert verifier.algorithm == "RS256"
- except ImportError as e:
- # If PyJWT not available, should get helpful error
- assert "PyJWT is required" in str(e)
-
-
-class TestScopesSupported:
- """Tests for the scopes_supported property on TokenVerifier."""
-
- def test_defaults_to_required_scopes(self, rsa_key_pair: RSAKeyPair):
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key,
- required_scopes=["read", "write"],
- )
- assert provider.scopes_supported == ["read", "write"]
-
- def test_empty_when_no_required_scopes(self, rsa_key_pair: RSAKeyPair):
- provider = JWTVerifier(
- public_key=rsa_key_pair.public_key,
- )
- assert provider.scopes_supported == []
diff --git a/tests/server/auth/test_jwt_provider_bearer.py b/tests/server/auth/test_jwt_provider_bearer.py
new file mode 100644
index 000000000..c88665c87
--- /dev/null
+++ b/tests/server/auth/test_jwt_provider_bearer.py
@@ -0,0 +1,610 @@
+from collections.abc import AsyncGenerator
+from typing import Any
+
+import httpx
+import pytest
+
+from fastmcp import Client, FastMCP
+from fastmcp.client.auth.bearer import BearerAuth
+from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
+from fastmcp.utilities.tests import run_server_async
+
+# Standard public IP used for DNS mocking in tests
+TEST_PUBLIC_IP = "93.184.216.34"
+
+
+@pytest.fixture(scope="module")
+def rsa_key_pair() -> RSAKeyPair:
+ return RSAKeyPair.generate()
+
+
+@pytest.fixture(scope="module")
+def bearer_token(rsa_key_pair: RSAKeyPair) -> str:
+ return rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ )
+
+
+@pytest.fixture
+def bearer_provider(rsa_key_pair: RSAKeyPair) -> JWTVerifier:
+ return JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ )
+
+
+def create_mcp_server(
+ public_key: str,
+ auth_kwargs: dict[str, Any] | None = None,
+) -> FastMCP:
+ mcp = FastMCP(
+ auth=JWTVerifier(
+ public_key=public_key,
+ **auth_kwargs or {},
+ )
+ )
+
+ @mcp.tool
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ return mcp
+
+
+@pytest.fixture
+async def mcp_server_url(rsa_key_pair: RSAKeyPair) -> AsyncGenerator[str, None]:
+ server = create_mcp_server(
+ public_key=rsa_key_pair.public_key,
+ auth_kwargs=dict(
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ ),
+ )
+ async with run_server_async(server, transport="http") as url:
+ yield url
+
+
+class TestBearerToken:
+ def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
+ """Test provider initialization with public key."""
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key, issuer="https://test.example.com"
+ )
+
+ assert provider.issuer == "https://test.example.com"
+ assert provider.public_key is not None
+ assert provider.jwks_uri is None
+
+ def test_initialization_with_jwks_uri(self):
+ """Test provider initialization with JWKS URI."""
+ provider = JWTVerifier(
+ jwks_uri="https://test.example.com/.well-known/jwks.json",
+ issuer="https://test.example.com",
+ )
+
+ assert provider.issuer == "https://test.example.com"
+ assert provider.jwks_uri == "https://test.example.com/.well-known/jwks.json"
+ assert provider.public_key is None
+
+ def test_initialization_requires_key_or_uri(self):
+ """Test that either public_key or jwks_uri is required."""
+ with pytest.raises(
+ ValueError, match="Either public_key or jwks_uri must be provided"
+ ):
+ JWTVerifier(issuer="https://test.example.com")
+
+ def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair):
+ """Test that both public_key and jwks_uri cannot be provided."""
+ with pytest.raises(
+ ValueError, match="Provide either public_key or jwks_uri, not both"
+ ):
+ JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ jwks_uri="https://test.example.com/.well-known/jwks.json",
+ issuer="https://test.example.com",
+ )
+
+ async def test_valid_token_validation(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test validation of a valid token."""
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ scopes=["read", "write"],
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+
+ assert access_token is not None
+ assert access_token.client_id == "test-user"
+ assert "read" in access_token.scopes
+ assert "write" in access_token.scopes
+ assert access_token.expires_at is not None
+
+ async def test_expired_token_rejection(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test rejection of expired tokens."""
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ expires_in_seconds=-3600, # Expired 1 hour ago
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+ assert access_token is None
+
+ async def test_invalid_issuer_rejection(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test rejection of tokens with invalid issuer."""
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://evil.example.com", # Wrong issuer
+ audience="https://api.example.com",
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+ assert access_token is None
+
+ async def test_invalid_audience_rejection(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test rejection of tokens with invalid audience."""
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://wrong-api.example.com", # Wrong audience
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+ assert access_token is None
+
+ async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
+ """Test that issuer validation is skipped when provider has no issuer configured."""
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ issuer=None, # No issuer validation
+ )
+
+ token = rsa_key_pair.create_token(
+ subject="test-user", issuer="https://any.example.com"
+ )
+
+ access_token = await provider.load_access_token(token)
+ assert access_token is not None
+
+ async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
+ """Test that audience validation is skipped when provider has no audience configured."""
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ issuer="https://test.example.com",
+ audience=None, # No audience validation
+ )
+
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://any-api.example.com",
+ )
+
+ access_token = await provider.load_access_token(token)
+ assert access_token is not None
+
+ async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
+ """Test validation with multiple audiences in token."""
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ )
+
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ additional_claims={
+ "aud": ["https://api.example.com", "https://other-api.example.com"]
+ },
+ )
+
+ access_token = await provider.load_access_token(token)
+ assert access_token is not None
+
+ async def test_provider_with_multiple_expected_audiences(
+ self, rsa_key_pair: RSAKeyPair
+ ):
+ """Test provider configured with multiple expected audiences."""
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ issuer="https://test.example.com",
+ audience=["https://api.example.com", "https://other-api.example.com"],
+ )
+
+ # Token with single audience that matches one of the expected
+ token1 = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ )
+ access_token1 = await provider.load_access_token(token1)
+ assert access_token1 is not None
+
+ # Token with multiple audiences, one of which matches
+ token2 = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ additional_claims={
+ "aud": ["https://api.example.com", "https://third-party.example.com"]
+ },
+ )
+ access_token2 = await provider.load_access_token(token2)
+ assert access_token2 is not None
+
+ # Token with audience that doesn't match any expected
+ token3 = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://wrong-api.example.com",
+ )
+ access_token3 = await provider.load_access_token(token3)
+ assert access_token3 is None
+
+ @pytest.mark.parametrize(
+ ("iss", "expected"),
+ [
+ ("https://test.example.com", True),
+ ("https://other-issuer.example.com", True),
+ ("https://wrong-issuer.example.com", False),
+ ],
+ )
+ async def test_provider_with_multiple_expected_issuers(
+ self, rsa_key_pair: RSAKeyPair, iss: str, expected: bool
+ ):
+ """Provider accepts any issuer from the configured list."""
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ issuer=["https://test.example.com", "https://other-issuer.example.com"],
+ audience="https://api.example.com",
+ )
+ token = rsa_key_pair.create_token(
+ subject="test-user", issuer=iss, audience="https://api.example.com"
+ )
+ access_token = await provider.load_access_token(token)
+ assert (access_token is not None) is expected
+
+ async def test_scope_extraction_string(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test scope extraction from space-separated string."""
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ scopes=["read", "write", "admin"],
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+
+ assert access_token is not None
+ assert set(access_token.scopes) == {"read", "write", "admin"}
+
+ async def test_scope_extraction_list(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test scope extraction from list format."""
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ additional_claims={"scope": ["read", "write"]}, # List format
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+
+ assert access_token is not None
+ assert set(access_token.scopes) == {"read", "write"}
+
+ async def test_no_scopes(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test token with no scopes."""
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ # No scopes
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+
+ assert access_token is not None
+ assert access_token.scopes == []
+
+ async def test_scp_claim_extraction_string(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test scope extraction from 'scp' claim with space-separated string."""
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ additional_claims={"scp": "read write admin"}, # 'scp' claim as string
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+
+ assert access_token is not None
+ assert set(access_token.scopes) == {"read", "write", "admin"}
+
+ async def test_scp_claim_extraction_list(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test scope extraction from 'scp' claim with list format."""
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ additional_claims={
+ "scp": ["read", "write", "admin"]
+ }, # 'scp' claim as list
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+
+ assert access_token is not None
+ assert set(access_token.scopes) == {"read", "write", "admin"}
+
+ async def test_scope_precedence_over_scp(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test that 'scope' claim takes precedence over 'scp' claim when both are present."""
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ additional_claims={
+ "scope": "read write", # Standard OAuth2 claim
+ "scp": "admin delete", # Should be ignored when 'scope' is present
+ },
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+
+ assert access_token is not None
+ assert set(access_token.scopes) == {"read", "write"} # Only 'scope' claim used
+
+ async def test_malformed_token_rejection(self, bearer_provider: JWTVerifier):
+ """Test rejection of malformed tokens."""
+ malformed_tokens = [
+ "not.a.jwt",
+ "too.many.parts.here.invalid",
+ "invalid-token",
+ "",
+ "header.body", # Missing signature
+ ]
+
+ for token in malformed_tokens:
+ access_token = await bearer_provider.load_access_token(token)
+ assert access_token is None
+
+ async def test_invalid_signature_rejection(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test rejection of tokens with invalid signatures."""
+ # Create a token with a different key pair
+ other_key_pair = RSAKeyPair.generate()
+ token = other_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+ assert access_token is None
+
+ async def test_client_id_fallback(
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
+ ):
+ """Test client_id extraction with fallback logic."""
+ # Test with explicit client_id claim
+ token = rsa_key_pair.create_token(
+ subject="user123",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ additional_claims={"client_id": "app456"},
+ )
+
+ access_token = await bearer_provider.load_access_token(token)
+ assert access_token is not None
+ assert access_token.client_id == "app456" # Should prefer client_id over sub
+
+ async def test_string_issuer_validation(self, rsa_key_pair: RSAKeyPair):
+ """Test that string (non-URL) issuers are supported per RFC 7519."""
+ # Create provider with string issuer
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ issuer="my-service", # String issuer, not a URL
+ )
+
+ # Create token with matching string issuer
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="my-service", # Same string issuer
+ )
+
+ access_token = await provider.load_access_token(token)
+ assert access_token is not None
+ assert access_token.client_id == "test-user"
+
+ async def test_string_issuer_mismatch_rejection(self, rsa_key_pair: RSAKeyPair):
+ """Test that mismatched string issuers are rejected."""
+ # Create provider with one string issuer
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ issuer="my-service",
+ )
+
+ # Create token with different string issuer
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="other-service", # Different string issuer
+ )
+
+ access_token = await provider.load_access_token(token)
+ assert access_token is None
+
+ async def test_url_issuer_still_works(self, rsa_key_pair: RSAKeyPair):
+ """Test that URL issuers still work after the fix."""
+ # Create provider with URL issuer
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ issuer="https://my-auth-server.com", # URL issuer
+ )
+
+ # Create token with matching URL issuer
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://my-auth-server.com", # Same URL issuer
+ )
+
+ access_token = await provider.load_access_token(token)
+ assert access_token is not None
+ assert access_token.client_id == "test-user"
+
+
+class TestFastMCPBearerAuth:
+ def test_bearer_auth(self):
+ mcp = FastMCP(
+ auth=JWTVerifier(issuer="https://test.example.com", public_key="abc")
+ )
+ assert isinstance(mcp.auth, JWTVerifier)
+
+ 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, mcp_server_url: str, bearer_token):
+ async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client:
+ tools = await client.list_tools() # noqa: F841
+ assert tools
+
+ async def test_invalid_token_raises_401(self, mcp_server_url: str):
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
+ async with Client(mcp_server_url, auth=BearerAuth("invalid")) 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_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair):
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ expires_in_seconds=-3600,
+ )
+
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
+ async with Client(mcp_server_url, auth=BearerAuth(token)) 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_token_with_bad_signature(self, mcp_server_url: str):
+ rsa_key_pair = RSAKeyPair.generate()
+ token = rsa_key_pair.create_token()
+
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
+ async with Client(mcp_server_url, auth=BearerAuth(token)) 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_token_with_insufficient_scopes(self, rsa_key_pair: RSAKeyPair):
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ scopes=["read"],
+ )
+
+ server = create_mcp_server(
+ public_key=rsa_key_pair.public_key,
+ auth_kwargs=dict(required_scopes=["read", "write"]),
+ )
+
+ async with run_server_async(server, transport="http") as mcp_server_url:
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
+ tools = await client.list_tools() # noqa: F841
+ # JWTVerifier returns 401 when verify_token returns None (invalid token)
+ # This is correct behavior - when TokenVerifier.verify_token returns None,
+ # it indicates the token is invalid (not just insufficient permissions)
+ assert isinstance(exc_info.value, httpx.HTTPStatusError)
+ assert exc_info.value.response.status_code == 401
+ assert "tools" not in locals()
+
+ async def test_token_with_sufficient_scopes(self, rsa_key_pair: RSAKeyPair):
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ scopes=["read", "write"],
+ )
+
+ server = create_mcp_server(
+ public_key=rsa_key_pair.public_key,
+ auth_kwargs=dict(required_scopes=["read", "write"]),
+ )
+
+ async with run_server_async(server, transport="http") as mcp_server_url:
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
+ tools = await client.list_tools()
+ assert tools
+
+
+class TestJWTVerifierImport:
+ """Test JWT token verifier can be imported and created."""
+
+ def test_jwt_verifier_requires_pyjwt(self):
+ """Test that JWTVerifier raises helpful error without PyJWT."""
+ # Since PyJWT is likely installed in test environment, we'll just test construction
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
+
+ # This should work if PyJWT is available
+ try:
+ verifier = JWTVerifier(public_key="dummy-key")
+ assert verifier.public_key == "dummy-key"
+ assert verifier.algorithm == "RS256"
+ except ImportError as e:
+ # If PyJWT not available, should get helpful error
+ assert "PyJWT is required" in str(e)
+
+
+class TestScopesSupported:
+ """Tests for the scopes_supported property on TokenVerifier."""
+
+ def test_defaults_to_required_scopes(self, rsa_key_pair: RSAKeyPair):
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ required_scopes=["read", "write"],
+ )
+ assert provider.scopes_supported == ["read", "write"]
+
+ def test_empty_when_no_required_scopes(self, rsa_key_pair: RSAKeyPair):
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ )
+ assert provider.scopes_supported == []
diff --git a/tests/server/auth/test_multi_auth.py b/tests/server/auth/test_multi_auth.py
new file mode 100644
index 000000000..369da86a5
--- /dev/null
+++ b/tests/server/auth/test_multi_auth.py
@@ -0,0 +1,407 @@
+import httpx
+import pytest
+from pydantic import AnyHttpUrl
+
+from fastmcp import FastMCP
+from fastmcp.server.auth import MultiAuth, RemoteAuthProvider, TokenVerifier
+from fastmcp.server.auth.auth import AccessToken
+from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
+
+
+class RaisingVerifier(TokenVerifier):
+ """A verifier that always raises, for testing exception resilience."""
+
+ async def verify_token(self, token: str) -> AccessToken | None:
+ raise RuntimeError("simulated failure")
+
+
+class TestMultiAuthInit:
+ """Test MultiAuth initialization and validation."""
+
+ def test_requires_server_or_verifiers(self):
+ """MultiAuth with neither server nor verifiers raises ValueError."""
+ with pytest.raises(ValueError, match="at least a server or one verifier"):
+ MultiAuth()
+
+ def test_server_only(self):
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ provider = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ auth = MultiAuth(server=provider)
+ assert auth.server is provider
+ assert auth.verifiers == []
+
+ def test_verifiers_only(self):
+ v = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ auth = MultiAuth(verifiers=[v])
+ assert auth.server is None
+ assert auth.verifiers == [v]
+
+ def test_single_verifier_not_in_list(self):
+ """A single TokenVerifier (not in a list) is accepted."""
+ v = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ auth = MultiAuth(verifiers=v)
+ assert auth.verifiers == [v]
+
+ def test_base_url_from_server(self):
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ provider = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ auth = MultiAuth(server=provider)
+ assert auth.base_url == AnyHttpUrl("https://api.example.com/")
+
+ def test_base_url_override(self):
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ provider = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ auth = MultiAuth(server=provider, base_url="https://override.example.com")
+ assert auth.base_url == AnyHttpUrl("https://override.example.com/")
+
+ def test_required_scopes_from_server(self):
+ verifier = StaticTokenVerifier(
+ tokens={"t": {"client_id": "c", "scopes": ["read"]}},
+ required_scopes=["read"],
+ )
+ provider = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ auth = MultiAuth(server=provider)
+ assert auth.required_scopes == ["read"]
+
+
+class TestMultiAuthVerifyToken:
+ """Test MultiAuth token verification chain."""
+
+ async def test_server_verified_first(self):
+ """Server's verify_token is tried before verifiers."""
+ server_verifier = StaticTokenVerifier(
+ tokens={"server_token": {"client_id": "server-client", "scopes": []}}
+ )
+ server = RemoteAuthProvider(
+ token_verifier=server_verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ extra = StaticTokenVerifier(
+ tokens={"extra_token": {"client_id": "extra-client", "scopes": []}}
+ )
+
+ auth = MultiAuth(server=server, verifiers=[extra])
+
+ result = await auth.verify_token("server_token")
+ assert result is not None
+ assert result.client_id == "server-client"
+
+ async def test_falls_back_to_verifiers(self):
+ """When server rejects a token, verifiers are tried."""
+ server_verifier = StaticTokenVerifier(
+ tokens={"server_token": {"client_id": "server-client", "scopes": []}}
+ )
+ server = RemoteAuthProvider(
+ token_verifier=server_verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ extra = StaticTokenVerifier(
+ tokens={"m2m_token": {"client_id": "m2m-service", "scopes": []}}
+ )
+
+ auth = MultiAuth(server=server, verifiers=[extra])
+
+ result = await auth.verify_token("m2m_token")
+ assert result is not None
+ assert result.client_id == "m2m-service"
+
+ async def test_verifier_order_matters(self):
+ """Verifiers are tried in order; first match wins."""
+ v1 = StaticTokenVerifier(
+ tokens={"shared_token": {"client_id": "first", "scopes": []}}
+ )
+ v2 = StaticTokenVerifier(
+ tokens={"shared_token": {"client_id": "second", "scopes": []}}
+ )
+
+ auth = MultiAuth(verifiers=[v1, v2])
+ result = await auth.verify_token("shared_token")
+ assert result is not None
+ assert result.client_id == "first"
+
+ async def test_no_match_returns_none(self):
+ """When no server or verifier accepts the token, returns None."""
+ v = StaticTokenVerifier(tokens={"known": {"client_id": "c", "scopes": []}})
+ auth = MultiAuth(verifiers=[v])
+ result = await auth.verify_token("unknown")
+ assert result is None
+
+ async def test_verifiers_only_no_server(self):
+ """MultiAuth with only verifiers (no server) works."""
+ v1 = StaticTokenVerifier(tokens={"token_a": {"client_id": "a", "scopes": []}})
+ v2 = StaticTokenVerifier(tokens={"token_b": {"client_id": "b", "scopes": []}})
+
+ auth = MultiAuth(verifiers=[v1, v2])
+
+ result_a = await auth.verify_token("token_a")
+ assert result_a is not None
+ assert result_a.client_id == "a"
+
+ result_b = await auth.verify_token("token_b")
+ assert result_b is not None
+ assert result_b.client_id == "b"
+
+ async def test_raising_verifier_does_not_break_chain(self):
+ """If a verifier raises, the chain continues to the next source."""
+ good = StaticTokenVerifier(
+ tokens={"valid": {"client_id": "good-client", "scopes": []}}
+ )
+ auth = MultiAuth(verifiers=[RaisingVerifier(), good])
+
+ result = await auth.verify_token("valid")
+ assert result is not None
+ assert result.client_id == "good-client"
+
+ async def test_raising_server_does_not_break_chain(self):
+ """If the server raises, verifiers are still tried."""
+ good = StaticTokenVerifier(
+ tokens={"valid": {"client_id": "fallback", "scopes": []}}
+ )
+ auth = MultiAuth(server=RaisingVerifier(), verifiers=[good])
+
+ result = await auth.verify_token("valid")
+ assert result is not None
+ assert result.client_id == "fallback"
+
+ async def test_all_raising_returns_none(self):
+ """If every source raises, verify_token returns None."""
+ auth = MultiAuth(verifiers=[RaisingVerifier(), RaisingVerifier()])
+ result = await auth.verify_token("anything")
+ assert result is None
+
+ async def test_server_match_short_circuits(self):
+ """When the server matches, verifiers are not consulted."""
+ # Both server and verifier know the same token with different client_ids
+ server_verifier = StaticTokenVerifier(
+ tokens={"token": {"client_id": "from-server", "scopes": []}}
+ )
+ server = RemoteAuthProvider(
+ token_verifier=server_verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ extra = StaticTokenVerifier(
+ tokens={"token": {"client_id": "from-verifier", "scopes": []}}
+ )
+
+ auth = MultiAuth(server=server, verifiers=[extra])
+ result = await auth.verify_token("token")
+ assert result is not None
+ assert result.client_id == "from-server"
+
+
+class TestMultiAuthRoutes:
+ """Test that routes delegate to the server."""
+
+ def test_routes_from_server(self):
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ server = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ auth = MultiAuth(server=server)
+ routes = auth.get_routes(mcp_path="/mcp")
+ # RemoteAuthProvider creates a protected resource metadata route
+ assert len(routes) >= 1
+
+ def test_no_routes_without_server(self):
+ v = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ auth = MultiAuth(verifiers=[v])
+ assert auth.get_routes() == []
+
+ def test_well_known_routes_delegate_to_server(self):
+ """get_well_known_routes delegates to the server's implementation."""
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ server = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ auth = MultiAuth(server=server)
+ well_known = auth.get_well_known_routes(mcp_path="/mcp")
+ server_well_known = server.get_well_known_routes(mcp_path="/mcp")
+ # MultiAuth should produce the same well-known routes as the server
+ assert len(well_known) == len(server_well_known)
+ assert [r.path for r in well_known] == [r.path for r in server_well_known]
+
+ def test_well_known_routes_empty_without_server(self):
+ v = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ auth = MultiAuth(verifiers=[v])
+ assert auth.get_well_known_routes() == []
+
+ def test_required_scopes_explicit_empty_list(self):
+ """Passing required_scopes=[] explicitly clears inherited scopes."""
+ verifier = StaticTokenVerifier(
+ tokens={"t": {"client_id": "c", "scopes": ["read"]}},
+ required_scopes=["read"],
+ )
+ server = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ # Server has required_scopes=["read"], but we explicitly clear them
+ auth = MultiAuth(server=server, required_scopes=[])
+ assert auth.required_scopes == []
+
+
+class TestMultiAuthIntegration:
+ """Integration tests: MultiAuth with a real FastMCP HTTP app."""
+
+ async def test_multi_auth_rejects_bad_tokens(self):
+ """End-to-end: MultiAuth rejects unknown tokens at the HTTP layer."""
+ oauth_tokens = StaticTokenVerifier(
+ tokens={
+ "oauth_token": {
+ "client_id": "interactive-client",
+ "scopes": ["read"],
+ }
+ }
+ )
+ m2m_tokens = StaticTokenVerifier(
+ tokens={
+ "m2m_token": {
+ "client_id": "backend-service",
+ "scopes": ["read"],
+ }
+ }
+ )
+
+ auth = MultiAuth(verifiers=[oauth_tokens, m2m_tokens])
+ mcp = FastMCP("test", auth=auth)
+ app = mcp.http_app(path="/mcp")
+
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="http://localhost",
+ ) as client:
+ # No token β 401
+ response = await client.get("/mcp")
+ assert response.status_code == 401
+
+ # Bad token β 401
+ response = await client.get(
+ "/mcp", headers={"Authorization": "Bearer bad_token"}
+ )
+ assert response.status_code == 401
+
+ async def test_multi_auth_with_server_provides_routes(self):
+ """MultiAuth with a server exposes the server's metadata routes."""
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ server = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ extra = StaticTokenVerifier(tokens={"m2m": {"client_id": "svc", "scopes": []}})
+
+ auth = MultiAuth(server=server, verifiers=[extra])
+ mcp = FastMCP("test", auth=auth)
+ app = mcp.http_app(path="/mcp")
+
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="https://api.example.com",
+ ) as client:
+ # Protected resource metadata should be available
+ response = await client.get("/.well-known/oauth-protected-resource/mcp")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["resource"] == "https://api.example.com/mcp"
+
+ async def test_multi_auth_accepts_valid_verifier_token(self):
+ """MultiAuth accepts tokens from verifiers (not just the server).
+
+ Verifies that both server and verifier tokens pass the HTTP auth
+ middleware. We use GET /mcp to check: 401 means auth rejected,
+ any other status means auth accepted and the request reached the
+ MCP session layer.
+ """
+ interactive_tokens = StaticTokenVerifier(
+ tokens={
+ "interactive_token": {
+ "client_id": "interactive-client",
+ "scopes": [],
+ }
+ }
+ )
+ m2m_tokens = StaticTokenVerifier(
+ tokens={
+ "m2m_token": {
+ "client_id": "backend-service",
+ "scopes": [],
+ }
+ }
+ )
+
+ auth = MultiAuth(verifiers=[interactive_tokens, m2m_tokens])
+ mcp = FastMCP("test", auth=auth)
+ app = mcp.http_app(path="/mcp")
+
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app, raise_app_exceptions=False),
+ base_url="http://localhost",
+ ) as client:
+ # No token β 401
+ response = await client.get("/mcp")
+ assert response.status_code == 401
+
+ # Interactive token passes auth (non-401 means auth accepted)
+ response = await client.get(
+ "/mcp", headers={"Authorization": "Bearer interactive_token"}
+ )
+ assert response.status_code != 401
+
+ # M2M token also passes auth
+ response = await client.get(
+ "/mcp", headers={"Authorization": "Bearer m2m_token"}
+ )
+ assert response.status_code != 401
+
+ # Bad token β 401
+ response = await client.get(
+ "/mcp", headers={"Authorization": "Bearer bad_token"}
+ )
+ assert response.status_code == 401
+
+
+class TestMultiAuthSetMcpPath:
+ """Test that set_mcp_path propagates to server and verifiers."""
+
+ def test_propagates_to_server(self):
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ server = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ )
+ auth = MultiAuth(server=server)
+ auth.set_mcp_path("/mcp")
+ assert server._mcp_path == "/mcp"
+
+ def test_propagates_to_verifiers(self):
+ v1 = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ v2 = StaticTokenVerifier(tokens={"t2": {"client_id": "c2", "scopes": []}})
+ auth = MultiAuth(verifiers=[v1, v2])
+ auth.set_mcp_path("/mcp")
+ assert v1._mcp_path == "/mcp"
+ assert v2._mcp_path == "/mcp"
diff --git a/tests/server/auth/test_oauth_consent_flow.py b/tests/server/auth/test_oauth_consent_flow.py
index 25f53fe21..7a65df297 100644
--- a/tests/server/auth/test_oauth_consent_flow.py
+++ b/tests/server/auth/test_oauth_consent_flow.py
@@ -15,19 +15,16 @@ This test suite verifies:
import re
import secrets
import time
-from unittest.mock import Mock
from urllib.parse import parse_qs, urlparse
import pytest
from key_value.aio.stores.memory import MemoryStore
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
-from mcp.types import Icon
from pydantic import AnyUrl
from starlette.applications import Starlette
from starlette.testclient import TestClient
-from fastmcp import FastMCP
from fastmcp.server.auth.auth import AccessToken, TokenVerifier
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.oauth_proxy.models import OAuthTransaction
@@ -660,615 +657,3 @@ class TestConsentSecurity:
assert r2.headers.get("location", "").startswith(
"https://github.com/login/oauth/authorize"
)
-
-
-class TestConsentPageServerIcon:
- """Tests for server icon display in OAuth consent screen."""
-
- async def test_consent_screen_displays_server_icon(self):
- """Test that consent screen shows server's custom icon when available."""
-
- # Create mock JWT verifier
- verifier = Mock(spec=TokenVerifier)
- verifier.required_scopes = ["read"]
- verifier.verify_token = Mock(return_value=None)
-
- # Create OAuthProxy
- 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=verifier,
- base_url="https://proxy.example.com",
- client_storage=MemoryStore(),
- jwt_signing_key="test-secret",
- )
-
- # Create FastMCP server with custom icon
-
- server = FastMCP(
- name="My Custom Server",
- auth=proxy,
- icons=[Icon(src="https://example.com/custom-icon.png")],
- website_url="https://example.com",
- )
-
- # Create HTTP app
- app = server.http_app()
-
- # Register a test client with the proxy
- client_info = OAuthClientInformationFull(
- client_id="test-client",
- client_secret="test-secret",
- redirect_uris=[AnyUrl("http://localhost:12345/callback")],
- )
- await proxy.register_client(client_info)
-
- # Create a transaction manually
-
- txn_id = "test-txn-id"
- transaction = OAuthTransaction(
- txn_id=txn_id,
- client_id="test-client",
- client_redirect_uri="http://localhost:12345/callback",
- client_state="client-state",
- code_challenge="challenge",
- code_challenge_method="S256",
- scopes=["read"],
- created_at=time.time(),
- )
- await proxy._transaction_store.put(key=txn_id, value=transaction)
-
- # Make request to consent page
- with TestClient(app) as client:
- response = client.get(f"/consent?txn_id={txn_id}")
-
- # Check that response is successful
- assert response.status_code == 200
-
- # Check that HTML contains custom icon
- assert "https://example.com/custom-icon.png" in response.text
-
- # Check that server name is used as alt text
- assert 'alt="My Custom Server"' in response.text
-
- async def test_consent_screen_falls_back_to_fastmcp_logo(self):
- """Test that consent screen shows FastMCP logo when no server icon provided."""
-
- # Create mock JWT verifier
- verifier = Mock(spec=TokenVerifier)
- verifier.required_scopes = ["read"]
- verifier.verify_token = Mock(return_value=None)
-
- # Create OAuthProxy
- 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=verifier,
- base_url="https://proxy.example.com",
- client_storage=MemoryStore(),
- jwt_signing_key="test-secret",
- )
-
- # Create FastMCP server without icon
- server = FastMCP(name="Server Without Icon", auth=proxy)
-
- # Create HTTP app
- app = server.http_app()
-
- # Register a test client
- client_info = OAuthClientInformationFull(
- client_id="test-client",
- client_secret="test-secret",
- redirect_uris=[AnyUrl("http://localhost:12345/callback")],
- )
- await proxy.register_client(client_info)
-
- # Create a transaction
-
- txn_id = "test-txn-id"
- transaction = OAuthTransaction(
- txn_id=txn_id,
- client_id="test-client",
- client_redirect_uri="http://localhost:12345/callback",
- client_state="client-state",
- code_challenge="challenge",
- code_challenge_method="S256",
- scopes=["read"],
- created_at=time.time(),
- )
- await proxy._transaction_store.put(key=txn_id, value=transaction)
-
- # Make request to consent page
- with TestClient(app) as client:
- response = client.get(f"/consent?txn_id={txn_id}")
-
- # Check that response is successful
- assert response.status_code == 200
-
- # Check that HTML contains FastMCP logo
- assert "gofastmcp.com/assets/brand/blue-logo.png" in response.text
-
- # Check that alt text is still the server name
- assert 'alt="Server Without Icon"' in response.text
-
- async def test_consent_screen_escapes_server_name(self):
- """Test that server name is properly HTML-escaped."""
-
- # Create mock JWT verifier
- verifier = Mock(spec=TokenVerifier)
- verifier.required_scopes = ["read"]
- verifier.verify_token = Mock(return_value=None)
-
- # Create OAuthProxy
- 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=verifier,
- base_url="https://proxy.example.com",
- client_storage=MemoryStore(),
- jwt_signing_key="test-secret",
- )
-
- # Create FastMCP server with special characters in name
- server = FastMCP(
- name='Server',
- auth=proxy,
- icons=[Icon(src="https://example.com/icon.png")],
- )
-
- # Create HTTP app
- app = server.http_app()
-
- # Register a test client
- client_info = OAuthClientInformationFull(
- client_id="test-client",
- client_secret="test-secret",
- redirect_uris=[AnyUrl("http://localhost:12345/callback")],
- )
- await proxy.register_client(client_info)
-
- # Create a transaction
-
- txn_id = "test-txn-id"
- transaction = OAuthTransaction(
- txn_id=txn_id,
- client_id="test-client",
- client_redirect_uri="http://localhost:12345/callback",
- client_state="client-state",
- code_challenge="challenge",
- code_challenge_method="S256",
- scopes=["read"],
- created_at=time.time(),
- )
- await proxy._transaction_store.put(key=txn_id, value=transaction)
-
- # Make request to consent page
- with TestClient(app) as client:
- response = client.get(f"/consent?txn_id={txn_id}")
-
- # Check that response is successful
- assert response.status_code == 200
-
- # Check that script tag is escaped
- assert "Server',
+ auth=proxy,
+ icons=[Icon(src="https://example.com/icon.png")],
+ )
+
+ # Create HTTP app
+ app = server.http_app()
+
+ # Register a test client
+ client_info = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+ await proxy.register_client(client_info)
+
+ # Create a transaction
+
+ txn_id = "test-txn-id"
+ transaction = OAuthTransaction(
+ txn_id=txn_id,
+ client_id="test-client",
+ client_redirect_uri="http://localhost:12345/callback",
+ client_state="client-state",
+ code_challenge="challenge",
+ code_challenge_method="S256",
+ scopes=["read"],
+ created_at=time.time(),
+ )
+ await proxy._transaction_store.put(key=txn_id, value=transaction)
+
+ # Make request to consent page
+ with TestClient(app) as client:
+ response = client.get(f"/consent?txn_id={txn_id}")
+
+ # Check that response is successful
+ assert response.status_code == 200
+
+ # Check that script tag is escaped
+ assert "