From 5815e1eff88fd68dc1b79097c85c77a34deb2a20 Mon Sep 17 00:00:00 2001 From: Cedric Date: Mon, 23 Feb 2026 18:05:22 +0100 Subject: [PATCH 01/61] fix: Replace hardcoded TTL with DEFAULT_TTL_MS - issue #3279 (#3280) --- src/fastmcp/server/tasks/subscriptions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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, } From e87ede075cc55a40c465ca11d3fcb00c8a24d314 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:07:31 -0500 Subject: [PATCH 02/61] fix: stop suppressing server stderr in fastmcp call (#3283) Server subprocess stderr was being sent to /dev/null, which silently discarded print(..., file=sys.stderr) and logging output from tools. --- src/fastmcp/cli/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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]: From 549f48bc7aaadca4ad653a3892c1e35b3c1a50d7 Mon Sep 17 00:00:00 2001 From: Aymen El Amri <5774128+eon01@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:04:03 +0100 Subject: [PATCH 03/61] fix: skip max_completion_tokens when maxTokens is None (#3284) --- src/fastmcp/client/sampling/handlers/openai.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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: From 59bb0a3ce782b2043251aad9951ca6da8dc0e4be Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:45:56 -0500 Subject: [PATCH 04/61] Fix link to loq repository in AGENTS.md (#3289) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index bed11163a..54d93259d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,6 @@ When modifying MCP functionality, changes typically need to be applied across al ## 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. +- 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 From 270783e6149fece5e14dd0b2b9ad53e098d5cb9b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:18:59 -0500 Subject: [PATCH 05/61] reverse CLAUDE.md/AGENTS.md symlink direction (#3294) --- .github/ISSUE_TEMPLATE/bug.yml | 2 + .github/ISSUE_TEMPLATE/enhancement.yml | 1 + AGENTS.md | 116 +----------------------- CLAUDE.md | 119 ++++++++++++++++++++++++- 4 files changed, 122 insertions(+), 116 deletions(-) mode change 100644 => 120000 AGENTS.md mode change 120000 => 100644 CLAUDE.md 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 54d93259d..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/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/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..e60814699 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# FastMCP Development Guidelines + +> **Audience**: LLM-driven engineering agents and human developers + +> **Note**: `CLAUDE.md` is a symlink to this file. Edit `AGENTS.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.** 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) +- **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 From d9541c9c2181fa4ad1e07e7c90c4db2f963e7924 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:44:24 -0500 Subject: [PATCH 06/61] Lazy-load heavy imports to reduce import time Defer auth providers (JWTVerifier, OAuthProxy, OIDCProxy) and Client to avoid eagerly importing authlib, cryptography, key_value.aio, and beartype on every `from fastmcp import FastMCP`. --- scripts/benchmark_imports.py | 212 ++++++++++++++++++++++++++++ src/fastmcp/__init__.py | 24 +++- src/fastmcp/server/__init__.py | 1 - src/fastmcp/server/auth/__init__.py | 44 +++++- src/fastmcp/server/server.py | 3 +- 5 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 scripts/benchmark_imports.py 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/server/__init__.py b/src/fastmcp/server/__init__.py index fb9afd895..101adfcdb 100644 --- a/src/fastmcp/server/__init__.py +++ b/src/fastmcp/server/__init__.py @@ -1,6 +1,5 @@ from .context import Context from .server import FastMCP, create_proxy -from . import dependencies __all__ = ["Context", "FastMCP", "create_proxy"] diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py index 94e23dca6..2c11d32b7 100644 --- a/src/fastmcp/server/auth/__init__.py +++ b/src/fastmcp/server/auth/__init__.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from .auth import ( OAuthProvider, TokenVerifier, @@ -12,10 +14,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__ = [ diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index db558dca3..2bdecf803 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()) From d773079aba236f256fea7579911e34c0a58c2636 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:10:04 -0500 Subject: [PATCH 07/61] Lazily expose server.dependencies instead of dropping it --- src/fastmcp/server/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/fastmcp/server/__init__.py b/src/fastmcp/server/__init__.py index 101adfcdb..3f64a0f39 100644 --- a/src/fastmcp/server/__init__.py +++ b/src/fastmcp/server/__init__.py @@ -1,5 +1,13 @@ +import importlib + from .context import Context from .server import FastMCP, create_proxy +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"] From 1704ffe88f20ee352baa3fb9e71152d6c1cd828e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 17:55:08 +0000 Subject: [PATCH 08/61] Add http_client parameter to token verifiers for connection pooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Claude Code https://claude.ai/code/session_012QKWmKd21vypDmxWbwuE4e --- docs/servers/auth/token-verification.mdx | 71 ++++ src/fastmcp/server/auth/providers/azure.py | 6 + src/fastmcp/server/auth/providers/discord.py | 17 +- src/fastmcp/server/auth/providers/github.py | 18 +- src/fastmcp/server/auth/providers/google.py | 17 +- .../server/auth/providers/introspection.py | 12 +- src/fastmcp/server/auth/providers/jwt.py | 13 +- src/fastmcp/server/auth/providers/workos.py | 18 +- .../server/auth/providers/test_http_client.py | 354 ++++++++++++++++++ 9 files changed, 520 insertions(+), 6 deletions(-) create mode 100644 tests/server/auth/providers/test_http_client.py diff --git a/docs/servers/auth/token-verification.mdx b/docs/servers/auth/token-verification.mdx index 890214c6d..5925a2d43 100644 --- a/docs/servers/auth/token-verification.mdx +++ b/docs/servers/auth/token-verification.mdx @@ -321,6 +321,77 @@ 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, +) +``` + + +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/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 868631d13..244ab91c0 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -10,6 +10,7 @@ import hashlib from collections import OrderedDict from typing import TYPE_CHECKING, Any, cast +import httpx from key_value.aio.protocols import AsyncKeyValue from fastmcp.server.auth.oauth_proxy import OAuthProxy @@ -107,6 +108,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 +153,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 +207,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 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..b6f03a105 100644 --- a/src/fastmcp/server/auth/providers/introspection.py +++ b/src/fastmcp/server/auth/providers/introspection.py @@ -24,6 +24,7 @@ Example: from __future__ import annotations import base64 +import contextlib import time from typing import Any, Literal, get_args @@ -80,6 +81,7 @@ class IntrospectionTokenVerifier(TokenVerifier): timeout_seconds: int = 10, required_scopes: list[str] | None = None, base_url: AnyHttpUrl | str | None = None, + http_client: httpx.AsyncClient | None = None, ): """ Initialize the introspection token verifier. @@ -93,6 +95,9 @@ 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 + 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,6 +125,7 @@ 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__) def _create_basic_auth_header(self) -> str: @@ -166,7 +172,11 @@ class IntrospectionTokenVerifier(TokenVerifier): AccessToken object if valid and active, None if invalid, inactive, or expired """ 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 = { diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index 828b9238f..90e1d608f 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,6 +186,10 @@ 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. + Only used when ssrf_safe is False; SSRF-safe fetches use their own transport. Raises: ValueError: If neither or both of `public_key` and `jwks_uri` are provided, or if `algorithm` is unsupported. @@ -228,6 +234,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 +335,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/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/tests/server/auth/providers/test_http_client.py b/tests/server/auth/providers/test_http_client.py new file mode 100644 index 000000000..39c8d51b0 --- /dev/null +++ b/tests/server/auth/providers/test_http_client.py @@ -0,0 +1,354 @@ +"""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 + + async def test_ssrf_safe_ignores_http_client( + self, + shared_client: httpx.AsyncClient, + ): + """When ssrf_safe=True, the custom http_client should NOT be used.""" + verifier = JWTVerifier( + jwks_uri="https://auth.example.com/.well-known/jwks.json", + ssrf_safe=True, + http_client=shared_client, + ) + + # ssrf_safe uses ssrf_safe_fetch instead of httpx.AsyncClient + # The http_client is stored but not used in this code path + 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 From 730175910cb09ba3741b14a445891b47a327feaf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 20:34:29 +0000 Subject: [PATCH 09/61] Raise error when http_client and ssrf_safe=True are both provided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Claude Code https://claude.ai/code/session_012QKWmKd21vypDmxWbwuE4e --- docs/servers/auth/token-verification.mdx | 4 ++++ src/fastmcp/server/auth/providers/jwt.py | 11 ++++++++-- .../server/auth/providers/test_http_client.py | 20 ++++++++----------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/servers/auth/token-verification.mdx b/docs/servers/auth/token-verification.mdx index 5925a2d43..a9146135f 100644 --- a/docs/servers/auth/token-verification.mdx +++ b/docs/servers/auth/token-verification.mdx @@ -364,6 +364,10 @@ verifier = JWTVerifier( ) ``` + +`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: diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index 90e1d608f..783499faa 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -189,10 +189,11 @@ class JWTVerifier(TokenVerifier): 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. - Only used when ssrf_safe is False; SSRF-safe fetches use their own transport. + 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") @@ -200,6 +201,12 @@ class JWTVerifier(TokenVerifier): if public_key and jwks_uri: raise ValueError("Provide either public_key or jwks_uri, not both") + if 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", diff --git a/tests/server/auth/providers/test_http_client.py b/tests/server/auth/providers/test_http_client.py index 39c8d51b0..8beb443e2 100644 --- a/tests/server/auth/providers/test_http_client.py +++ b/tests/server/auth/providers/test_http_client.py @@ -180,21 +180,17 @@ class TestJWTVerifierHttpClient: assert result is not None assert not shared_client.is_closed - async def test_ssrf_safe_ignores_http_client( + def test_ssrf_safe_rejects_http_client( self, shared_client: httpx.AsyncClient, ): - """When ssrf_safe=True, the custom http_client should NOT be used.""" - verifier = JWTVerifier( - jwks_uri="https://auth.example.com/.well-known/jwks.json", - ssrf_safe=True, - http_client=shared_client, - ) - - # ssrf_safe uses ssrf_safe_fetch instead of httpx.AsyncClient - # The http_client is stored but not used in this code path - assert verifier._http_client is shared_client - assert verifier.ssrf_safe is True + """ssrf_safe=True and http_client cannot be used together.""" + 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, + ) class TestGitHubHttpClient: From 892e1731f7f651eb847eac38ca1bb9aa714acfe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 21:30:47 +0000 Subject: [PATCH 10/61] Allow http_client with static public_key in JWTVerifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Claude Code https://claude.ai/code/session_012QKWmKd21vypDmxWbwuE4e --- src/fastmcp/server/auth/providers/jwt.py | 3 ++- .../server/auth/providers/test_http_client.py | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index 783499faa..5640fa390 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -201,7 +201,8 @@ class JWTVerifier(TokenVerifier): if public_key and jwks_uri: raise ValueError("Provide either public_key or jwks_uri, not both") - if ssrf_safe and http_client is not None: + # 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" diff --git a/tests/server/auth/providers/test_http_client.py b/tests/server/auth/providers/test_http_client.py index 8beb443e2..34c118f38 100644 --- a/tests/server/auth/providers/test_http_client.py +++ b/tests/server/auth/providers/test_http_client.py @@ -180,11 +180,11 @@ class TestJWTVerifierHttpClient: assert result is not None assert not shared_client.is_closed - def test_ssrf_safe_rejects_http_client( + def test_ssrf_safe_rejects_http_client_with_jwks( self, shared_client: httpx.AsyncClient, ): - """ssrf_safe=True and http_client cannot be used together.""" + """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", @@ -192,6 +192,21 @@ class TestJWTVerifierHttpClient: 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.""" From 8a4c9b33e1c51560c50afb55c70e002f93cc1f6c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:43:40 -0500 Subject: [PATCH 11/61] Add in-memory caching for token introspection results (#3298) --- .../server/auth/providers/introspection.py | 136 +++++- .../auth/providers/test_introspection.py | 432 ++++++++++++++++++ 2 files changed, 566 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/server/auth/providers/introspection.py b/src/fastmcp/server/auth/providers/introspection.py index b6f03a105..09e20abfd 100644 --- a/src/fastmcp/server/auth/providers/introspection.py +++ b/src/fastmcp/server/auth/providers/introspection.py @@ -25,7 +25,9 @@ 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 @@ -37,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"] @@ -60,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( @@ -71,6 +86,9 @@ class IntrospectionTokenVerifier(TokenVerifier): ``` """ + # Default cache settings + DEFAULT_MAX_CACHE_SIZE = 10000 + def __init__( self, *, @@ -81,6 +99,8 @@ 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, ): """ @@ -95,6 +115,12 @@ 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. @@ -128,6 +154,98 @@ class IntrospectionTokenVerifier(TokenVerifier): 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}" @@ -165,12 +283,21 @@ 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 ( contextlib.nullcontext(self._http_client) @@ -203,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", @@ -215,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 @@ -239,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) @@ -251,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/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.""" From 43ac5395b9094db35a4667f2a29269766ae33a1f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:12:23 -0500 Subject: [PATCH 12/61] Add SessionStart hook to install gh CLI in cloud sessions (#3308) Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- .claude/hooks/session-init.sh | 26 ++++++++++++++++++++++++++ .claude/settings.json | 15 +++++++++++++++ 2 files changed, 41 insertions(+) create mode 100755 .claude/hooks/session-init.sh create mode 100644 .claude/settings.json 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 + } + ] + } + ] + } +} From 80efbd3d578174b7e8ae211afbbe2004541f26f0 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:19:53 -0500 Subject: [PATCH 13/61] chore: Update SDK documentation (#3273) Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- docs/python-sdk/fastmcp-cli-client.mdx | 14 +-- .../fastmcp-server-auth-providers-azure.mdx | 14 +-- .../fastmcp-server-auth-providers-discord.mdx | 6 +- .../fastmcp-server-auth-providers-github.mdx | 6 +- .../fastmcp-server-auth-providers-google.mdx | 6 +- ...cp-server-auth-providers-introspection.mdx | 11 +- .../fastmcp-server-auth-providers-jwt.mdx | 20 ++-- .../fastmcp-server-auth-providers-workos.mdx | 10 +- docs/python-sdk/fastmcp-server-context.mdx | 95 +++++++++------- .../fastmcp-server-dependencies.mdx | 88 +++++++++------ ...cp-server-providers-openapi-components.mdx | 8 +- docs/python-sdk/fastmcp-server-server.mdx | 104 +++++++++--------- .../fastmcp-server-tasks-subscriptions.mdx | 2 +- 13 files changed, 210 insertions(+), 174 deletions(-) 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-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index 7c9a982e0..7d2988de7 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-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..6c73ede2e 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -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..b2313dd7d 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -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 @@ -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. 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-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 267f66d13..d3a7de773 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 From 2d3d0d5eab8b06f3132738d69f7f9e82266fde68 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:08:40 -0500 Subject: [PATCH 14/61] Fix ty 0.0.19 type errors (#3310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix ty 0.0.19 type errors πŸ€– Generated with Claude Code * Fix ruff formatting in sampling/run.py πŸ€– Generated with Claude Code https://claude.ai/code/session_01GWzbyF1vHvVeS4yJ5bhScf --------- Co-authored-by: Claude --- src/fastmcp/client/roots.py | 4 +- src/fastmcp/client/sampling/__init__.py | 4 +- src/fastmcp/server/providers/proxy.py | 10 +- src/fastmcp/server/sampling/run.py | 4 +- .../test_initialization_middleware.py | 12 +- tests/server/test_dependencies.py | 4 +- uv.lock | 209 +++++++++--------- 7 files changed, 129 insertions(+), 118 deletions(-) diff --git a/src/fastmcp/client/roots.py b/src/fastmcp/client/roots.py index d9dd7cb67..f623b0346 100644 --- a/src/fastmcp/client/roots.py +++ b/src/fastmcp/client/roots.py @@ -1,6 +1,6 @@ import inspect from collections.abc import Awaitable, Callable -from typing import TypeAlias +from typing import TypeAlias, cast import mcp.types import pydantic @@ -65,7 +65,7 @@ def _create_roots_callback_from_fn( try: roots = fn(context) if inspect.isawaitable(roots): - roots = await roots + roots = cast(RootsList, await roots) return mcp.types.ListRootsResult(roots=convert_roots_list(roots)) except Exception as e: return mcp.types.ErrorData( diff --git a/src/fastmcp/client/sampling/__init__.py b/src/fastmcp/client/sampling/__init__.py index 1cdb9ba1d..95637ade5 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 @@ -51,7 +51,7 @@ def create_sampling_callback( try: result = sampling_handler(params.messages, params, context) if inspect.isawaitable(result): - result = await result + result = cast(SamplingHandlerResult, await result) if isinstance(result, str): result = CreateMessageResult( diff --git a/src/fastmcp/server/providers/proxy.py b/src/fastmcp/server/providers/proxy.py index 0b7ce0096..be844bb4a 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: @@ -189,7 +189,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 +288,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 +403,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 +517,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 # ------------------------------------------------------------------------- diff --git a/src/fastmcp/server/sampling/run.py b/src/fastmcp/server/sampling/run.py index 6ece2c30e..08d4198aa 100644 --- a/src/fastmcp/server/sampling/run.py +++ b/src/fastmcp/server/sampling/run.py @@ -225,7 +225,9 @@ async def call_sampling_handler( ) if inspect.isawaitable(result): - result = await result + result = cast( + str | CreateMessageResult | CreateMessageResultWithTools, await result + ) # Convert string to CreateMessageResult if isinstance(result, str): diff --git a/tests/server/middleware/test_initialization_middleware.py b/tests/server/middleware/test_initialization_middleware.py index 4716af5f0..ca1850862 100644 --- a/tests/server/middleware/test_initialization_middleware.py +++ b/tests/server/middleware/test_initialization_middleware.py @@ -25,7 +25,7 @@ class InitializationMiddleware(Middleware): self.client_info = None self.session_data = {} - async def on_initialize( + async def on_initialize( # type: ignore[override] self, context: MiddlewareContext[mt.InitializeRequest], call_next: CallNext[mt.InitializeRequest, None], @@ -63,7 +63,7 @@ class ClientDetectionMiddleware(Middleware): self.tools_modified = False self.initialization_called = False - async def on_initialize( + async def on_initialize( # type: ignore[override] self, context: MiddlewareContext[mt.InitializeRequest], call_next: CallNext[mt.InitializeRequest, None], @@ -77,7 +77,7 @@ class ClientDetectionMiddleware(Middleware): return await call_next(context) - async def on_list_tools( + async def on_list_tools( # type: ignore[override] self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, list], @@ -109,7 +109,7 @@ async def test_simple_initialization_hook(): super().__init__() self.called = False - async def on_initialize( + async def on_initialize( # type: ignore[override] self, context: MiddlewareContext[mt.InitializeRequest], call_next: CallNext[mt.InitializeRequest, None], @@ -301,7 +301,7 @@ async def test_middleware_mcp_error_during_initialization(): server = FastMCP("TestServer") class ErrorThrowingMiddleware(Middleware): - async def on_initialize( + async def on_initialize( # type: ignore[override] self, context: MiddlewareContext[mt.InitializeRequest], call_next: CallNext[mt.InitializeRequest, None], @@ -327,7 +327,7 @@ async def test_middleware_mcp_error_before_call_next(): server = FastMCP("TestServer") class EarlyErrorMiddleware(Middleware): - async def on_initialize( + async def on_initialize( # type: ignore[override] self, context: MiddlewareContext[mt.InitializeRequest], call_next: CallNext[mt.InitializeRequest, None], diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index df7f04697..b5134522c 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -67,7 +67,7 @@ async def test_depends_with_async_function(mcp: FastMCP): return 42 @mcp.tool() - async def greet_user(name: str, user_id: int = Depends(get_user_id)) -> str: + async def greet_user(name: str, user_id: int = Depends(get_user_id)) -> str: # type: ignore[assignment] return f"Hello {name}, your ID is {user_id}" result = await mcp.call_tool("greet_user", {"name": "Alice"}) @@ -198,7 +198,7 @@ async def test_sync_tool_with_async_dependency(mcp: FastMCP): return "loaded_config" @mcp.tool() - def process_data(value: int, config: str = Depends(fetch_config)) -> str: + def process_data(value: int, config: str = Depends(fetch_config)) -> str: # type: ignore[assignment] return f"Processing {value} with {config}" result = await mcp.call_tool("process_data", {"value": 100}) diff --git a/uv.lock b/uv.lock index 7e7976c22..f52ae1ccb 100644 --- a/uv.lock +++ b/uv.lock @@ -39,7 +39,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.79.0" +version = "0.84.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -51,9 +51,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/b1/91aea3f8fd180d01d133d931a167a78a3737b3fd39ccef2ae8d6619c24fd/anthropic-0.79.0.tar.gz", hash = "sha256:8707aafb3b1176ed6c13e2b1c9fb3efddce90d17aee5d8b83a86c70dcdcca871", size = 509825, upload-time = "2026-02-07T18:06:18.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/b2/cc0b8e874a18d7da50b0fda8c99e4ac123f23bf47b471827c5f6f3e4a767/anthropic-0.79.0-py3-none-any.whl", hash = "sha256:04cbd473b6bbda4ca2e41dd670fe2f829a911530f01697d0a1e37321eb75f3cf", size = 405918, upload-time = "2026-02-07T18:06:20.246Z" }, + { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" }, ] [[package]] @@ -111,15 +111,15 @@ wheels = [ [[package]] name = "azure-core" -version = "1.38.1" +version = "1.38.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/9b/23893febea484ad8183112c9419b5eb904773adb871492b5fa8ff7b21e09/azure_core-1.38.1.tar.gz", hash = "sha256:9317db1d838e39877eb94a2240ce92fa607db68adf821817b723f0d679facbf6", size = 363323, upload-time = "2026-02-11T02:03:06.051Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/fe/5c7710bc611a4070d06ba801de9a935cc87c3d4b689c644958047bdf2cba/azure_core-1.38.2.tar.gz", hash = "sha256:67562857cb979217e48dc60980243b61ea115b77326fa93d83b729e7ff0482e7", size = 363734, upload-time = "2026-02-18T19:33:05.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/88/aaea2ad269ce70b446660371286272c1f6ba66541a7f6f635baf8b0db726/azure_core-1.38.1-py3-none-any.whl", hash = "sha256:69f08ee3d55136071b7100de5b198994fc1c5f89d2b91f2f43156d20fcf200a4", size = 217930, upload-time = "2026-02-11T02:03:07.548Z" }, + { url = "https://files.pythonhosted.org/packages/42/23/6371a551800d3812d6019cd813acd985f9fac0fedc1290129211a73da4ae/azure_core-1.38.2-py3-none-any.whl", hash = "sha256:074806c75cf239ea284a33a66827695ef7aeddac0b4e19dda266a93e4665ead9", size = 217957, upload-time = "2026-02-18T19:33:07.696Z" }, ] [[package]] @@ -195,11 +195,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -596,7 +596,7 @@ wheels = [ [[package]] name = "cyclopts" -version = "4.5.3" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -606,9 +606,9 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/16/06e35c217334930ff7c476ce1c8e74ed786fa3ef6742e59a1458e2412290/cyclopts-4.5.3.tar.gz", hash = "sha256:35fa70971204c450d9668646a6ca372eb5fa3070fbe8dd51c5b4b31e65198f2d", size = 162437, upload-time = "2026-02-16T15:07:11.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/5c/88a4068c660a096bbe87efc5b7c190080c9e86919c36ec5f092cb08d852f/cyclopts-4.6.0.tar.gz", hash = "sha256:483c4704b953ea6da742e8de15972f405d2e748d19a848a4d61595e8e5360ee5", size = 162724, upload-time = "2026-02-23T15:44:49.286Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/1f/d8bce383a90d8a6a11033327777afa4d4d611ec11869284adb6f48152906/cyclopts-4.5.3-py3-none-any.whl", hash = "sha256:50af3085bb15d4a6f2582dd383dad5e4ba6a0d4d4c64ee63326d881a752a6919", size = 200231, upload-time = "2026-02-16T15:07:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/1e8337755a70dc7d7ff10a73dc8f20e9352c9ad6c2256ed863ac95cd3539/cyclopts-4.6.0-py3-none-any.whl", hash = "sha256:0a891cb55bfd79a3cdce024db8987b33316aba11071e5258c21ac12a640ba9f2", size = 200518, upload-time = "2026-02-23T15:44:47.854Z" }, ] [[package]] @@ -710,16 +710,16 @@ wheels = [ [[package]] name = "fakeredis" -version = "2.34.0" +version = "2.34.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "redis" }, { name = "sortedcontainers" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/44/c403963727d707e03f49a417712b0a23e853d33ae50729679040b6cfe281/fakeredis-2.34.0.tar.gz", hash = "sha256:72bc51a7ab39bedf5004f0cf1b5206822619c1be8c2657fd878d1f4250256c57", size = 177156, upload-time = "2026-02-16T15:56:34.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/40/fd09efa66205eb32253d2b2ebc63537281384d2040f0a88bcd2289e120e4/fakeredis-2.34.1.tar.gz", hash = "sha256:4ff55606982972eecce3ab410e03d746c11fe5deda6381d913641fbd8865ea9b", size = 177315, upload-time = "2026-02-25T13:17:51.315Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/8e/af19c00753c432355f9b76cec3ab0842578de43ba575e82735b18c1b3ec9/fakeredis-2.34.0-py3-none-any.whl", hash = "sha256:bc45d362c6cc3a537f8287372d8ea532538dfbe7f5d635d0905d7b3464ec51d2", size = 122063, upload-time = "2026-02-16T15:56:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/49/b5/82f89307d0d769cd9bf46a54fb9136be08e4e57c5570ae421db4c9a2ba62/fakeredis-2.34.1-py3-none-any.whl", hash = "sha256:0107ec99d48913e7eec2a5e3e2403d1bd5f8aa6489d1a634571b975289c48f12", size = 122160, upload-time = "2026-02-25T13:17:49.701Z" }, ] [package.optional-dependencies] @@ -742,7 +742,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.129.0" +version = "0.133.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -751,9 +751,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/6f/0eafed8349eea1fa462238b54a624c8b408cd1ba2795c8e64aa6c34f8ab7/fastapi-0.133.1.tar.gz", hash = "sha256:ed152a45912f102592976fde6cbce7dae1a8a1053da94202e51dd35d184fadd6", size = 378741, upload-time = "2026-02-25T18:18:17.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c9/a175a7779f3599dfa4adfc97a6ce0e157237b3d7941538604aadaf97bfb6/fastapi-0.133.1-py3-none-any.whl", hash = "sha256:658f34ba334605b1617a65adf2ea6461901bdb9af3a3080d63ff791ecf7dc2e2", size = 109029, upload-time = "2026-02-25T18:18:18.578Z" }, ] [[package]] @@ -1035,7 +1035,7 @@ wheels = [ [[package]] name = "inline-snapshot" -version = "0.32.0" +version = "0.32.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, @@ -1045,9 +1045,9 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/74/19294067d5b5b78144eab2aacec85b998c2d2ea6b3d24eefd9a90255d7aa/inline_snapshot-0.32.0.tar.gz", hash = "sha256:57fa3df325284d0d14def5dab9ac5da89e383f085bea9a7be51fdeab65e59ced", size = 2623331, upload-time = "2026-02-13T19:51:54.469Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/66/9e1a4d3341e8301a98f6987afd7c7053441b4c07360b1fa4fca7279683f9/inline_snapshot-0.32.3.tar.gz", hash = "sha256:c606d2551de08a293d29bfcd1df2edde9d478f57c83f063b4642f45906b2d748", size = 2625275, upload-time = "2026-02-24T07:46:54.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/25/0e84a6322e5fdb1bf67870b2269151449f4894987b26c78718918dd64ea6/inline_snapshot-0.32.0-py3-none-any.whl", hash = "sha256:b522ae2c891f666e80213c5f9677ec6fd4a2a7d334ab9d6ce745675bec6a40f0", size = 84087, upload-time = "2026-02-13T19:51:52.604Z" }, + { url = "https://files.pythonhosted.org/packages/c5/af/38e4c5192ab8dbcc73dee07bc0f76718e4a8612783f0e573374588228c1c/inline_snapshot-0.32.3-py3-none-any.whl", hash = "sha256:3925c75fadfcbd190f3c77218a485d27d8af46b952bfe64410b482dd9dbeb24f", size = 84668, upload-time = "2026-02-24T07:46:53.029Z" }, ] [package.optional-dependencies] @@ -1298,17 +1298,16 @@ wheels = [ [[package]] name = "jsonschema-path" -version = "0.3.4" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pathable" }, { name = "pyyaml" }, { name = "referencing" }, - { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/da/1ebeb1c0ff579c330e200e8b06e6200653e3d0758136d8bd86762d63e7de/jsonschema_path-0.4.2.tar.gz", hash = "sha256:5f5ff183150030ea24bb51cf1ddac9bf5dbf030272e2792a7ffe8262f7eea2a5", size = 13417, upload-time = "2026-02-23T16:21:36.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, + { url = "https://files.pythonhosted.org/packages/e8/10/96f8fe82137979fcd1e46fff243ce7d80cd03b9e1cee8f22476ce780f38c/jsonschema_path-0.4.2-py3-none-any.whl", hash = "sha256:9c3d88e727cc4f1a88e51dbbed4211dbcd815d27799d2685efd904435c3d39e7", size = 16702, upload-time = "2026-02-23T16:21:35.119Z" }, ] [[package]] @@ -1499,16 +1498,16 @@ wheels = [ [[package]] name = "msal" -version = "1.34.0" +version = "1.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/ec/52e6c9ad90ad7eb3035f5e511123e89d1ecc7617f0c94653264848623c12/msal-1.35.0.tar.gz", hash = "sha256:76ab7513dbdac88d76abdc6a50110f082b7ed3ff1080aca938c53fc88bc75b51", size = 164057, upload-time = "2026-02-24T10:58:28.415Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/56/26/5463e615de18ad8b80d75d14c612ef3c866fcc07c1c52e8eac7948984214/msal-1.35.0-py3-none-any.whl", hash = "sha256:baf268172d2b736e5d409689424d2f321b4142cab231b4b96594c86762e7e01d", size = 120082, upload-time = "2026-02-24T10:58:27.219Z" }, ] [[package]] @@ -1525,7 +1524,7 @@ wheels = [ [[package]] name = "openai" -version = "2.21.0" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1537,9 +1536,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] [[package]] @@ -1656,24 +1655,24 @@ wheels = [ [[package]] name = "pathable" -version = "0.4.4" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, ] [[package]] name = "pdbpp" -version = "0.12.0.post1" +version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fancycompleter" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/23/0bd339679059fb289e0752671b5eac63a472742d1de0035e13a27429e500/pdbpp-0.12.0.post1.tar.gz", hash = "sha256:cace9951d7414fe651141d240ab20e4509c8f97d20f7b142386d1bd1bb182d18", size = 75765, upload-time = "2026-01-19T10:56:55.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8d/dbc2c26f9c947e3d6df82d3ee7711fb52a92000177cea22fd3e428dc0429/pdbpp-0.12.1.tar.gz", hash = "sha256:932cc5963760105e33607f44a1a3b83d28096d7dbf055b77b527c5c70a1dafef", size = 77358, upload-time = "2026-02-23T14:23:44.993Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/f2/20fa391af27da4ef421832fb7d554748786efcc3b0da69a51e2ca66043d0/pdbpp-0.12.0.post1-py3-none-any.whl", hash = "sha256:8c62acd6adaa02f620d7d9cff689208a28019b6c05e6b894318f313e61fb1dc7", size = 30661, upload-time = "2026-01-19T10:56:53.638Z" }, + { url = "https://files.pythonhosted.org/packages/05/4e/0703722c46447fa03c9425386b3ef3e90254a7c1eb5da654c3c33b210317/pdbpp-0.12.1-py3-none-any.whl", hash = "sha256:3828809519439f468c9475c4c2cbb3899f2f5ba40e79c207d90eabe4c2373f5e", size = 30659, upload-time = "2026-02-23T14:23:43.66Z" }, ] [[package]] @@ -1989,21 +1988,21 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/a1/ae859ffac5a3338a66b74c5e29e244fd3a3cc483c89feaf9f56c39898d75/pydantic_settings-2.13.0.tar.gz", hash = "sha256:95d875514610e8595672800a5c40b073e99e4aae467fa7c8f9c263061ea2e1fe", size = 222450, upload-time = "2026-02-15T12:11:23.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/1a/dd1b9d7e627486cf8e7523d09b70010e05a4bc41414f4ae6ce184cf0afb6/pydantic_settings-2.13.0-py3-none-any.whl", hash = "sha256:d67b576fff39cd086b595441bf9c75d4193ca9c0ed643b90360694d0f1240246", size = 58429, upload-time = "2026-02-15T12:11:22.133Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] name = "pydocket" -version = "0.17.7" +version = "0.17.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, @@ -2019,10 +2018,11 @@ dependencies = [ { name = "taskgroup", marker = "python_full_version < '3.11'" }, { name = "typer" }, { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/b2/5e12dbe2acf59e4499285e8eee66e8e81b6ba2f553696d2f4ccca0a7978c/pydocket-0.17.7.tar.gz", hash = "sha256:5c77ec6731a167cdcb44174abf793fe63e7b6c1c1c8a799cc6ec7502b361ee77", size = 347071, upload-time = "2026-02-11T21:01:31.744Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/e9/08c8642607b1b4b4f92798c04da625d763ad2b585ced7d91cc593d301ed3/pydocket-0.17.9.tar.gz", hash = "sha256:4b98b9951303fba2b77649969539d501500cd0b0e5accc27e03b16c25a76f3e6", size = 348534, upload-time = "2026-02-20T20:53:42.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/c7/68f2553819965326f968375f02597d49efe71b309ba9d8fef539aeb51c48/pydocket-0.17.7-py3-none-any.whl", hash = "sha256:d1e0921ac02026c4a0140fc72a3848545f3e91e6e74c6e32c588489017c130b2", size = 94608, upload-time = "2026-02-11T21:01:30.111Z" }, + { url = "https://files.pythonhosted.org/packages/ad/79/886e4db80730935f87176657aadf22f51ec9952d36ae34df9d257a9ca93d/pydocket-0.17.9-py3-none-any.whl", hash = "sha256:3f48f40d6250a33c70622b0d6c3841ed23feb3997f8e4440acd5073cd43fa044", size = 94908, upload-time = "2026-02-20T20:53:41.509Z" }, ] [[package]] @@ -2179,16 +2179,16 @@ wheels = [ [[package]] name = "pytest-env" -version = "1.3.2" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "python-dotenv" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/ad/dd32e4614fb68ad980c949fd4299f8c6a8d4874e24ec8d222c056efb4741/pytest_env-1.3.2.tar.gz", hash = "sha256:f091a2c6a8eb91befcae2b4c1bd2905a51f33bc1c6567707b7feed4e51b76b47", size = 12009, upload-time = "2026-02-11T22:09:49.168Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/56/a931c6f6194917ff44be41b8586e2ffd13a18fa70fb28d9800a4695befa5/pytest_env-1.5.0.tar.gz", hash = "sha256:db8994b9ce170f135a37acc09ac753a6fc697d15e691b576ed8d8ca261c40246", size = 15271, upload-time = "2026-02-17T18:31:39.095Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/ad/d793670b26f4fb82e974dbff20d05782ebb23490b08987976cdc62d854bb/pytest_env-1.3.2-py3-none-any.whl", hash = "sha256:e8626b776a035112a8ad58fcc9e04926868c58f15225de484de7c8af4b4b526c", size = 7864, upload-time = "2026-02-11T22:09:47.775Z" }, + { url = "https://files.pythonhosted.org/packages/61/af/99b52a8524983bfece35e51e65a0b517b22920c023e57855c95e744e19e4/pytest_env-1.5.0-py3-none-any.whl", hash = "sha256:89a15686ac837c9cd009a8a2d52bd55865e2f23c82094247915dae4540c87161", size = 10122, upload-time = "2026-02-17T18:31:37.496Z" }, ] [[package]] @@ -2407,28 +2407,28 @@ wheels = [ [[package]] name = "redis" -version = "7.2.0" +version = "7.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" }, + { url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" }, ] [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] @@ -2448,15 +2448,15 @@ wheels = [ [[package]] name = "rich" -version = "14.3.2" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] @@ -2596,27 +2596,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.1" +version = "0.15.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/3b/20d9a0bc954d51b63f20cf710cf506bfe675d1e6138139342dd5ccc90326/ruff-0.15.3.tar.gz", hash = "sha256:78757853320d8ddb9da24e614ef69a37bcbcfd477e5a6435681188d4bce4eaa1", size = 4569031, upload-time = "2026-02-26T15:39:38.015Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, - { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, - { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, - { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, - { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, - { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, - { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, - { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, - { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, - { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/c544ab1d70f86dc50a2f2a8e1262e5af5025897ccd820415f559f9f2f63f/ruff-0.15.3-py3-none-linux_armv6l.whl", hash = "sha256:f7df0fd6f889a8d8de2ddb48a9eb55150954400f2157ea15b21a2f49ecaaf988", size = 10444066, upload-time = "2026-02-26T15:39:47.708Z" }, + { url = "https://files.pythonhosted.org/packages/fb/15/9dee3f4e891261adbd690f8c6f075418a7cd76e845601b00a0da2ae2ad6e/ruff-0.15.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0198b5445197d443c3bbf2cc358f4bd477fb3951e3c7f2babc13e9bb490614a8", size = 10853125, upload-time = "2026-02-26T15:40:18.943Z" }, + { url = "https://files.pythonhosted.org/packages/88/ba/fc5aeda852c89faf821d36c951df866117342e88439e1b1e1e762a07b7fd/ruff-0.15.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:adf95b5be57b25fbbbc07cd68d37414bee8729e807ad0217219558027186967e", size = 10180833, upload-time = "2026-02-26T15:40:13.282Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/e2f80a39164476fac4d45752a0d4721d6645f40b7f851e48add12af9947e/ruff-0.15.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b56dbd9cd86489ccbad96bb58fa4c958342b5510fdeb60ea13d9d3566bd845c", size = 10536806, upload-time = "2026-02-26T15:40:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/fd/89/2e5bf0ed30ea3778460ea4d8cc6cb4d88ba96d9732d2c0cc33349cd65196/ruff-0.15.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6f263ce511871955d8c5401b62c7e863988ea4d0527aa0a3b1b7ecff4d4abc4", size = 10276093, upload-time = "2026-02-26T15:39:44.654Z" }, + { url = "https://files.pythonhosted.org/packages/82/cb/318206d778c7f42917ca7b0f9436cf27652d1731fe434d3c9990c4a611fa/ruff-0.15.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e90fa1bed82ffede5768232b9bd23212c547ab7cd74c752007ecade1d895ee1a", size = 11051593, upload-time = "2026-02-26T15:39:35.157Z" }, + { url = "https://files.pythonhosted.org/packages/58/8f/65ee4c1b88e49dd4c0a3fc43e81832536c7942f0c702b6f3d25db0f95d6c/ruff-0.15.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e9d53760b7061ddbe5ea9e25381332c607fc14c40bde78f8a25392a93a68d74", size = 11885820, upload-time = "2026-02-26T15:39:59.504Z" }, + { url = "https://files.pythonhosted.org/packages/db/04/d4261f6729ad9a356bc6e3223ba297acf3b66118cef4795b4a8953b255ff/ruff-0.15.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec90e3b78c56c4acca4264d371dd48e29215ecb673cc2fa3c4b799b72050e491", size = 11340583, upload-time = "2026-02-26T15:39:50.781Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/490f38b2bc104e0fdc9496c2a66a48fb2d24a01de46ba0c60c4f6c4d4590/ruff-0.15.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ce448fd395f822e34c8f6f7dfcd84b6726340082950858f92c4daa6baf8915", size = 11160701, upload-time = "2026-02-26T15:40:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/ad/25/eae9cb7b6c28b425ed8cbe797da89c78146071102181ba74c4cdfd06bbeb/ruff-0.15.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14f7d763962d385f75b9b3b57fcc5661c56c20d8b1ddc9f5c881b5fa0ba499fa", size = 11111482, upload-time = "2026-02-26T15:39:56.462Z" }, + { url = "https://files.pythonhosted.org/packages/95/18/16d0b5ef143cb9e52724f18cbccb4b3c5cd4d4e2debbd95e2be3aeb64c9e/ruff-0.15.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b57084e3a3d65418d376c7023711c37cce023cd2fb038a76ba15ee21f3c2c2ee", size = 10497151, upload-time = "2026-02-26T15:40:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/1829314241ddba07c54a742ab387da343fe56a0267a6b6498f3e2ae99821/ruff-0.15.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d567523ff7dcf3112b0f71231d18c3506dd06943359476ee64dea0f9c8f63976", size = 10281955, upload-time = "2026-02-26T15:40:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/80a4ec4bd3cf58ca9b49dccf2bd232b520db14184912fb7e0eb6f3ecc484/ruff-0.15.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4223088d255bf31a50b6640445b39f668164d64c23e5fa403edfb1e0b11122e5", size = 10766613, upload-time = "2026-02-26T15:40:21.55Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/fe016b862295dc57499997e7f2edc58119469b210f4f03ccb763fa65f130/ruff-0.15.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32399ddae088970b2db6efd8d3f49981375cb828075359b6c088ed1fe63d64e1", size = 11262113, upload-time = "2026-02-26T15:39:41.5Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/77dcd05940388d9ba3de03ac4b8b598826d57935728071e1be9f2ef5b714/ruff-0.15.3-py3-none-win32.whl", hash = "sha256:1f1eb95ff614351e3a89a862b6d94e6c42c170e61916e1f20facd6c38477f5f3", size = 10509423, upload-time = "2026-02-26T15:40:05.217Z" }, + { url = "https://files.pythonhosted.org/packages/29/d5/76aab0fabbd54e8c77d02fcff2494906ba85b539d22aa9b7124f7100f008/ruff-0.15.3-py3-none-win_amd64.whl", hash = "sha256:2b22dffe5f5e1e537097aa5208684f069e495f980379c4491b1cfb198a444d0c", size = 11637739, upload-time = "2026-02-26T15:39:53.951Z" }, + { url = "https://files.pythonhosted.org/packages/f2/61/9b4e3682dfd26054321e1b2fdb67a51361dd6ec2fb63f2b50d711f8832ae/ruff-0.15.3-py3-none-win_arm64.whl", hash = "sha256:82443c14d694d4cbd9e598ede27ef5d6f08389ccad91c933be775ea2f4e66f76", size = 10957794, upload-time = "2026-02-26T15:40:08.045Z" }, ] [[package]] @@ -2713,8 +2713,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [ @@ -2798,31 +2798,31 @@ wheels = [ [[package]] name = "ty" -version = "0.0.17" +version = "0.0.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/5e/da108b9eeb392e02ff0478a34e9651490b36af295881cb56575b83f0cc3a/ty-0.0.19.tar.gz", hash = "sha256:ee3d9ed4cb586e77f6efe3d0fe5a855673ca438a3d533a27598e1d3502a2948a", size = 5220026, upload-time = "2026-02-26T12:13:15.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" }, - { url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" }, - { url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" }, - { url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" }, - { url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" }, - { url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" }, - { url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" }, - { url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" }, - { url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" }, + { url = "https://files.pythonhosted.org/packages/5a/31/fd8c6067abb275bea11523d21ecf64e1d870b1ce80cac529cf6636df1471/ty-0.0.19-py3-none-linux_armv6l.whl", hash = "sha256:29bed05d34c8a7597567b8e327c53c1aed4a07dcfbe6c81e6d60c7444936ad77", size = 10268470, upload-time = "2026-02-26T12:13:42.881Z" }, + { url = "https://files.pythonhosted.org/packages/15/de/16a11bbf7d98c75849fc41f5d008b89bb5d080a4b10dc8ea851ee2bd371b/ty-0.0.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79140870c688c97ec68e723c28935ddef9d91a76d48c68e665fe7c851e628b8a", size = 10098562, upload-time = "2026-02-26T12:13:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4f/086d6ff6686eadf903913c45b53ab96694b62bbfee1d8cf3e55a9b5aa4b2/ty-0.0.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6e9c1f9cfa6a26f7881d14d75cf963af743f6c4189e6aa3e3b4056a65f22e730", size = 9604073, upload-time = "2026-02-26T12:13:24.645Z" }, + { url = "https://files.pythonhosted.org/packages/95/13/888a6b6c7ed4a880fee91bec997f775153ce86215ee4c56b868516314734/ty-0.0.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbca43b050edf1db2e64ae7b79add233c2aea2855b8a876081bbd032edcd0610", size = 10106295, upload-time = "2026-02-26T12:13:40.584Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e8/05a372cae8da482de73b8246fb43236bf11e24ac28c879804568108759db/ty-0.0.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8acaa88ab1955ca6b15a0ccc274011c4961377fe65c3948e5d2b212f2517b87c", size = 10098234, upload-time = "2026-02-26T12:13:33.725Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f1/5b0958e9e9576e7662192fe689bbb3dc88e631a4e073db3047793a547d58/ty-0.0.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a901b6a6dd9d17d5b3b2e7bafc3057294e88da3f5de507347316687d7f191a1", size = 10607218, upload-time = "2026-02-26T12:13:17.576Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ab/358c78b77844f58ff5aca368550ab16c719f1ab0ec892ceb1114d7500f4e/ty-0.0.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8deafdaaaee65fd121c66064da74a922d8501be4a2d50049c71eab521a23eff7", size = 11160593, upload-time = "2026-02-26T12:13:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/95/59/827fc346d66a59fe48e9689a5ceb67dbbd5b4de2e8d4625371af39a2e8b7/ty-0.0.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e56071af280897441018f74f921b97d53aec0856f8af85f4f949df8eda07d", size = 10822392, upload-time = "2026-02-26T12:13:29.415Z" }, + { url = "https://files.pythonhosted.org/packages/81/f9/3bbfbbe35478de9bcd63848f4bc9bffda72278dd9732dbad3efc3978432e/ty-0.0.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abdf5885130393ce74501dba792f48ce0a515756ec81c33a4b324bdf3509df6e", size = 10707139, upload-time = "2026-02-26T12:13:20.148Z" }, + { url = "https://files.pythonhosted.org/packages/12/9e/597023b183ec4ade83a36a0cea5c103f3bffa34f70813d46386c61447fb8/ty-0.0.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:877e89005c8f9d1dbff5ad14cbac9f35c528406fde38926f9b44f24830de8d6a", size = 10096933, upload-time = "2026-02-26T12:13:45.266Z" }, + { url = "https://files.pythonhosted.org/packages/1e/76/d0d2f6e674db2a17c8efa5e26682b9dfa8d34774705f35902a7b45ebd3bd/ty-0.0.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:39bd1da051c1e4d316efaf79dbed313255633f7c6ad6e24d29f4d9c6ffaf4de6", size = 10109547, upload-time = "2026-02-26T12:13:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b0/76026c06b852a3aa4fdb5bd329fdc2175aaf3c64a3fafece9cc4df167cee/ty-0.0.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:87df8415a6c9cb27b8f1382fcdc6052e59f5b9f50f78bc14663197eb5c8d3699", size = 10289110, upload-time = "2026-02-26T12:13:38.29Z" }, + { url = "https://files.pythonhosted.org/packages/14/6c/f3b3a189816b4f079b20fe5d0d7ee38e38a472f53cc6770bb6571147e3de/ty-0.0.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:89b6bb23c332ed5c38dd859eb5793f887abcc936f681a40d4ea68e35eac1af33", size = 10796479, upload-time = "2026-02-26T12:13:10.992Z" }, + { url = "https://files.pythonhosted.org/packages/3d/18/caee33d1ce9dd50bd94c26cde7cda4f6971e22e474e7d72a5c86d745ad58/ty-0.0.19-py3-none-win32.whl", hash = "sha256:19b33df3aa7af7b1a9eaa4e1175c3b4dec0f5f2e140243e3492c8355c37418f3", size = 9677215, upload-time = "2026-02-26T12:13:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/18fc0771d0b1da7d7cc2fc9af278d3122b754fe8b521a748734f4e16ecfd/ty-0.0.19-py3-none-win_amd64.whl", hash = "sha256:b9052c61464cdd76bc8e6796f2588c08700f25d0dcbc225bb165e390ea9d96a4", size = 10651252, upload-time = "2026-02-26T12:13:13.035Z" }, + { url = "https://files.pythonhosted.org/packages/8b/8c/26f7ce8863eb54510082747b3dfb1046ba24f16fc11de18c0e5feb36ff18/ty-0.0.19-py3-none-win_arm64.whl", hash = "sha256:9329804b66dcbae8e7af916ef4963221ed53b8ec7d09b0793591c5ae8a0f3270", size = 10093195, upload-time = "2026-02-26T12:13:26.816Z" }, ] [[package]] name = "typer" -version = "0.23.2" +version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -2830,9 +2830,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/93d16574e66dfe4c2284ffdaca4b0320ade32858cb2cc586c8dd79f127c5/typer-0.23.2.tar.gz", hash = "sha256:a99706a08e54f1aef8bb6a8611503808188a4092808e86addff1828a208af0de", size = 120162, upload-time = "2026-02-16T18:52:40.354Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2c/dee705c427875402200fe779eb8a3c00ccb349471172c41178336e9599cc/typer-0.23.2-py3-none-any.whl", hash = "sha256:e9c8dc380f82450b3c851a9b9d5a0edf95d1d6456ae70c517d8b06a50c7a9978", size = 56834, upload-time = "2026-02-16T18:52:39.308Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] [[package]] @@ -2856,6 +2856,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -2867,16 +2876,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] [[package]] From 507e6b80abc155dc8b824f0be9bda34a26ba558a Mon Sep 17 00:00:00 2001 From: manojPal23234 Date: Fri, 27 Feb 2026 02:38:54 +0530 Subject: [PATCH 15/61] OpenAPI: rewrite $ref under propertyNames and patternProperties in _replace_ref_with_defs; add regression test for dict[StrEnum, Model] (#3306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Normalize OpenAPI $ref everywhere (incl. propertyNames); migrate componentsβ†’$defs; add regression test * Fix: normalize $ref in propertyNames and additionalProperties; add regression test * Deterministic migration: components.schemas override $defs on collision; preserve direct $defs refs via alias; add collision test * Fix: rewrite $ref in propertyNames and patternProperties in _replace_ref_with_defs * Fix syntax error, formatting, and stray files * Skip boolean subschemas in patternProperties --------- Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- src/fastmcp/utilities/openapi/schemas.py | 13 +++++++ .../openapi/test_propertynames_ref_rewrite.py | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/utilities/openapi/test_propertynames_ref_rewrite.py 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/utilities/openapi/test_propertynames_ref_rewrite.py b/tests/utilities/openapi/test_propertynames_ref_rewrite.py new file mode 100644 index 000000000..809363410 --- /dev/null +++ b/tests/utilities/openapi/test_propertynames_ref_rewrite.py @@ -0,0 +1,34 @@ +from fastmcp.utilities.openapi.schemas import _replace_ref_with_defs + + +def test_replace_ref_with_defs_rewrites_propertyNames_ref(): + """ + Regression test for issue #3303. + + When using dict[StrEnum, Model], Pydantic generates: + + { + "type": "object", + "propertyNames": {"$ref": "#/components/schemas/Category"}, + "additionalProperties": {"$ref": "#/components/schemas/ItemInfo"} + } + + _replace_ref_with_defs should rewrite BOTH refs to #/$defs/. + """ + + schema = { + "type": "object", + "propertyNames": {"$ref": "#/components/schemas/Category"}, + "additionalProperties": {"$ref": "#/components/schemas/ItemInfo"}, + } + + result = _replace_ref_with_defs(schema) + + # additionalProperties ref is rewritten + assert result["additionalProperties"]["$ref"] == "#/$defs/ItemInfo" + + # propertyNames ref must also be rewritten (this was the bug) + assert result["propertyNames"]["$ref"] == "#/$defs/Category" + + # Ensure no dangling OpenAPI refs remain + assert "#/components/schemas/" not in str(result) From c349bd9e256a6b0443c6db60e5533cba6c348cc4 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:10:02 -0500 Subject: [PATCH 16/61] Remove stale add_resource() key parameter from docs (#3309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `key` parameter was removed from `add_resource()` in the 2.x era and no longer exists in the implementation. Removes all references and the "Custom Resource Keys" section from both the current and v2 docs. πŸ€– Generated with Claude Code https://claude.ai/code/session_01Nc1qEJ1rKaRRxB5h6Qu5V3 Co-authored-by: Claude --- docs/servers/resources.mdx | 28 +--------------------------- docs/v2/servers/resources.mdx | 28 +--------------------------- 2 files changed, 2 insertions(+), 54 deletions(-) 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/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 From c96c0400f349898a4eecaeb625b8c1b98050c4c4 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:42:38 -0500 Subject: [PATCH 17/61] feat: Search transforms for tool discovery (#3154) * feat: Add search transforms for tool discovery RegexSearchTransform and BM25SearchTransform collapse large tool catalogs into a search interface so LLMs discover tools on demand instead of receiving the full listing. * chore: Update SDK documentation * fix: call_tool recursion guard, atomic BM25 rebuild, hash includes descriptions * Extract CatalogTransform base class for catalog-aware transforms Transforms that replace list_tools() with synthetic components (like search) need to read the real catalog at call time without triggering their own replacement logic. CatalogTransform handles the re-entrant bypass via per-instance ContextVar, exposing transform_tools() as the subclass hook and get_tool_catalog() for catalog access. * Add search transform examples for regex and BM25 * Add README for search transform examples * Polish search example clients with rich output * Remove hardcoded tool counts from search example subtitles * Clarify that review bot feedback should be evaluated on its merits * Expand search transform docs with proper hierarchy --------- Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> --- CLAUDE.md | 2 +- docs/docs.json | 1 + docs/servers/transforms/tool-search.mdx | 177 +++++++ docs/servers/transforms/transforms.mdx | 1 + examples/search/README.md | 66 +++ examples/search/client_bm25.py | 93 ++++ examples/search/client_regex.py | 105 ++++ examples/search/server_bm25.py | 85 +++ examples/search/server_regex.py | 74 +++ src/fastmcp/server/transforms/__init__.py | 4 - src/fastmcp/server/transforms/catalog.py | 239 +++++++++ .../server/transforms/search/__init__.py | 23 + src/fastmcp/server/transforms/search/base.py | 171 +++++++ src/fastmcp/server/transforms/search/bm25.py | 142 ++++++ src/fastmcp/server/transforms/search/regex.py | 56 ++ tests/server/transforms/test_catalog.py | 82 +++ tests/server/transforms/test_search.py | 482 ++++++++++++++++++ 17 files changed, 1798 insertions(+), 5 deletions(-) create mode 100644 docs/servers/transforms/tool-search.mdx create mode 100644 examples/search/README.md create mode 100644 examples/search/client_bm25.py create mode 100644 examples/search/client_regex.py create mode 100644 examples/search/server_bm25.py create mode 100644 examples/search/server_regex.py create mode 100644 src/fastmcp/server/transforms/catalog.py create mode 100644 src/fastmcp/server/transforms/search/__init__.py create mode 100644 src/fastmcp/server/transforms/search/base.py create mode 100644 src/fastmcp/server/transforms/search/bm25.py create mode 100644 src/fastmcp/server/transforms/search/regex.py create mode 100644 tests/server/transforms/test_catalog.py create mode 100644 tests/server/transforms/test_search.py diff --git a/CLAUDE.md b/CLAUDE.md index e60814699..c2d3d96b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ When modifying MCP functionality, changes typically need to be applied across al - 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.** 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. +- **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 diff --git a/docs/docs.json b/docs/docs.json index 6c7b732ac..f669bd766 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -163,6 +163,7 @@ "servers/transforms/namespace", "servers/transforms/tool-transformation", "servers/visibility", + "servers/transforms/tool-search", "servers/transforms/resources-as-tools", "servers/transforms/prompts-as-tools" ] diff --git a/docs/servers/transforms/tool-search.mdx b/docs/servers/transforms/tool-search.mdx new file mode 100644 index 000000000..bc54ce051 --- /dev/null +++ b/docs/servers/transforms/tool-search.mdx @@ -0,0 +1,177 @@ +--- +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") + +@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.""" + ... + +mcp.add_transform(RegexSearchTransform()) +``` + +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") + +# ... define tools ... + +mcp.add_transform(BM25SearchTransform()) +``` + +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..ba6642909 100644 --- a/docs/servers/transforms/transforms.mdx +++ b/docs/servers/transforms/transforms.mdx @@ -29,6 +29,7 @@ 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 diff --git a/examples/search/README.md b/examples/search/README.md new file mode 100644 index 000000000..253b196c6 --- /dev/null +++ b/examples/search/README.md @@ -0,0 +1,66 @@ +# 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 server_regex.py # in one terminal +uv run python client_regex.py # in another + +# BM25 +uv run python server_bm25.py +uv run python client_bm25.py +``` + +## Example Output (Regex) + +``` +=== Available Tools === + - search_tools: Search for tools matching a regex pattern. + - call_tool: Call a tool by name with the given arguments. + +=== Search: math tools (pattern: 'add|multiply|fibonacci') === + - add: Add two numbers together. + - multiply: Multiply two numbers. + - fibonacci: Generate the first n Fibonacci numbers. + +=== Search: text tools (pattern: 'text|string|word') === + - reverse_string: Reverse a string. + - word_count: Count the number of words in a text. + - to_uppercase: Convert text to uppercase. + +=== Calling 'add' via call_tool === + Result: 42 +``` + +## Example Output (BM25) + +``` +=== Available Tools === + - list_files: List files in a directory. + - search_tools: Search for tools using natural language. + - call_tool: Call a tool by name with the given arguments. + +=== Search: 'work with numbers' === + - multiply: Multiply two numbers. + - add: Add two numbers together. + - fibonacci: Generate the first n Fibonacci numbers. + +=== Search: 'file operations' === + - read_file: Read the contents of a file. + +=== Calling 'word_count' via call_tool === + Result: 6 +``` + +Note how `list_files` appears in the BM25 example's tool listing β€” it's pinned via `always_visible=["list_files"]`, keeping it visible alongside the synthetic search tools. diff --git a/examples/search/client_bm25.py b/examples/search/client_bm25.py new file mode 100644 index 000000000..a1b67cbda --- /dev/null +++ b/examples/search/client_bm25.py @@ -0,0 +1,93 @@ +"""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 rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from fastmcp.client import Client + +console = Console() + + +def _get_text(result) -> str: + """Extract text content from a CallToolResult.""" + return result.content[0].text + + +def _tool_table(tools: list[dict], *, ranked: 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) + table.add_column("Description", style="dim") + for i, tool in enumerate(tools, 1): + row = [tool["name"], 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() + + # list_files is pinned via always_visible + 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]", + subtitle="[dim]list_files pinned via always_visible[/dim]", + border_style="blue", + ) + ) + console.print() + + # Natural language searches β€” BM25 ranks by relevance + queries = ["work with numbers", "manipulate text strings", "file operations"] + for query in queries: + result = await client.call_tool("search_tools", {"query": query}) + found = json.loads(_get_text(result)) + console.print( + Panel( + _tool_table(found, ranked=True), + title=f'[bold]search_tools[/bold][dim](query="{query}")[/dim]', + subtitle=f"[dim]{len(found)} result{'s' if len(found) != 1 else ''}[/dim]", + border_style="green", + ) + ) + console.print() + + # Call a discovered tool + result = await client.call_tool( + "call_tool", + { + "name": "word_count", + "arguments": {"text": "BM25 search makes tool discovery easy"}, + }, + ) + call_label = Text.assemble( + ("call_tool", "bold"), + ('(word_count, text="BM25 search makes tool discovery easy")', "dim"), + ) + console.print(call_label, "β†’", f"[bold green]{_get_text(result)}[/bold green]") + 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..6bf17de63 --- /dev/null +++ b/examples/search/client_regex.py @@ -0,0 +1,105 @@ +"""Example: Client using regex search to discover and call tools. + +Demonstrates the workflow: list tools (sees only search_tools + call_tool), +search for tools matching a regex pattern, then call a discovered tool. + +Run with: + uv run python examples/search/client_regex.py +""" + +import asyncio +import json + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from fastmcp.client import Client + +console = Console() + + +def _get_text(result) -> str: + """Extract text content from a CallToolResult.""" + return result.content[0].text + + +def _tool_table(tools: list[dict]) -> Table: + table = Table(show_header=True, show_edge=False, pad_edge=False, expand=True) + table.add_column("Tool", style="cyan", no_wrap=True) + table.add_column("Description", style="dim") + for tool in tools: + table.add_row(tool["name"], tool.get("description", "")) + 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() + + # Show what the client actually sees + 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]", + subtitle="[dim]real tools are discoverable via search[/dim]", + border_style="blue", + ) + ) + console.print() + + # Search for math tools + result = await client.call_tool( + "search_tools", {"pattern": "add|multiply|fibonacci"} + ) + found = json.loads(_get_text(result)) + console.print( + Panel( + _tool_table(found), + title='[bold]search_tools[/bold][dim](pattern="add|multiply|fibonacci")[/dim]', + border_style="green", + ) + ) + console.print() + + # Search for text tools + result = await client.call_tool("search_tools", {"pattern": "text|string|word"}) + found = json.loads(_get_text(result)) + console.print( + Panel( + _tool_table(found), + title='[bold]search_tools[/bold][dim](pattern="text|string|word")[/dim]', + border_style="green", + ) + ) + console.print() + + # Call discovered tools via the proxy + result = await client.call_tool( + "call_tool", {"name": "add", "arguments": {"a": 17, "b": 25}} + ) + call_label = Text.assemble( + ("call_tool", "bold"), + ("(add, a=17, b=25)", "dim"), + ) + console.print(call_label, "β†’", f"[bold green]{_get_text(result)}[/bold green]") + + result = await client.call_tool( + "call_tool", + {"name": "reverse_string", "arguments": {"text": "hello world"}}, + ) + call_label = Text.assemble( + ("call_tool", "bold"), + ('(reverse_string, text="hello world")', "dim"), + ) + console.print(call_label, "β†’", f"[bold green]{_get_text(result)}[/bold green]") + 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..9491d185f --- /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 +""" + +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.""" + 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() + + +# 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/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..3fd271dfe --- /dev/null +++ b/src/fastmcp/server/transforms/search/__init__.py @@ -0,0 +1,23 @@ +"""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.bm25 import BM25SearchTransform +from fastmcp.server.transforms.search.regex import RegexSearchTransform + +__all__ = [ + "BM25SearchTransform", + "RegexSearchTransform", +] diff --git a/src/fastmcp/server/transforms/search/base.py b/src/fastmcp/server/transforms/search/base.py new file mode 100644 index 000000000..ced552e31 --- /dev/null +++ b/src/fastmcp/server/transforms/search/base.py @@ -0,0 +1,171 @@ +"""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 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(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 + ] + + +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", + ) -> 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 + + # ------------------------------------------------------------------ + # 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) + + # ------------------------------------------------------------------ + # 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..a7750fe94 --- /dev/null +++ b/src/fastmcp/server/transforms/search/bm25.py @@ -0,0 +1,142 @@ +"""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, + _extract_searchable_text, + _serialize_tools_for_output, +) +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", + ) -> None: + super().__init__( + max_results=max_results, + always_visible=always_visible, + search_tool_name=search_tool_name, + call_tool_name=call_tool_name, + ) + 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] + ) -> 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 _serialize_tools_for_output(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..190d25270 --- /dev/null +++ b/src/fastmcp/server/transforms/search/regex.py @@ -0,0 +1,56 @@ +"""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, + _serialize_tools_for_output, +) +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] + ) -> 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 _serialize_tools_for_output(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/tests/server/transforms/test_catalog.py b/tests/server/transforms/test_catalog.py new file mode 100644 index 000000000..77795c9be --- /dev/null +++ b/tests/server/transforms/test_catalog.py @@ -0,0 +1,82 @@ +"""Tests for CatalogTransform base class.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from mcp.types import TextContent + +from fastmcp import FastMCP +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 +from fastmcp.utilities.versions import VersionSpec + + +class ReplacingTransform(CatalogTransform): + """Minimal subclass that replaces tools with a synthetic tool. + + Uses ``get_tool_catalog()`` to read the real catalog inside the + synthetic tool's handler, verifying that the bypass mechanism works. + """ + + async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: + return [self._make_synthetic_tool()] + + async def get_tool( + self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None + ) -> Tool | None: + if name == "count_tools": + return self._make_synthetic_tool() + return await call_next(name, version=version) + + def _make_synthetic_tool(self) -> Tool: + transform = self + + async def count_tools(ctx: Context = None) -> int: # type: ignore[assignment] + """Return the number of real tools in the catalog.""" + catalog = await transform.get_tool_catalog(ctx) + return len(catalog) + + return Tool.from_function(fn=count_tools, name="count_tools") + + +class TestCatalogTransformBypass: + async def test_list_tools_replaced_by_subclass(self): + mcp = FastMCP("test") + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + @mcp.tool + def multiply(x: float, y: float) -> float: + return x * y + + mcp.add_transform(ReplacingTransform()) + tools = await mcp.list_tools() + names = {t.name for t in tools} + assert names == {"count_tools"} + + async def test_get_tool_catalog_returns_real_tools(self): + mcp = FastMCP("test") + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + @mcp.tool + def multiply(x: float, y: float) -> float: + return x * y + + mcp.add_transform(ReplacingTransform()) + result = await mcp.call_tool("count_tools", {}) + assert any("2" in c.text for c in result.content if isinstance(c, TextContent)) + + async def test_multiple_instances_have_independent_bypass(self): + """Each CatalogTransform instance has its own bypass ContextVar.""" + t1 = ReplacingTransform() + t2 = ReplacingTransform() + assert t1._instance_id != t2._instance_id + assert t1._bypass is not t2._bypass diff --git a/tests/server/transforms/test_search.py b/tests/server/transforms/test_search.py new file mode 100644 index 000000000..9549f8c01 --- /dev/null +++ b/tests/server/transforms/test_search.py @@ -0,0 +1,482 @@ +"""Tests for search transforms.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any +from unittest.mock import MagicMock + +import mcp.types as mcp_types +import pytest +from mcp.types import TextContent + +from fastmcp import Client, FastMCP +from fastmcp.server.context import Context +from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.server.transforms import Visibility +from fastmcp.server.transforms.search.bm25 import ( + BM25SearchTransform, + _BM25Index, + _catalog_hash, +) +from fastmcp.server.transforms.search.regex import RegexSearchTransform +from fastmcp.tools.tool import Tool, ToolResult + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _parse_tool_result(result: ToolResult) -> list[dict[str, Any]]: + """Extract tool list from a ToolResult's structured content.""" + assert result.structured_content is not None + return result.structured_content["result"] + + +def _make_server_with_tools() -> FastMCP: + mcp = FastMCP("test") + + @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 search_database(query: str, limit: int = 10) -> str: + """Search the database for records matching the query.""" + return f"results for {query}" + + @mcp.tool + def delete_record(record_id: str) -> str: + """Delete a record from the database by its ID.""" + return f"deleted {record_id}" + + @mcp.tool + def send_email(to: str, subject: str, body: str) -> str: + """Send an email to the given recipient.""" + return "sent" + + return mcp + + +# --------------------------------------------------------------------------- +# Shared behavior tests (parameterized across both transforms) +# --------------------------------------------------------------------------- + + +class TestBaseTransformBehavior: + """Tests for behavior shared by all search transforms.""" + + async def test_list_tools_hides_tools_regex(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + tools = await mcp.list_tools() + names = {t.name for t in tools} + assert names == {"search_tools", "call_tool"} + + async def test_list_tools_hides_tools_bm25(self): + mcp = _make_server_with_tools() + mcp.add_transform(BM25SearchTransform()) + tools = await mcp.list_tools() + names = {t.name for t in tools} + assert names == {"search_tools", "call_tool"} + + async def test_always_visible_pins_tools(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform(always_visible=["add"])) + tools = await mcp.list_tools() + names = {t.name for t in tools} + assert "add" in names + assert "search_tools" in names + assert "call_tool" in names + assert "multiply" not in names + + async def test_get_tool_returns_synthetic(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + search = await mcp.get_tool("search_tools") + assert search is not None + assert search.name == "search_tools" + call = await mcp.get_tool("call_tool") + assert call is not None + assert call.name == "call_tool" + + async def test_get_tool_passes_through_hidden(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + tool = await mcp.get_tool("add") + assert tool is not None + assert tool.name == "add" + + async def test_call_tool_proxy_executes(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + # Need to call list_tools to populate catalog + await mcp.list_tools() + result = await mcp.call_tool( + "call_tool", {"name": "add", "arguments": {"a": 2, "b": 3}} + ) + assert any("5" in c.text for c in result.content if isinstance(c, TextContent)) + + async def test_custom_tool_names(self): + mcp = _make_server_with_tools() + mcp.add_transform( + RegexSearchTransform( + search_tool_name="find_tools", + call_tool_name="run_tool", + ) + ) + tools = await mcp.list_tools() + names = {t.name for t in tools} + assert names == {"find_tools", "run_tool"} + assert await mcp.get_tool("find_tools") is not None + assert await mcp.get_tool("run_tool") is not None + + async def test_search_respects_visibility_filtering(self): + """Tools disabled via Visibility transform should not appear in search.""" + mcp = _make_server_with_tools() + mcp.add_transform(Visibility(False, names={"delete_record"})) + mcp.add_transform(RegexSearchTransform()) + + tools = await mcp.list_tools() + names = {t.name for t in tools} + assert "delete_record" not in names + + result = await mcp.call_tool("search_tools", {"pattern": "delete"}) + found = _parse_tool_result(result) + assert not any(t["name"] == "delete_record" for t in found) + + async def test_search_respects_auth_middleware(self): + """Tools filtered by auth middleware should not appear in search.""" + + class BlockAdminTools(Middleware): + async def on_list_tools( + self, + context: MiddlewareContext[mcp_types.ListToolsRequest], + call_next: CallNext[mcp_types.ListToolsRequest, Sequence[Tool]], + ) -> Sequence[Tool]: + tools = await call_next(context) + return [t for t in tools if t.name != "delete_record"] + + mcp = _make_server_with_tools() + mcp.add_middleware(BlockAdminTools()) + mcp.add_transform(RegexSearchTransform()) + + async with Client(mcp) as client: + tools = await client.list_tools() + names = {t.name for t in tools} + assert "delete_record" not in names + assert "search_tools" in names + + result = await client.call_tool("search_tools", {"pattern": "delete"}) + found = _parse_tool_result(result) + assert not any(t["name"] == "delete_record" for t in found) + + async def test_search_respects_session_visibility(self): + """Tools disabled via session visibility should not appear in search.""" + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + + @mcp.tool + async def disable_delete(ctx: Context) -> str: + """Helper tool to disable delete_record for this session.""" + await ctx.disable_components(names={"delete_record"}) + return "disabled" + + async with Client(mcp) as client: + # Before disabling, search should find delete_record + result = await client.call_tool("search_tools", {"pattern": "delete"}) + found = _parse_tool_result(result) + assert any(t["name"] == "delete_record" for t in found) + + # Disable via session visibility + await client.call_tool("disable_delete", {}) + + # After disabling, search should NOT find it + result = await client.call_tool("search_tools", {"pattern": "delete"}) + found = _parse_tool_result(result) + assert not any(t["name"] == "delete_record" for t in found) + + +# --------------------------------------------------------------------------- +# Regex-specific tests +# --------------------------------------------------------------------------- + + +class TestRegexSearch: + async def test_search_by_name(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"pattern": "add"}) + tools = _parse_tool_result(result) + assert any(t["name"] == "add" for t in tools) + + async def test_search_by_description(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"pattern": "email"}) + tools = _parse_tool_result(result) + assert any(t["name"] == "send_email" for t in tools) + + async def test_search_by_param_name(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"pattern": "record_id"}) + tools = _parse_tool_result(result) + assert any(t["name"] == "delete_record" for t in tools) + + async def test_search_by_param_description(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"pattern": "recipient"}) + tools = _parse_tool_result(result) + assert any(t["name"] == "send_email" for t in tools) + + async def test_search_or_pattern(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform(max_results=10)) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"pattern": "add|multiply"}) + tools = _parse_tool_result(result) + names = {t["name"] for t in tools} + assert "add" in names + assert "multiply" in names + + async def test_search_case_insensitive(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"pattern": "ADD"}) + tools = _parse_tool_result(result) + assert any(t["name"] == "add" for t in tools) + + async def test_search_invalid_pattern(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"pattern": "[invalid"}) + tools = _parse_tool_result(result) + assert tools == [] + + async def test_search_max_results(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform(max_results=2)) + await mcp.list_tools() + # Match everything + result = await mcp.call_tool("search_tools", {"pattern": ".*"}) + tools = _parse_tool_result(result) + assert len(tools) == 2 + + async def test_search_no_matches(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"pattern": "zzz_nonexistent"}) + tools = _parse_tool_result(result) + assert tools == [] + + async def test_search_returns_full_schema(self): + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"pattern": "add"}) + tools = _parse_tool_result(result) + add_tool = next(t for t in tools if t["name"] == "add") + assert "inputSchema" in add_tool + assert "properties" in add_tool["inputSchema"] + + +# --------------------------------------------------------------------------- +# BM25-specific tests +# --------------------------------------------------------------------------- + + +class TestBM25Search: + async def test_search_relevance(self): + mcp = _make_server_with_tools() + mcp.add_transform(BM25SearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"query": "database"}) + tools = _parse_tool_result(result) + # Database tools should rank highest + assert len(tools) > 0 + names = {t["name"] for t in tools} + assert "search_database" in names + + async def test_search_database_tools(self): + mcp = _make_server_with_tools() + mcp.add_transform(BM25SearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool( + "search_tools", {"query": "delete records from database"} + ) + tools = _parse_tool_result(result) + assert len(tools) > 0 + # delete_record should be highly relevant + assert tools[0]["name"] == "delete_record" + + async def test_search_max_results(self): + mcp = _make_server_with_tools() + mcp.add_transform(BM25SearchTransform(max_results=2)) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"query": "number"}) + tools = _parse_tool_result(result) + assert len(tools) <= 2 + + async def test_search_no_matches(self): + mcp = _make_server_with_tools() + mcp.add_transform(BM25SearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"query": "zzz_nonexistent_xyz"}) + tools = _parse_tool_result(result) + assert tools == [] + + async def test_index_rebuilds_on_catalog_change(self): + """When a new tool is added, the next list_tools + search sees it.""" + mcp = _make_server_with_tools() + mcp.add_transform(BM25SearchTransform()) + await mcp.list_tools() + + # Search before adding tool + result = await mcp.call_tool("search_tools", {"query": "weather forecast"}) + tools = _parse_tool_result(result) + assert not any(t["name"] == "get_weather" for t in tools) + + # Add a new tool + @mcp.tool + def get_weather(city: str) -> str: + """Get the weather forecast for a city.""" + return f"sunny in {city}" + + # Must call list_tools to refresh the catalog cache + await mcp.list_tools() + + result = await mcp.call_tool("search_tools", {"query": "weather forecast"}) + tools = _parse_tool_result(result) + assert any(t["name"] == "get_weather" for t in tools) + + async def test_search_empty_query(self): + mcp = _make_server_with_tools() + mcp.add_transform(BM25SearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"query": ""}) + tools = _parse_tool_result(result) + assert tools == [] + + async def test_search_returns_full_schema(self): + mcp = _make_server_with_tools() + mcp.add_transform(BM25SearchTransform()) + await mcp.list_tools() + result = await mcp.call_tool("search_tools", {"query": "add numbers"}) + tools = _parse_tool_result(result) + add_tool = next(t for t in tools if t["name"] == "add") + assert "inputSchema" in add_tool + assert "properties" in add_tool["inputSchema"] + + +# --------------------------------------------------------------------------- +# BM25 index unit tests +# --------------------------------------------------------------------------- + + +class TestBM25Index: + def test_basic_ranking(self): + index = _BM25Index() + index.build( + [ + "search database query records", + "add two numbers together", + "send email recipient subject", + ] + ) + results = index.query("database records", 3) + assert results[0] == 0 # Database doc should rank first + + def test_empty_corpus(self): + index = _BM25Index() + index.build([]) + assert index.query("anything", 5) == [] + + def test_no_matching_tokens(self): + index = _BM25Index() + index.build(["alpha beta gamma"]) + assert index.query("zzz", 5) == [] + + +# --------------------------------------------------------------------------- +# call_tool self-reference guard +# --------------------------------------------------------------------------- + + +class TestCallToolGuard: + async def test_call_tool_proxy_rejects_itself(self): + """Calling call_tool(name='call_tool') must not recurse infinitely.""" + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + + async with Client(mcp) as client: + with pytest.raises(Exception): + await client.call_tool( + "call_tool", {"name": "call_tool", "arguments": {}} + ) + + async def test_call_tool_proxy_rejects_search_tool(self): + """Calling call_tool(name='search_tools') must be rejected.""" + mcp = _make_server_with_tools() + mcp.add_transform(RegexSearchTransform()) + + async with Client(mcp) as client: + with pytest.raises(Exception): + await client.call_tool( + "call_tool", + {"name": "search_tools", "arguments": {"pattern": "add"}}, + ) + + async def test_call_tool_proxy_rejects_custom_names(self): + """Guard works when synthetic tools have custom names.""" + mcp = _make_server_with_tools() + mcp.add_transform( + RegexSearchTransform( + search_tool_name="find_tools", call_tool_name="run_tool" + ) + ) + + async with Client(mcp) as client: + with pytest.raises(Exception): + await client.call_tool( + "run_tool", {"name": "run_tool", "arguments": {}} + ) + with pytest.raises(Exception): + await client.call_tool( + "run_tool", {"name": "find_tools", "arguments": {"pattern": "add"}} + ) + + +# --------------------------------------------------------------------------- +# catalog hash staleness +# --------------------------------------------------------------------------- + + +class TestCatalogHash: + def test_hash_differs_for_same_name_different_description(self): + """Hash must change when a tool's description changes, not just its name.""" + tool_a = MagicMock() + tool_a.name = "search" + tool_a.description = "find records in the database" + tool_a.parameters = {} + + tool_b = MagicMock() + tool_b.name = "search" + tool_b.description = "send an email to a recipient" + tool_b.parameters = {} + + assert _catalog_hash([tool_a]) != _catalog_hash([tool_b]) From 9344224452b462b20b225d13042e04e4c549845e Mon Sep 17 00:00:00 2001 From: Wang Yiyang Date: Fri, 27 Feb 2026 20:37:25 +0800 Subject: [PATCH 18/61] Update docs/servers/server.mdx(Fix "FastMCP Constructor Parameters") Fix description of "FastMCP Constructor Parameters": Remove parameters `on_duplicate_tools`, `on_duplicate_resources` and `on_duplicate_prompts`, which are no longer accepted by FastMCP(). Add the new parameter `on_duplicate` and its description. --- docs/servers/server.mdx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index e3624ea4f..fe1b38541 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -76,16 +76,8 @@ The `FastMCP` constructor accepts several configuration options. The most common 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 From 3438b77e88216724ec90f66183b74b452e5ba4c2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:54:27 -0500 Subject: [PATCH 19/61] Fix stale docs: update tag filtering API and add output_schema ParamField --- docs/servers/server.mdx | 24 ++++++++++-------------- docs/servers/tools.mdx | 6 ++++++ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index fe1b38541..b28985df9 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -68,13 +68,6 @@ The `FastMCP` constructor accepts several configuration options. The most common - - Only expose components with at least one matching tag - - - - Hide components with any matching tag - How to handle duplicate component registrations @@ -169,25 +162,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/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 From b9153404f4c438623481334e892128f363775b9c Mon Sep 17 00:00:00 2001 From: Adam Azzam <33043305+aaazzam@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:14:03 -0500 Subject: [PATCH 20/61] Add experimental CodeMode transform (#3297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * code mode * update uv.lock for monty optional dep πŸ€– Generated with Claude Code * retry CI * Address PR review comments on CodeMode transform πŸ€– Generated with Claude Code Co-Authored-By: Claude Opus 4.6 * Fix ty unresolved-attribute error on search_helper πŸ€– Generated with Claude Code Co-Authored-By: Claude Opus 4.6 * more idiomacy * harden * fix docs * harden * fix red CI * Refactor CodeMode to use CatalogTransform base class Removes the duplicate ContextVar bypass pattern in favor of the shared CatalogTransform machinery. Also fixes a pre-existing bug where `from __future__ import annotations` caused NameError for Annotated in nested function scopes at runtime. * Remove redundant _get_visible_tools wrapper in CodeMode * Rewrite CodeMode docs with proper motivation and structure * Fix type narrowing in collision test * Stop unwrapping tool results in CodeMode's call_tool call_tool() inside execute blocks now returns structured content as-is, preserving the {"result": value} wrapping. This means the output schema shown in search results accurately describes what call_tool() returns, so LLMs can trust the schema when writing code. Also adds examples/code_mode/ with a server and narrated client demo. * Simplify call_tool return type: dict | str * Fix example client to unwrap structured results * Let server resolve tool versions instead of pinning first match * Rewrite CodeMode docs to match current behavior * Rename optional extra from monty to code-mode --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- docs/docs.json | 3 +- docs/servers/transforms/code-mode.mdx | 166 +++++++ docs/servers/transforms/transforms.mdx | 1 + examples/code_mode/README.md | 37 ++ examples/code_mode/client.py | 142 ++++++ examples/code_mode/server.py | 84 ++++ pyproject.toml | 3 +- src/fastmcp/experimental/__init__.py | 0 .../experimental/transforms/__init__.py | 11 + .../experimental/transforms/code_mode.py | 276 +++++++++++ .../experimental/transforms/test_code_mode.py | 456 ++++++++++++++++++ uv.lock | 82 +++- 12 files changed, 1254 insertions(+), 7 deletions(-) create mode 100644 docs/servers/transforms/code-mode.mdx create mode 100644 examples/code_mode/README.md create mode 100644 examples/code_mode/client.py create mode 100644 examples/code_mode/server.py create mode 100644 src/fastmcp/experimental/__init__.py create mode 100644 src/fastmcp/experimental/transforms/__init__.py create mode 100644 src/fastmcp/experimental/transforms/code_mode.py create mode 100644 tests/experimental/transforms/test_code_mode.py diff --git a/docs/docs.json b/docs/docs.json index f669bd766..24422f592 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -165,7 +165,8 @@ "servers/visibility", "servers/transforms/tool-search", "servers/transforms/resources-as-tools", - "servers/transforms/prompts-as-tools" + "servers/transforms/prompts-as-tools", + "servers/transforms/code-mode" ] }, { diff --git a/docs/servers/transforms/code-mode.mdx b/docs/servers/transforms/code-mode.mdx new file mode 100644 index 000000000..1073c3366 --- /dev/null +++ b/docs/servers/transforms/code-mode.mdx @@ -0,0 +1,166 @@ +--- +title: Code Mode (Experimental) +sidebarTitle: Code Mode +description: Let LLMs write Python to orchestrate tools in a sandbox +icon: flask +tag: EXPERIMENTAL +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +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 by replacing the tool catalog with two meta-tools β€” `search` for discovering tools by keyword, and `execute` for running Python scripts that chain tool calls in a sandbox. The LLM discovers what it needs, writes a script, and gets back only the final answer. One round-trip instead of ten; tool definitions loaded only when needed instead of all at once. + +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). + + +CodeMode requires a sandbox to execute LLM-generated code safely. The default sandbox uses [pydantic-monty](https://github.com/pydantic/pydantic-monty), installed via `pip install "fastmcp[code-mode]"`. You can also provide your own sandbox β€” see [Custom Sandbox Providers](#custom-sandbox-providers). + + +## Basic Usage + +```python +from fastmcp import FastMCP +from fastmcp.experimental.transforms import CodeMode + +mcp = FastMCP("Server") + +@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()) + +if __name__ == "__main__": + mcp.run() +``` + +Clients now see only two tools. The LLM discovers the real tools through `search`, then orchestrates them through `execute`: + +```python +# Discover tools with a keyword search +result = await client.call_tool("search", {"query": "add multiply numbers"}) +# [{"name": "add", "inputSchema": {...}, ...}, {"name": "multiply", ...}] + +# Chain tool calls in a single round-trip +result = await client.call_tool("execute", { + "code": """ +a = await call_tool("add", {"x": 3, "y": 4}) +b = await call_tool("multiply", {"x": a["result"], "y": 2}) +return b +""" +}) +# {"result": 14} +``` + +## Search + +The `search` meta-tool takes a `query` string and returns matching tools ranked by relevance, including their full `inputSchema` and `outputSchema`. The LLM uses these schemas to understand how to call each tool and what to expect back. + +Search uses BM25 ranking by default, matching against tool names and descriptions. You can swap in any [search transform](/servers/transforms/tool-search): + +```python +from fastmcp.server.transforms.search import RegexSearchTransform + +mcp.add_transform( + CodeMode( + search_transform=RegexSearchTransform(), + ) +) +``` + +## Execute + +The `execute` meta-tool takes a `code` string containing async Python. Inside the sandbox, one function is available: + +```python +await call_tool(tool_name, params) # -> dict | str +``` + +The return type depends on whether the tool declares an output schema. When it does, `call_tool` returns the structured content dict exactly as the schema describes β€” for example, `{"result": 42}` for a tool returning `int`. When there's no output schema, `call_tool` returns the text content as a string. The LLM can tell which to expect from the `outputSchema` field in search results. + +Use `return` to produce the final output from the script. + +### Default Arguments + +When tools share common parameters (like workspace IDs or API keys), `default_arguments` injects them automatically: + +```python +mcp.add_transform(CodeMode( + default_arguments={"workspace_id": "ws-123"} +)) +``` + +Defaults are only injected when the tool actually accepts the parameter and the LLM hasn't provided it explicitly. Parameters that a tool doesn't accept are silently skipped. + +## OpenAPI Integration + +`CodeMode` pairs naturally with OpenAPI-backed providers, where a single API spec can expose hundreds of endpoints as tools: + +```python +import httpx + +from fastmcp import FastMCP +from fastmcp.experimental.transforms import CodeMode +from fastmcp.server.providers.openapi import OpenAPIProvider + +openapi_spec = httpx.get("https://api.example.com/openapi.json").json() +api_client = httpx.AsyncClient(base_url="https://api.example.com") + +provider = OpenAPIProvider( + openapi_spec=openapi_spec, + client=api_client, +) + +mcp = FastMCP("API Code Mode", providers=[provider]) +mcp.add_transform(CodeMode()) +``` + +## Configuration + +### Custom Tool Names + +The default `search` and `execute` names can be changed: + +```python +mcp.add_transform(CodeMode( + search_tool_name="find_tools", + execute_tool_name="run_workflow", + execute_description="Run multi-step API workflows", +)) +``` + +### Custom Sandbox Providers + +The default `MontySandboxProvider` uses [pydantic-monty](https://github.com/pydantic/pydantic-monty) for sandboxed execution. You can replace it with any object implementing the `SandboxProvider` protocol: + +```python +from collections.abc import Callable +from typing import Any + +from fastmcp.experimental.transforms import CodeMode, 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.add_transform(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/transforms.mdx b/docs/servers/transforms/transforms.mdx index ba6642909..a3684c0a4 100644 --- a/docs/servers/transforms/transforms.mdx +++ b/docs/servers/transforms/transforms.mdx @@ -32,6 +32,7 @@ FastMCP provides several transforms for common use cases: - **[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 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..48d62a410 --- /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 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/pyproject.toml b/pyproject.toml index 9babf664a..f0299ba15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,13 +53,14 @@ classifiers = [ [project.optional-dependencies] anthropic = ["anthropic>=0.40.0"] azure = ["azure-identity>=1.16.0"] +code-mode = ["pydantic-monty>=0.0.7"] openai = ["openai>=1.102.0"] tasks = ["pydocket>=0.17.2"] [dependency-groups] dev = [ "dirty-equals>=0.9.0", - "fastmcp[anthropic,azure,openai,tasks]", + "fastmcp[anthropic,azure,code-mode,openai,tasks]", # add optional dependencies for fastmcp dev "fastapi>=0.115.12", "opentelemetry-sdk>=1.20.0", 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..6728feb70 --- /dev/null +++ b/src/fastmcp/experimental/transforms/__init__.py @@ -0,0 +1,11 @@ +from .code_mode import ( + CodeMode, + MontySandboxProvider, + SandboxProvider, +) + +__all__ = [ + "CodeMode", + "MontySandboxProvider", + "SandboxProvider", +] diff --git a/src/fastmcp/experimental/transforms/code_mode.py b/src/fastmcp/experimental/transforms/code_mode.py new file mode 100644 index 000000000..439457099 --- /dev/null +++ b/src/fastmcp/experimental/transforms/code_mode.py @@ -0,0 +1,276 @@ +import asyncio +import importlib +import logging +from collections.abc import Callable, Sequence +from typing import Annotated, Any, 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 ( + BaseSearchTransform, + _serialize_tools_for_output, +) +from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.utilities.versions import VersionSpec + +logger = logging.getLogger(__name__) + + +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) + + +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`.""" + + def __init__(self, *, install_hint: str = "fastmcp[code-mode]") -> None: + self.install_hint = install_hint + + 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. " + f"Install it with `{self.install_hint}` 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 + return await pydantic_monty.run_monty_async(monty, **run_kwargs) + + +class CodeMode(CatalogTransform): + """Transform that collapses all tools into `search` + `execute` meta-tools.""" + + def __init__( + self, + *, + default_arguments: dict[str, Any] | None = None, + sandbox_provider: SandboxProvider | None = None, + search_transform: BaseSearchTransform | None = None, + search_tool_name: str = "search", + execute_tool_name: str = "execute", + execute_description: str | None = None, + ) -> None: + if search_tool_name == execute_tool_name: + raise ValueError( + "search_tool_name and execute_tool_name must be different." + ) + + super().__init__() + self._default_arguments = default_arguments or {} + self.search_tool_name = search_tool_name + self.execute_tool_name = execute_tool_name + self.execute_description = execute_description + self.sandbox_provider = sandbox_provider or MontySandboxProvider() + self._cached_search_tool: Tool | None = None + self._cached_execute_tool: Tool | None = None + + if search_transform is None: + from fastmcp.server.transforms.search.bm25 import BM25SearchTransform + + search_transform = BM25SearchTransform() + self._search_transform = search_transform + + async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: + return [self._get_search_tool(), self._get_execute_tool()] + + async def get_tool( + self, + name: str, + call_next: GetToolNext, + *, + version: VersionSpec | None = None, + ) -> Tool | None: + if name == self.search_tool_name: + return self._get_search_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_search_tool(self) -> Tool: + if self._cached_search_tool is None: + self._cached_search_tool = self._make_search_tool() + return self._cached_search_tool + + 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_search_tool(self) -> Tool: + transform = self + + async def search( + query: Annotated[ + str, + "Search query to find available tools", + ], + ctx: Context = None, # type: ignore[assignment] + ) -> list[dict[str, Any]]: + """Search for available tools by query. + + Returns matching tool definitions ranked by relevance, + in the same format as list_tools. + """ + tools = await transform.get_tool_catalog(ctx) + results = await transform._search_transform._search(tools, query) + return _serialize_tools_for_output(results) + + return Tool.from_function(fn=search, name=self.search_tool_name) + + 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.""" + defaults = transform._default_arguments + # Cache the tool catalog for the duration of this execute block + # so multiple call_tool() invocations don't each trigger list_tools(). + 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}") + + accepted_args = set(tool.parameters.get("properties", {}).keys()) + skipped = { + key + for key in defaults + if key not in params and key not in accepted_args + } + if skipped: + logger.debug( + "default_arguments keys %s not accepted by tool %r, skipping", + skipped, + tool.name, + ) + merged = { + key: value + for key, value in defaults.items() + if key not in params and key in accepted_args + } + merged.update(params) + + result = await ctx.fastmcp.call_tool(tool.name, merged) + 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", + "MontySandboxProvider", + "SandboxProvider", +] diff --git a/tests/experimental/transforms/test_code_mode.py b/tests/experimental/transforms/test_code_mode.py new file mode 100644 index 000000000..1bad81528 --- /dev/null +++ b/tests/experimental/transforms/test_code_mode.py @@ -0,0 +1,456 @@ +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 import CodeMode, MontySandboxProvider +from fastmcp.experimental.transforms.code_mode import _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_search_results(result: ToolResult) -> list[dict[str, Any]]: + """Extract the list of tool dicts from a search ToolResult. + + The search tool returns ``list[dict]`` which gets wrapped in + ``{"result": [...]}`` by the structured-output convention. + """ + data = _unwrap_result(result) + if isinstance(data, dict) and "result" in data: + return data["result"] + if isinstance(data, list): + return data + raise AssertionError(f"Unexpected search result shape: {data!r}") + + +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) + + +async def test_code_mode_transform_hides_backend_tools_and_supports_defaults() -> None: + mcp = FastMCP("CodeMode Test") + + @mcp.tool + def add(x: int, y: int, workspace_id: str) -> str: + """Add two numbers with workspace context.""" + return f"{workspace_id}:{x + y}" + + @mcp.tool + def status() -> str: + """Get current status.""" + return "ok" + + mcp.add_transform( + CodeMode( + default_arguments={"workspace_id": "ws-default"}, + sandbox_provider=_UnsafeTestSandboxProvider(), + ) + ) + + listed_tools = await mcp.list_tools(run_middleware=False) + assert {tool.name for tool in listed_tools} == {"search", "execute"} + + search_result = await _run_tool(mcp, "search", {"query": "add numbers"}) + names = [t["name"] for t in _unwrap_search_results(search_result)] + assert "add" in names + + execute_result = await _run_tool( + mcp, + "execute", + {"code": "return await call_tool('add', {'x': 2, 'y': 3})"}, + ) + assert _unwrap_result(execute_result) == {"result": "ws-default:5"} + + status_result = await _run_tool( + mcp, + "execute", + {"code": "return await call_tool('status', {})"}, + ) + assert _unwrap_result(status_result) == {"result": "ok"} + + +async def test_code_mode_transform_replaces_listed_tools() -> None: + mcp = FastMCP("CodeMode Transform") + + @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", "execute"} + + +async def test_code_mode_tool_descriptions_are_configurable() -> None: + mcp = FastMCP("CodeMode Descriptions") + + @mcp.tool + def ping() -> str: + return "pong" + + mcp.add_transform( + CodeMode( + sandbox_provider=_UnsafeTestSandboxProvider(), + search_tool_name="search_meta", + execute_tool_name="execute_meta", + execute_description="Custom execute description", + ) + ) + + listed_tools = await mcp.list_tools(run_middleware=False) + by_name = {tool.name: tool for tool in listed_tools} + + assert by_name["execute_meta"].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_tools = await mcp.list_tools(run_middleware=False) + by_name = {tool.name: tool for tool in listed_tools} + + execute_description = by_name["execute"].description or "" + + assert "single block" in execute_description + assert "Use `return` to produce output." in execute_description + assert ( + "Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope." + in execute_description + ) + + +async def test_code_mode_search_returns_matching_tools() -> None: + 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"}) + tools = _unwrap_search_results(result) + assert len(tools) > 0 + assert tools[0]["name"] == "square" + + +async def test_code_mode_search_results_include_schema() -> None: + mcp = FastMCP("CodeMode Output Schema") + + @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, "search", {"query": "square"}) + tools = _unwrap_search_results(result) + assert len(tools) > 0 + tool_dict = tools[0] + assert "inputSchema" in tool_dict + + +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"}) + tools = _unwrap_search_results(result) + assert tools == [] + + +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"}) + tools = _unwrap_search_results(result) + assert tools == [] + + +async def test_code_mode_shadows_colliding_tool_names() -> None: + """Backend tools with the same name as meta-tools are shadowed, not rejected.""" + 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 tool_names == {"search", "execute"} + + result = await _run_tool( + mcp, "execute", {"code": 'return await call_tool("ping", {})'} + ) + assert _unwrap_result(result) == {"result": "pong"} + + +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_monty_provider_raises_informative_error_when_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = MontySandboxProvider(install_hint="fastmcp[code-mode]") + 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_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_execute_default_arguments_overridden_by_explicit() -> None: + """Explicit params in call_tool() override default_arguments.""" + mcp = FastMCP("CodeMode Override") + + @mcp.tool + def greet(name: str, greeting: str) -> str: + return f"{greeting}, {name}!" + + mcp.add_transform( + CodeMode( + default_arguments={"greeting": "Hello"}, + sandbox_provider=_UnsafeTestSandboxProvider(), + ) + ) + + result = await _run_tool( + mcp, + "execute", + {"code": "return await call_tool('greet', {'name': 'World'})"}, + ) + assert _unwrap_result(result) == {"result": "Hello, World!"} + + result = await _run_tool( + mcp, + "execute", + { + "code": "return await call_tool('greet', {'name': 'World', 'greeting': 'Hi'})" + }, + ) + assert _unwrap_result(result) == {"result": "Hi, World!"} + + +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" + + 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" + + +async def test_code_mode_sandbox_error_surfaces_as_tool_error() -> None: + """Runtime errors in sandbox code surface as ToolError.""" + 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')"}) + + +def test_code_mode_rejects_identical_tool_names() -> None: + """CodeMode raises ValueError when search and execute names collide.""" + with pytest.raises(ValueError, match="must be different"): + CodeMode( + search_tool_name="tools", + execute_tool_name="tools", + sandbox_provider=_UnsafeTestSandboxProvider(), + ) diff --git a/uv.lock b/uv.lock index f52ae1ccb..c45b5e333 100644 --- a/uv.lock +++ b/uv.lock @@ -789,6 +789,9 @@ anthropic = [ azure = [ { name = "azure-identity" }, ] +code-mode = [ + { name = "pydantic-monty" }, +] openai = [ { name = "openai" }, ] @@ -800,7 +803,7 @@ tasks = [ dev = [ { name = "dirty-equals" }, { name = "fastapi" }, - { name = "fastmcp", extra = ["anthropic", "azure", "openai", "tasks"] }, + { name = "fastmcp", extra = ["anthropic", "azure", "code-mode", "openai", "tasks"] }, { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -844,6 +847,7 @@ requires-dist = [ { name = "platformdirs", specifier = ">=4.0.0" }, { name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], specifier = ">=0.4.4,<0.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, + { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = ">=0.0.7" }, { name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.17.2" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, @@ -853,13 +857,13 @@ requires-dist = [ { name = "watchfiles", specifier = ">=1.0.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] -provides-extras = ["anthropic", "azure", "openai", "tasks"] +provides-extras = ["anthropic", "azure", "code-mode", "openai", "tasks"] [package.metadata.requires-dev] dev = [ { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "fastapi", specifier = ">=0.115.12" }, - { name = "fastmcp", extras = ["anthropic", "azure", "openai", "tasks"] }, + { name = "fastmcp", extras = ["anthropic", "azure", "code-mode", "openai", "tasks"] }, { name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" }, { name = "ipython", specifier = ">=8.12.3" }, { name = "loq", specifier = ">=0.1.0a3" }, @@ -1986,6 +1990,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pydantic-monty" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/e3/0d8b2b025628477c839f894e632f5197872b19df0a86b2ec30fac3b5960a/pydantic_monty-0.0.7.tar.gz", hash = "sha256:2189ea1d7aadab2f95374733d692f51d1206379a4fc7ce18ab46895512e88f92", size = 684705, upload-time = "2026-02-19T14:12:47.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/25/4f923b64c6d52e2de788b21e20feb6a1acd0063419751a4cd843aa94c3de/pydantic_monty-0.0.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:8a8c133e83dcea584c17d4a18f4370d702f28c030b24be4aa033a8d094212b46", size = 6265462, upload-time = "2026-02-19T14:13:08.332Z" }, + { url = "https://files.pythonhosted.org/packages/ab/3f/6171caec0df775992b8aa4bbb862939e63fb49cab50db4c5329afa96590b/pydantic_monty-0.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:712c8db9fa80e6695aa5a829d5ed570f00bec071fe73cf32ed20d8ad7c21f83e", size = 6158093, upload-time = "2026-02-19T14:12:51.337Z" }, + { url = "https://files.pythonhosted.org/packages/4e/41/cd683e3546a32a489c1a43bc1d82653c4b62690525209bea23c9e9b6f986/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be373ba4acfcdd2047215f0675afb0cae2942ebd91f4d7f27a11ed224510432", size = 6060244, upload-time = "2026-02-19T14:14:36.326Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/326a61cb35b2a26e6a1f640359f91522c8ab4868d9d9fd81ec9fe846e29a/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98446b6cf32de3b1ad9e84b101f3db34a8799af35c95643068587d596f06b558", size = 6311667, upload-time = "2026-02-19T14:13:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3a/67d811f258a3ccff2ab2e2669792294ff47e875e0c305536adb19c68ee74/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b725086fb5c4b7205cf85b243c19596bb53cd24c35764f09a55454a23c1f0e37", size = 6861045, upload-time = "2026-02-19T14:13:06.35Z" }, + { url = "https://files.pythonhosted.org/packages/35/88/27cf345219b9504c58da2c53e2280fbac2b353d0c0afe7ab9d34d3ca0701/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94a43fcdfe607f25a0ba822f325610ca7018d73de4836c3eca27dac2a23fec7a", size = 6868099, upload-time = "2026-02-19T14:14:22.68Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ef/9ff941e2357d8ecdc577c999995055e42e9c382a13d72eff594a21d2c285/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b123fd3ef7d4b228381678fc3292b377e2a1f729a52beb31358313b49e2dcdb", size = 6641962, upload-time = "2026-02-19T14:14:43.196Z" }, + { url = "https://files.pythonhosted.org/packages/2e/67/a63b73e5e0d5434e388b5c74eb7f11191afe44ccc8a4cbd6055edc708aa9/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c61bc830acc85a4ca875903885eaa17322e8289d53e4ca6abe6a15a4a7998bd5", size = 6690453, upload-time = "2026-02-19T14:13:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f6/f16238540591b3bb829925f2911bd4a257da2ac5c39acaeef5f70cac8ee6/pydantic_monty-0.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a6b71a66837ca73e107e8bf31e1a715a7fe84b2d3d3b5e760bfeb206f00e68f6", size = 6236989, upload-time = "2026-02-19T14:14:17.242Z" }, + { url = "https://files.pythonhosted.org/packages/9a/21/0ff2e16249157a64b60e359e4d35c82559829e281bbd1ba42693af143136/pydantic_monty-0.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b8f9221339ff3ba95ded5f69760f974fdffdb41f19b5234b5ccf19b183667849", size = 6671096, upload-time = "2026-02-19T14:13:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/f4/53/8545e1c059225304938cf07d29aead0c7617ae05c6b2040957cd8f039a84/pydantic_monty-0.0.7-cp310-cp310-win32.whl", hash = "sha256:c6f2b930cdaac5dafca862813c3cf192071d93d3e1f15f952902dccec106b1d1", size = 6134622, upload-time = "2026-02-19T14:12:53.517Z" }, + { url = "https://files.pythonhosted.org/packages/0b/de/e7247a58787f0001f5119cc385f8f3868e7850b5342974534778dbaf900d/pydantic_monty-0.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:8e0fefba589a443538d4df90b2bfb4a23722bd838146f3419276f22f665aa2e0", size = 6694493, upload-time = "2026-02-19T14:14:30.997Z" }, + { url = "https://files.pythonhosted.org/packages/83/30/7f9432e9923b9b60c732364d75bce7ea4b22a6a18c6d7345078dac27d6e2/pydantic_monty-0.0.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:1ff6197ef4fd71e5f03c4b8d4228e387705fcad3798b1ea19c23594f7ea26660", size = 6265108, upload-time = "2026-02-19T14:13:31.46Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/ab7d83cf4dc0f9ac075d3edcee897f71a1efdd7659570a74abe1780cb769/pydantic_monty-0.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:649209a0a401969e86ac46014e8967636a7f5c4dbccd3bd22134eba8c3a7be6e", size = 6156865, upload-time = "2026-02-19T14:13:57.05Z" }, + { url = "https://files.pythonhosted.org/packages/98/f9/9471a56881ba8b2b87dc6bc274194fb3e0d58ad61d1ee17a6427690c951e/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:300dc5dcaae167f61540037cc5cb66ee63edb39298e04807472afd6f14c7d4cf", size = 6059238, upload-time = "2026-02-19T14:14:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/10/6a/99c6d9eadf38e64d7b198becf481642f1352882b79d12342d29f4984098e/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6392ab97cdd1290e6dfb7bdb13c328df75b08b3a25aa2a6f84b117c8f3688428", size = 6310805, upload-time = "2026-02-19T14:14:40.343Z" }, + { url = "https://files.pythonhosted.org/packages/11/9d/327dc638a17f1aefee843743382f0aa32efce297d6a43011025a1fc9fa7d/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac3a30019322d328ac6217c6bdbbe7c17b24fd2dd1015b4c334094213445cd30", size = 6859696, upload-time = "2026-02-19T14:14:29.198Z" }, + { url = "https://files.pythonhosted.org/packages/3f/38/ae57ee792a30c1421cdb84f738573c883989215658a590010eb8012139e6/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242d757c42ad53bab2be676588dd5b301e2e77420e79cc6f009256bf3b76472e", size = 6867309, upload-time = "2026-02-19T14:12:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/19/a2/b6bac68b3b100089dda7d45456260d73563ca76775bb83298120f2666853/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c9edcec7e6543bc1188a299debb706a47261b28cfb6157ec8181f70a463f735", size = 6640874, upload-time = "2026-02-19T14:12:49.002Z" }, + { url = "https://files.pythonhosted.org/packages/3d/04/cf4847759d9f5069daad414266502c42e59b2c93b496e76478afce861121/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e1b2f45bbeb09f68f99fd301fcd370f7b03152f2935a5c65533c86c11b9377eb", size = 6690344, upload-time = "2026-02-19T14:13:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e9/b3bcd311599e01c66d7821798ad926e51e02569ddeb57506c5db2df85cb0/pydantic_monty-0.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8275e24a44c43c0125c5d558ead78f750d891d6a11995bf750eabb0781086a40", size = 6235416, upload-time = "2026-02-19T14:13:16.188Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/b545fd3f3d473e47a081815b3521bab36f68788b3d921b4b03de648d4986/pydantic_monty-0.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1910fb89e52a2b3b8413cc4c30d3bc353f1a8a622e67e6d0c1030c5dc86931c", size = 6670652, upload-time = "2026-02-19T14:14:03.149Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fe/e209b45ece1cdce88dad03033497e0f49525d348f5c5a2e60f7d6d78da1b/pydantic_monty-0.0.7-cp311-cp311-win32.whl", hash = "sha256:899f68f6b3a4808a6d73292f819cad85f50a4301b018322d2ce3473488c56959", size = 6133507, upload-time = "2026-02-19T14:12:55.433Z" }, + { url = "https://files.pythonhosted.org/packages/1d/70/6b8f6b0427b7c1bf5c1d6bffcf68c5f2902c8f31304eaf0ebed5b2d4d475/pydantic_monty-0.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:12456fbf22df015a78abcdb9f8bd1f8227673a93e781263ae9b9c6e2524b5cd6", size = 6693590, upload-time = "2026-02-19T14:14:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/ca0884108c3237bb15bb2a1b3f24ddd957b9c750f1ed3211801497941999/pydantic_monty-0.0.7-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f35e18f284524d26d5f27084e2b93eb40139055bbf0cab6221a043eb5e9ce2dc", size = 6264252, upload-time = "2026-02-19T14:13:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5b/31f70c7792a857bacbdce90b8aae4629c31a9fec35f0116d91a2fb53241b/pydantic_monty-0.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e5d8924c65bb1ced60785a156e28c73f7f79f164b4f090dc26312c3917ffff7", size = 6133285, upload-time = "2026-02-19T14:14:46.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/c92216c0427e8a10a01fa98f29252f6fabd8ca80ca193e0fd30fe28e65c1/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6279a468469d5a3b80d94dd0ab6110cd291a1dfbb057fa7d6dbad1f499be855d", size = 6059856, upload-time = "2026-02-19T14:13:02.594Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a7/bc3e67b12d8a9da65f2677d9a48bc1e055a1d853d48572ba4845a64075cf/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb5feb69a5902d059db5dab269f90423b85a22163668422be47ccac8d7f7c44a", size = 6313780, upload-time = "2026-02-19T14:13:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c8/97/a9b856b17ee1e54892dafbb7ea29305520cae2dcd8aafe82a26a0edbc33c/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f1913e9729aa6711092ecbfce764df199a4787fe3e23a7ed74c78bf846579e", size = 6856827, upload-time = "2026-02-19T14:13:40.836Z" }, + { url = "https://files.pythonhosted.org/packages/98/57/2d8184b9f5a0b2b3bb47fdad7061c6a182699824efe6de9d8dd19ee68c0a/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec528f9f4194e6298757ad99e25da47f06f49ae2bc176ee26f49da5eb1dd7849", size = 6870737, upload-time = "2026-02-19T14:13:53.602Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b3/fe3d3eff82b41e517739841a492d7a48ea2daf8e7b822299b848b5d4c0aa/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75405b9186a9acfa49cd66aa339b5a2450d733a2d59fae20cc8be45e45204d5f", size = 6611843, upload-time = "2026-02-19T14:13:49.957Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d2/fdd8fe135ea14e30b40adadc896dda6c805596688998c9bcdfd8d85a16cf/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1917e42fce4733f92f5f6ad64ae4d0e87abbf9fb284ef52589ac3e292e928bfe", size = 6692856, upload-time = "2026-02-19T14:13:00.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a6/fdde6f8d76aa0cff4b53060b63d7f09dbb19da61a967cb4b3dfd972acf1c/pydantic_monty-0.0.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d72a5d4f3ee7f9d2630b0379e4cfb397e181eb0b16e8c49a03f80ba6471edb89", size = 6236587, upload-time = "2026-02-19T14:14:32.602Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/0580bbc001a39252b2f7da4b7504ac10572e4ca0ec967aebc5a9d752b6f7/pydantic_monty-0.0.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20028220981516912f130986354ef6c926b98778146ca349560cb852e44d9ca6", size = 6672260, upload-time = "2026-02-19T14:13:26.527Z" }, + { url = "https://files.pythonhosted.org/packages/d8/53/578a7b781a5714db5c4b1989c6e876d30caa0adf8a5a4caad89abc306667/pydantic_monty-0.0.7-cp312-cp312-win32.whl", hash = "sha256:e28b1c3ed52892f8ac12ee0f2b535402dfe1cb1e5c18128f1cb69eb8b66c285a", size = 6131085, upload-time = "2026-02-19T14:14:13.296Z" }, + { url = "https://files.pythonhosted.org/packages/56/98/20bd45fcd472937b1b3438b7587e209e3cfd447c30d02f654b86b44adaad/pydantic_monty-0.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:031dfab63ff9d7acdc641852e0d822603038cef1c27c5060900b9fd51cc853d0", size = 6664431, upload-time = "2026-02-19T14:13:29.968Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/d8cb6c30d9d7bcc7d3c8d2c349a227e2a83cd1fbe7182f4941896eb35443/pydantic_monty-0.0.7-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:55d36818f8e35872ed35e395b41df8acc460bcdbbfd471fe0c39e293a1d50db5", size = 6262596, upload-time = "2026-02-19T14:14:15.2Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/f6b4881ca9779bd87eb8d8c0823133b56c232cf09d765c07a7f91d641490/pydantic_monty-0.0.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:acb437458d93d54a9658656545fb6c9b396dbe66f68633b3c57bfd2f4aa1d400", size = 6133793, upload-time = "2026-02-19T14:14:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/39/b9/dfcffd95ff233b8c98db9254242d9c10190989762016d18509aa04d43b1b/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b196345ffa1997041cb870ea693148feafe270575e2c2963532eedde0e84dedf", size = 6059400, upload-time = "2026-02-19T14:13:38.953Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2e/d6ecef842024267ddf4128613342b8985a7444e74f3a4a312713c913a91a/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d471e3cfe77d62edaf43b7f0962b95270ee4243abea12cb8e8cf1ad972dc3612", size = 6312625, upload-time = "2026-02-19T14:13:36.901Z" }, + { url = "https://files.pythonhosted.org/packages/75/2e/e4a2a9fbc3640bcee15b80c2f8ba0f97bf989c58c01d6da187524f71d12b/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a67771afd385579bf3f894ce933fb9e467fba9a632ccf27246e271d448f6f5f", size = 6859902, upload-time = "2026-02-19T14:14:26.67Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/7aaf5c793f52e3403892a2de1f5dd18ae38234d82111cc9b7d92443e5b0d/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf662e1bbee4ddd318d5b8bfa9233173045029be0f67f15f816955b246bb7ec0", size = 6870524, upload-time = "2026-02-19T14:13:28.208Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a9/c16f078864a273460923f1371b769c2719e1ce1ad86bc9031e3ed7fb3eae/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0151ff59a8a0d9e29ddb448affa33943108121e6e324795646a2f5facaf1a5d8", size = 6611960, upload-time = "2026-02-19T14:14:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/20/d3/b3ef3432558a8cc9551d8b80a028a0f51cd2a518275932e03359eac3dc39/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:45f17a65134d3a0031e1f54d143770699f6a0ce92a1e74f0ae4914e52370f058", size = 6691834, upload-time = "2026-02-19T14:13:34.661Z" }, + { url = "https://files.pythonhosted.org/packages/ef/56/1ab5d1cbc0edfb522f0c28c9f5a7fc74eea6355234f73833087524d034bf/pydantic_monty-0.0.7-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ede6e68cb8a1f7216e26b0b2fb6cd0eae7a92104be8a49d1042e9e428de9262b", size = 6235704, upload-time = "2026-02-19T14:13:48.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/cdebae67b0543f696ed7daff8587dc8a458e6552b52d5877cb8e55be74b4/pydantic_monty-0.0.7-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:4b2fd51eea05a0cc37bb91f326efdb1acbcd4b8262dac1c55aeb208e51254978", size = 6671530, upload-time = "2026-02-19T14:12:56.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/53/c0dacaec260b71050fd6b31d09570f9d74bbb2a2e9586032694e92b9fa59/pydantic_monty-0.0.7-cp313-cp313-win32.whl", hash = "sha256:40f2092970c5899ac2a2784d712a4c7e194b33cd0133315254e4baf141cd6c93", size = 6130341, upload-time = "2026-02-19T14:14:09.701Z" }, + { url = "https://files.pythonhosted.org/packages/3e/05/31490a7a899d8bbb2e513630ea6f591ceb8a111c91fe7573a96c9f6b6327/pydantic_monty-0.0.7-cp313-cp313-win_amd64.whl", hash = "sha256:42cee2646415bb9bd7da428d169783203618a418f960c6f75c7a74d6946d6b31", size = 6664341, upload-time = "2026-02-19T14:14:19.008Z" }, + { url = "https://files.pythonhosted.org/packages/99/15/64aff358df0b822dd22f212fec501e3944edffe978a4ab05530ea641dc68/pydantic_monty-0.0.7-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c97b2e1dcd0126417892595c1da724a8c4348f7dcec26ba774117bd51bde46f8", size = 6266090, upload-time = "2026-02-19T14:13:12.364Z" }, + { url = "https://files.pythonhosted.org/packages/64/90/7b5a4292eb9993eb8be9d958b5a57764818eeda471e3e79be7da4e9b49ba/pydantic_monty-0.0.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6f40e6e133b309ba733874f5980ab6cf867ec8cea2a6a389a641819cd8dcb7cd", size = 6152219, upload-time = "2026-02-19T14:13:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/2740af0157eb3c6f10c16b0d8376b8c9cf0b910720fe90229885bccb4a91/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57f2a327b6aa7402a2b2c3ddb3964bd45f12597f8f950dd4c5905b843b353b73", size = 6060942, upload-time = "2026-02-19T14:12:45.601Z" }, + { url = "https://files.pythonhosted.org/packages/f6/26/1cf235c2cc8e219a94ed8b11151280ba89e8020a475b6280c89e62f7275f/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:771f1f158af0de2480ea2a8862e4c0c7f79e9a13cc3e17529002e1abfc077f95", size = 6315477, upload-time = "2026-02-19T14:14:11.314Z" }, + { url = "https://files.pythonhosted.org/packages/b8/41/0faca7b9d8868822b7177ae941f193f397479bb114d3a6396466167a3198/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a4182312ee8c26d8834e76375b2b5c766cb5d86d1dcf1515aa95e02704fcad83", size = 6862130, upload-time = "2026-02-19T14:13:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/34/7b/0f2bd4105a285f50f17af721e83a76ebc1186a9f07a2a29d6d576490a232/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8751696fe66fb1bdd429d98fde3a7f4b7dce9cb45f22095e28b96b690a422a2c", size = 6872292, upload-time = "2026-02-19T14:14:08.078Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/4380820d62d348afb1355814ea674788e28db7a73108a486e0b8898987de/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b73cd1137bb4fd9bf95ed5f87e48d960f7d556c30eeafde6996f908abbf183", size = 6636567, upload-time = "2026-02-19T14:14:01.464Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/29e5f89a558a6409d514bb2790c72442ec65804cbf1df6a870bbc0038673/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1fa45a66757de5ea45c0809e15643bc2521959dbe2bc231694b22fa189decc9", size = 6693896, upload-time = "2026-02-19T14:14:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/f6/26/5886d0f57ddb5ddf766ee2d0a4b3032267be9efd49a7c95c4d87a0b4b6a9/pydantic_monty-0.0.7-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ffe8122db9f0f64619a66f4cee2f577245ba59158b7d651a2eae08df691d34f9", size = 6236867, upload-time = "2026-02-19T14:14:20.704Z" }, + { url = "https://files.pythonhosted.org/packages/b5/72/1bb8741baf84f217d92291b862f8a8cb64d735fe4be20be2827fdf787593/pydantic_monty-0.0.7-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2122a5b6df53843329af01f6671300747449608d9cdf88ae5f9cf9977794e7a2", size = 6673504, upload-time = "2026-02-19T14:14:24.517Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/a4ff2bfe46350ffde4b5edc1f293b252cff90063f1f4cece49affe5a6462/pydantic_monty-0.0.7-cp314-cp314-win32.whl", hash = "sha256:bfbea2eddb9eef186326a6dfb27d79f8de434d7a3979f36f03b04216234a0275", size = 6131872, upload-time = "2026-02-19T14:13:14.437Z" }, + { url = "https://files.pythonhosted.org/packages/60/1f/d873f280aae5cbd27021189843fbd5f77be4262a7654ef30445d984518ab/pydantic_monty-0.0.7-cp314-cp314-win_amd64.whl", hash = "sha256:1b750afceef78f5c5d3e3e3c32a8060b3a7e1b97e3a00ac2bede5bc5e87cde8f", size = 6687125, upload-time = "2026-02-19T14:13:04.317Z" }, +] + [[package]] name = "pydantic-settings" version = "2.13.1" @@ -2713,8 +2785,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup" }, - { name = "typing-extensions" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [ From 2d1fe3ec0fe00e0b567a392c6e6043e132dc8c0a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:17:31 -0500 Subject: [PATCH 21/61] Narrate search example clients (#3321) * Narrate search example clients for screenshot-readability * Show actual call_tool invocation in result panels --- examples/search/README.md | 49 +------------- examples/search/client_bm25.py | 109 +++++++++++++++++++++++-------- examples/search/client_regex.py | 110 ++++++++++++++++++++++++-------- examples/search/server_bm25.py | 4 +- 4 files changed, 171 insertions(+), 101 deletions(-) diff --git a/examples/search/README.md b/examples/search/README.md index 253b196c6..32a26390d 100644 --- a/examples/search/README.md +++ b/examples/search/README.md @@ -14,53 +14,8 @@ Both strategies respect the full auth pipeline: middleware, visibility transform ```bash # Regex -uv run python server_regex.py # in one terminal -uv run python client_regex.py # in another +uv run python examples/search/client_regex.py # BM25 -uv run python server_bm25.py -uv run python client_bm25.py +uv run python examples/search/client_bm25.py ``` - -## Example Output (Regex) - -``` -=== Available Tools === - - search_tools: Search for tools matching a regex pattern. - - call_tool: Call a tool by name with the given arguments. - -=== Search: math tools (pattern: 'add|multiply|fibonacci') === - - add: Add two numbers together. - - multiply: Multiply two numbers. - - fibonacci: Generate the first n Fibonacci numbers. - -=== Search: text tools (pattern: 'text|string|word') === - - reverse_string: Reverse a string. - - word_count: Count the number of words in a text. - - to_uppercase: Convert text to uppercase. - -=== Calling 'add' via call_tool === - Result: 42 -``` - -## Example Output (BM25) - -``` -=== Available Tools === - - list_files: List files in a directory. - - search_tools: Search for tools using natural language. - - call_tool: Call a tool by name with the given arguments. - -=== Search: 'work with numbers' === - - multiply: Multiply two numbers. - - add: Add two numbers together. - - fibonacci: Generate the first n Fibonacci numbers. - -=== Search: 'file operations' === - - read_file: Read the contents of a file. - -=== Calling 'word_count' via call_tool === - Result: 6 -``` - -Note how `list_files` appears in the BM25 example's tool listing β€” it's pinned via `always_visible=["list_files"]`, keeping it visible alongside the synthetic search tools. diff --git a/examples/search/client_bm25.py b/examples/search/client_bm25.py index a1b67cbda..b33e395a3 100644 --- a/examples/search/client_bm25.py +++ b/examples/search/client_bm25.py @@ -9,30 +9,55 @@ Run with: import asyncio import json +from typing import Any from rich.console import Console from rich.panel import Panel from rich.table import Table -from rich.text import Text from fastmcp.client import Client console = Console() -def _get_text(result) -> str: - """Extract text content from a CallToolResult.""" +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 _tool_table(tools: list[dict], *, ranked: bool = False) -> Table: +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"], tool.get("description", "")] + 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) @@ -45,35 +70,66 @@ async def main(): console.rule("[bold]BM25 Search Transform[/bold]") console.print() - # list_files is pinned via always_visible + # 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]", - subtitle="[dim]list_files pinned via always_visible[/dim]", + title_align="left", border_style="blue", ) ) console.print() - # Natural language searches β€” BM25 ranks by relevance - queries = ["work with numbers", "manipulate text strings", "file operations"] - for query in queries: - result = await client.call_tool("search_tools", {"query": query}) - found = json.loads(_get_text(result)) - console.print( - Panel( - _tool_table(found, ranked=True), - title=f'[bold]search_tools[/bold][dim](query="{query}")[/dim]', - subtitle=f"[dim]{len(found)} result{'s' if len(found) != 1 else ''}[/dim]", - border_style="green", - ) + # 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() + ) + console.print() - # Call a discovered tool + 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", { @@ -81,11 +137,14 @@ async def main(): "arguments": {"text": "BM25 search makes tool discovery easy"}, }, ) - call_label = Text.assemble( - ("call_tool", "bold"), - ('(word_count, text="BM25 search makes tool discovery easy")', "dim"), + 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(call_label, "β†’", f"[bold green]{_get_text(result)}[/bold green]") console.print() diff --git a/examples/search/client_regex.py b/examples/search/client_regex.py index 6bf17de63..ccccefbd2 100644 --- a/examples/search/client_regex.py +++ b/examples/search/client_regex.py @@ -1,7 +1,7 @@ """Example: Client using regex search to discover and call tools. -Demonstrates the workflow: list tools (sees only search_tools + call_tool), -search for tools matching a regex pattern, then call a discovered tool. +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 @@ -9,28 +9,58 @@ Run with: import asyncio import json +from typing import Any from rich.console import Console from rich.panel import Panel from rich.table import Table -from rich.text import Text from fastmcp.client import Client console = Console() -def _get_text(result) -> str: - """Extract text content from a CallToolResult.""" +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 _tool_table(tools: list[dict]) -> Table: +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 tool in tools: - table.add_row(tool["name"], tool.get("description", "")) + 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 @@ -40,64 +70,90 @@ async def main(): console.rule("[bold]Regex Search Transform[/bold]") console.print() - # Show what the client actually sees + # 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]", - subtitle="[dim]real tools are discoverable via search[/dim]", + title_align="left", border_style="blue", ) ) console.print() - # Search for math tools + # 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 = json.loads(_get_text(result)) + found = _get_result(result) + if isinstance(found, str): + found = json.loads(found) console.print( Panel( - _tool_table(found), - title='[bold]search_tools[/bold][dim](pattern="add|multiply|fibonacci")[/dim]', + _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() - # Search for text tools result = await client.call_tool("search_tools", {"pattern": "text|string|word"}) - found = json.loads(_get_text(result)) + found = _get_result(result) + if isinstance(found, str): + found = json.loads(found) console.print( Panel( - _tool_table(found), - title='[bold]search_tools[/bold][dim](pattern="text|string|word")[/dim]', + _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() - # Call discovered tools via the proxy + # 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}} ) - call_label = Text.assemble( - ("call_tool", "bold"), - ("(add, a=17, b=25)", "dim"), + 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(call_label, "β†’", f"[bold green]{_get_text(result)}[/bold green]") + console.print() result = await client.call_tool( "call_tool", {"name": "reverse_string", "arguments": {"text": "hello world"}}, ) - call_label = Text.assemble( - ("call_tool", "bold"), - ('(reverse_string, text="hello world")', "dim"), + 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(call_label, "β†’", f"[bold green]{_get_text(result)}[/bold green]") console.print() diff --git a/examples/search/server_bm25.py b/examples/search/server_bm25.py index 9491d185f..63cdf8048 100644 --- a/examples/search/server_bm25.py +++ b/examples/search/server_bm25.py @@ -12,6 +12,8 @@ Run with: uv run python examples/search/server_bm25.py """ +import os + from fastmcp import FastMCP from fastmcp.server.transforms.search import BM25SearchTransform @@ -62,8 +64,6 @@ def to_uppercase(text: str) -> str: @mcp.tool def list_files(directory: str) -> list[str]: """List files in a directory.""" - import os - return os.listdir(directory) From 3ff1472ea955a9df793bedde0a184b161d68ae47 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:37:57 -0500 Subject: [PATCH 22/61] Add Prefab Apps integration for MCP tool UIs (#3316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add prefab auto-wiring for MCP Apps (#3119) Tools that return prefab types (UIResponse, Component) automatically get wired to the shared prefab renderer resource. Works via app=True, return type inference, or both. * Prefab compatibility updates * Use published prefab-ui >=0.6.0, remove local source override * Migrate UIResponse to PrefabApp for Prefab UI integration PrefabApp is a pure data object with to_json(), html(), and csp() methods. Tools can return PrefabApp, bare Components, or ToolResult with structured_content for custom LLM fallback text. * Add Prefab UI apps documentation * Add mini apps and full apps documentation pages Mini apps covers the common single-screen patterns: charts (bar, line, area, pie), data tables with sorting/search/pagination, forms (manual and Pydantic-generated), status displays, conditional content, and layout composition with tabs and accordions. Full apps covers multi-page applications using Pages/Page components, shared state across pages, and using ToolCall with result_key for server-driven state updates. * Reframe apps docs around motivation, add generative UIs page The docs now lead with the problem β€” MCP tools stuff data into the LLM context window, and building HTML/JS/CSS frontends is a non-starter for Python developers β€” before introducing Prefab as the solution. Mini apps are framed as the primary use case: focused, single-purpose UIs that present data visually and collect structured input. New generative UIs page covers the concept of LLMs producing component JSON directly, enabling adaptive dashboards, tailored forms, and exploratory workflows. * Tag Prefab docs pages as SOON instead of NEW * Rename Low-Level API to Custom HTML Apps The page is about using the MCP Apps extension directly, not a FastMCP or Prefab internal API. Reframed to make clear this is the open MCP protocol with FastMCP providing convenience wrappers. * Tighten apps docs and widen content area Strip editorial motivation from all app doc pages β€” let code examples do the talking. Add content-area max-width override (44rem) to style.css. * Restructure apps docs, fix code issues Rename Prefab UI β†’ Prefab Apps, mini-apps β†’ patterns, remove generative-uis and full-apps pages. Rewrite prefab page to lead with what users do (declare a UI, return it) before explaining internals. Patterns page now has fully self-contained copy-pasteable examples with explicit imports and links to prefab docs. Forms show the two-tool pattern (form + handler). Add patterns_server.py example. Code fixes: move get_args to module-level import, remove dead AuthCheckCallable type alias, fix ToolCallβ†’CallTool in all docs. * Remove unused ToolResult import from chart_server * Handle composite Prefab types in type inference and schema suppression _has_prefab_return_type and the output schema suppression logic only checked bare classes, missing unions (Column | None) and Annotated wrappers (Annotated[PrefabApp | None, ...]). Recurse through Union, types.UnionType, and Annotated to detect Prefab types in composite annotations. --- docs/apps/low-level.mdx | 10 +- docs/apps/overview.mdx | 63 ++- docs/apps/patterns.mdx | 479 +++++++++++++++++ docs/apps/prefab.mdx | 191 +++++++ docs/css/style.css | 4 + docs/docs.json | 2 + examples/apps/chart_server.py | 102 ++++ examples/apps/datatable_server.py | 165 ++++++ examples/apps/patterns_server.py | 489 ++++++++++++++++++ pyproject.toml | 16 +- src/fastmcp/resources/types.py | 12 +- src/fastmcp/server/apps.py | 4 +- .../local_provider/decorators/tools.py | 116 ++++- src/fastmcp/tools/function_parsing.py | 36 +- src/fastmcp/tools/function_tool.py | 4 + src/fastmcp/tools/tool.py | 33 ++ tests/test_apps_prefab.py | 431 +++++++++++++++ uv.lock | 24 +- 18 files changed, 2143 insertions(+), 38 deletions(-) create mode 100644 docs/apps/patterns.mdx create mode 100644 docs/apps/prefab.mdx create mode 100644 examples/apps/chart_server.py create mode 100644 examples/apps/datatable_server.py create mode 100644 examples/apps/patterns_server.py create mode 100644 tests/test_apps_prefab.py 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..f62296200 100644 --- a/docs/apps/overview.mdx +++ b/docs/apps/overview.mdx @@ -10,22 +10,63 @@ 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 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). -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. +```python +from prefab_ui.components import Column, Heading, BarChart, ChartSeries +from prefab_ui.app import PrefabApp +from fastmcp import FastMCP -## What's Coming in 3.1 +mcp = FastMCP("Dashboard") -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.tool(app=True) +def sales_chart(year: int) -> PrefabApp: + """Show sales data as an interactive chart.""" + data = get_sales_data(year) -Stay tuned. In the meantime, the [low-level API](/apps/low-level) is ready to use. + 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..714d7e247 --- /dev/null +++ b/docs/apps/patterns.mdx @@ -0,0 +1,479 @@ +--- +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' + + + +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..88927a23a --- /dev/null +++ b/docs/apps/prefab.mdx @@ -0,0 +1,191 @@ +--- +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 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/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/docs.json b/docs/docs.json index 24422f592..66380d498 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -202,6 +202,8 @@ "group": "Apps", "pages": [ "apps/overview", + "apps/prefab", + "apps/patterns", "apps/low-level" ] }, 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/pyproject.toml b/pyproject.toml index f0299ba15..81d7cadb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ 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"] openai = ["openai>=1.102.0"] @@ -60,7 +61,7 @@ tasks = ["pydocket>=0.17.2"] [dependency-groups] dev = [ "dirty-equals>=0.9.0", - "fastmcp[anthropic,azure,code-mode,openai,tasks]", + "fastmcp[anthropic,apps,azure,code-mode,openai,tasks]", # add optional dependencies for fastmcp dev "fastapi>=0.115.12", "opentelemetry-sdk>=1.20.0", @@ -105,6 +106,7 @@ source = "uv-dynamic-versioning" [tool.hatch.metadata] allow-direct-references = true + [tool.uv-dynamic-versioning] vcs = "git" style = "pep440" @@ -191,18 +193,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/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/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/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/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/tests/test_apps_prefab.py b/tests/test_apps_prefab.py new file mode 100644 index 000000000..301141d05 --- /dev/null +++ b/tests/test_apps_prefab.py @@ -0,0 +1,431 @@ +"""Tests for MCP Apps Phase 2 β€” Prefab integration. + +Covers ``convert_result`` for PrefabApp/Component, ``app=True`` auto-wiring, +return-type inference, output-schema suppression, and end-to-end round trips. +""" + +from __future__ import annotations + +from typing import Annotated + +from mcp.types import TextContent +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading, Text +from prefab_ui.components.base import Component + +from fastmcp import Client, FastMCP +from fastmcp.resources.types import TextResource +from fastmcp.server.apps import UI_MIME_TYPE, AppConfig +from fastmcp.server.providers.local_provider.decorators.tools import ( + PREFAB_RENDERER_URI, +) +from fastmcp.tools.tool import Tool, ToolResult + +# --------------------------------------------------------------------------- +# convert_result +# --------------------------------------------------------------------------- + + +class TestConvertResult: + def test_prefab_app(self): + with Column() as view: + Heading("Hello") + app = PrefabApp(view=view, state={"name": "Alice"}) + + tool = Tool(name="t", parameters={}) + result = tool.convert_result(app) + + assert isinstance(result, ToolResult) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "[Rendered Prefab UI]" + assert result.structured_content is not None + assert result.structured_content["version"] == "0.2" + assert result.structured_content["state"] == {"name": "Alice"} + assert result.structured_content["view"]["type"] == "Column" + + def test_bare_component(self): + heading = Heading("World") + + tool = Tool(name="t", parameters={}) + result = tool.convert_result(heading) + + assert isinstance(result, ToolResult) + assert result.structured_content is not None + assert result.structured_content["version"] == "0.2" + assert result.structured_content["view"]["type"] == "Heading" + + def test_tool_result_with_prefab_structured_content(self): + """ToolResult with PrefabApp as structured_content preserves custom text.""" + app = PrefabApp(view=Heading("Hello"), state={"x": 1}) + + tool = Tool(name="t", parameters={}) + result = tool.convert_result( + ToolResult(content="Custom fallback text", structured_content=app) + ) + + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Custom fallback text" + assert result.structured_content is not None + assert result.structured_content["version"] == "0.2" + assert result.structured_content["view"]["type"] == "Heading" + + def test_tool_result_with_component_structured_content(self): + """ToolResult with bare Component as structured_content.""" + tool = Tool(name="t", parameters={}) + result = tool.convert_result( + ToolResult(content="My text", structured_content=Heading("Hi")) + ) + + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "My text" + assert result.structured_content is not None + assert result.structured_content["version"] == "0.2" + assert result.structured_content["view"]["type"] == "Heading" + + def test_tool_result_passthrough(self): + """ToolResult without prefab structured_content passes through unchanged.""" + original = ToolResult(content="hello") + tool = Tool(name="t", parameters={}) + assert tool.convert_result(original) is original + + +# --------------------------------------------------------------------------- +# app=True auto-wiring +# --------------------------------------------------------------------------- + + +class TestAppTrue: + def test_app_true_sets_meta(self): + mcp = FastMCP("test") + + @mcp.tool(app=True) + def my_tool() -> str: + return "hello" + + tools = mcp._local_provider._components + tool = next( + v + for v in tools.values() + if hasattr(v, "parameters") and v.name == "my_tool" + ) + assert tool.meta is not None + assert "ui" in tool.meta + assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI + + def test_app_true_registers_renderer_resource(self): + mcp = FastMCP("test") + + @mcp.tool(app=True) + def my_tool() -> str: + return "hello" + + renderer_key = f"resource:{PREFAB_RENDERER_URI}@" + assert renderer_key in mcp._local_provider._components + + def test_renderer_resource_has_correct_mime_type(self): + mcp = FastMCP("test") + + @mcp.tool(app=True) + def my_tool() -> str: + return "hello" + + renderer_key = f"resource:{PREFAB_RENDERER_URI}@" + resource = mcp._local_provider._components[renderer_key] + assert isinstance(resource, TextResource) + assert resource.mime_type == UI_MIME_TYPE + + def test_renderer_resource_has_csp(self): + mcp = FastMCP("test") + + @mcp.tool(app=True) + def my_tool() -> str: + return "hello" + + renderer_key = f"resource:{PREFAB_RENDERER_URI}@" + resource = mcp._local_provider._components[renderer_key] + assert resource.meta is not None + assert "ui" in resource.meta + assert "csp" in resource.meta["ui"] + + def test_multiple_tools_share_renderer(self): + mcp = FastMCP("test") + + @mcp.tool(app=True) + def tool_a() -> str: + return "a" + + @mcp.tool(app=True) + def tool_b() -> str: + return "b" + + renderer_keys = [ + k for k in mcp._local_provider._components if k.startswith("resource:ui://") + ] + assert len(renderer_keys) == 1 + + def test_explicit_app_config_not_overridden(self): + mcp = FastMCP("test") + + @mcp.tool(app=AppConfig(resource_uri="ui://custom/app.html")) + def my_tool() -> PrefabApp: + return PrefabApp(view=Heading("hi")) + + tools = mcp._local_provider._components + tool = next( + v + for v in tools.values() + if hasattr(v, "parameters") and v.name == "my_tool" + ) + assert tool.meta is not None + assert tool.meta["ui"]["resourceUri"] == "ui://custom/app.html" + + +# --------------------------------------------------------------------------- +# Return type inference +# --------------------------------------------------------------------------- + + +class TestInference: + def test_prefab_app_annotation_inferred(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> PrefabApp: + return PrefabApp(view=Heading("hi")) + + tools = mcp._local_provider._components + tool = next( + v + for v in tools.values() + if hasattr(v, "parameters") and v.name == "my_tool" + ) + assert tool.meta is not None + assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI + + def test_component_annotation_inferred(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> Component: + return Heading("hi") + + tools = mcp._local_provider._components + tool = next( + v + for v in tools.values() + if hasattr(v, "parameters") and v.name == "my_tool" + ) + assert tool.meta is not None + assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI + + def test_no_annotation_no_inference(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool(): + return "hello" + + tools = mcp._local_provider._components + tool = next( + v + for v in tools.values() + if hasattr(v, "parameters") and v.name == "my_tool" + ) + assert tool.meta is None or "ui" not in (tool.meta or {}) + + def test_non_prefab_annotation_no_inference(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> str: + return "hello" + + tools = mcp._local_provider._components + tool = next( + v + for v in tools.values() + if hasattr(v, "parameters") and v.name == "my_tool" + ) + assert tool.meta is None or "ui" not in (tool.meta or {}) + + def test_optional_prefab_app_inferred(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> PrefabApp | None: + return None + + tools = mcp._local_provider._components + tool = next( + v + for v in tools.values() + if hasattr(v, "parameters") and v.name == "my_tool" + ) + assert tool.meta is not None + assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI + + def test_annotated_prefab_app_inferred(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> Annotated[PrefabApp | None, "some metadata"]: + return None + + tools = mcp._local_provider._components + tool = next( + v + for v in tools.values() + if hasattr(v, "parameters") and v.name == "my_tool" + ) + assert tool.meta is not None + assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI + + def test_component_subclass_union_inferred(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> Column | None: + return None + + tools = mcp._local_provider._components + tool = next( + v + for v in tools.values() + if hasattr(v, "parameters") and v.name == "my_tool" + ) + assert tool.meta is not None + assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI + + +# --------------------------------------------------------------------------- +# Output schema suppression +# --------------------------------------------------------------------------- + + +class TestOutputSchema: + def test_prefab_app_return_no_output_schema(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> PrefabApp: + return PrefabApp(view=Heading("hi")) + + tools = mcp._local_provider._components + tool: Tool = next( + v for v in tools.values() if isinstance(v, Tool) and v.name == "my_tool" + ) + assert tool.output_schema is None + + def test_component_return_no_output_schema(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> Column: + with Column() as view: + Heading("hi") + return view + + tools = mcp._local_provider._components + tool: Tool = next( + v for v in tools.values() if isinstance(v, Tool) and v.name == "my_tool" + ) + assert tool.output_schema is None + + def test_optional_component_no_output_schema(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> Column | None: + return None + + tools = mcp._local_provider._components + tool: Tool = next( + v for v in tools.values() if isinstance(v, Tool) and v.name == "my_tool" + ) + assert tool.output_schema is None + + def test_annotated_prefab_app_no_output_schema(self): + mcp = FastMCP("test") + + @mcp.tool + def my_tool() -> Annotated[PrefabApp | None, "metadata"]: + return None + + tools = mcp._local_provider._components + tool: Tool = next( + v for v in tools.values() if isinstance(v, Tool) and v.name == "my_tool" + ) + assert tool.output_schema is None + + +# --------------------------------------------------------------------------- +# Integration β€” client-server round trip +# --------------------------------------------------------------------------- + + +class TestIntegration: + async def test_tool_call_returns_prefab_structured_content(self): + mcp = FastMCP("test") + + @mcp.tool(app=True) + def greet(name: str) -> PrefabApp: + with Column() as view: + Heading("Hello") + Text(f"Welcome, {name}!") + return PrefabApp(view=view, state={"name": name}) + + async with Client(mcp) as client: + result = await client.call_tool("greet", {"name": "Alice"}) + + assert result.structured_content is not None + assert result.structured_content["version"] == "0.2" + assert result.structured_content["state"] == {"name": "Alice"} + + async def test_tool_call_with_custom_text(self): + mcp = FastMCP("test") + + @mcp.tool(app=True) + def greet(name: str) -> ToolResult: + app = PrefabApp(view=Heading(f"Hello {name}")) + return ToolResult( + content=f"Greeting for {name}", + structured_content=app, + ) + + async with Client(mcp) as client: + result = await client.call_tool("greet", {"name": "Alice"}) + + assert any( + "Greeting for Alice" in c.text for c in result.content if hasattr(c, "text") + ) + assert result.structured_content is not None + assert result.structured_content["version"] == "0.2" + + async def test_tools_list_includes_app_meta(self): + mcp = FastMCP("test") + + @mcp.tool(app=True) + def my_tool() -> PrefabApp: + return PrefabApp(view=Heading("hi")) + + async with Client(mcp) as client: + tools = await client.list_tools() + + tool = next(t for t in tools if t.name == "my_tool") + meta = tool.meta or {} + assert "ui" in meta + assert meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI + + async def test_renderer_resource_readable(self): + mcp = FastMCP("test") + + @mcp.tool(app=True) + def my_tool() -> str: + return "hello" + + async with Client(mcp) as client: + contents = await client.read_resource(PREFAB_RENDERER_URI) + + assert len(contents) > 0 + text = contents[0].text if hasattr(contents[0], "text") else "" + assert " Date: Fri, 27 Feb 2026 21:18:44 -0500 Subject: [PATCH 23/61] Add resource limits to MontySandboxProvider --- .../experimental/transforms/code_mode.py | 22 +++++++++++++++---- .../experimental/transforms/test_code_mode.py | 17 +++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/fastmcp/experimental/transforms/code_mode.py b/src/fastmcp/experimental/transforms/code_mode.py index 439457099..826c90f8e 100644 --- a/src/fastmcp/experimental/transforms/code_mode.py +++ b/src/fastmcp/experimental/transforms/code_mode.py @@ -68,10 +68,22 @@ class SandboxProvider(Protocol): class MontySandboxProvider: - """Sandbox provider backed by `pydantic-monty`.""" + """Sandbox provider backed by `pydantic-monty`. - def __init__(self, *, install_hint: str = "fastmcp[code-mode]") -> None: - self.install_hint = install_hint + 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, @@ -85,7 +97,7 @@ class MontySandboxProvider: except ModuleNotFoundError as exc: raise ImportError( "CodeMode requires pydantic-monty for the Monty sandbox provider. " - f"Install it with `{self.install_hint}` or pass a custom SandboxProvider." + "Install it with `fastmcp[code-mode]` or pass a custom SandboxProvider." ) from exc inputs = inputs or {} @@ -102,6 +114,8 @@ class MontySandboxProvider: 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) diff --git a/tests/experimental/transforms/test_code_mode.py b/tests/experimental/transforms/test_code_mode.py index 1bad81528..7355b5586 100644 --- a/tests/experimental/transforms/test_code_mode.py +++ b/tests/experimental/transforms/test_code_mode.py @@ -335,7 +335,7 @@ async def test_code_mode_execute_non_text_content_stringified() -> None: async def test_monty_provider_raises_informative_error_when_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: - provider = MontySandboxProvider(install_hint="fastmcp[code-mode]") + provider = MontySandboxProvider() real_import_module = importlib.import_module def _fake_import_module(name: str, package: str | None = None): @@ -446,6 +446,21 @@ async def test_code_mode_sandbox_error_surfaces_as_tool_error() -> None: await _run_tool(mcp, "execute", {"code": "raise ValueError('boom')"}) +async def test_monty_provider_forwards_limits() -> None: + """MontySandboxProvider passes limits through to pydantic-monty.""" + 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: + """Without limits, a simple script completes normally.""" + provider = MontySandboxProvider() + result = await provider.run("return 1 + 2") + assert result == 3 + + def test_code_mode_rejects_identical_tool_names() -> None: """CodeMode raises ValueError when search and execute names collide.""" with pytest.raises(ValueError, match="must be different"): From 09a99e1ecca5cb05f95b5e87051884530099c499 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:33:45 -0500 Subject: [PATCH 24/61] Accept transforms as FastMCP init kwarg --- docs/servers/transforms/code-mode.mdx | 44 ++++++++++++------------- docs/servers/transforms/tool-search.mdx | 2 +- docs/servers/transforms/transforms.mdx | 4 +-- src/fastmcp/server/server.py | 4 +++ 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/servers/transforms/code-mode.mdx b/docs/servers/transforms/code-mode.mdx index 1073c3366..755e94991 100644 --- a/docs/servers/transforms/code-mode.mdx +++ b/docs/servers/transforms/code-mode.mdx @@ -8,7 +8,7 @@ tag: EXPERIMENTAL import { VersionBadge } from '/snippets/version-badge.mdx' - + 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. @@ -26,7 +26,7 @@ CodeMode requires a sandbox to execute LLM-generated code safely. The default sa from fastmcp import FastMCP from fastmcp.experimental.transforms import CodeMode -mcp = FastMCP("Server") +mcp = FastMCP("Server", transforms=[CodeMode()]) @mcp.tool def add(x: int, y: int) -> int: @@ -37,11 +37,6 @@ def add(x: int, y: int) -> int: def multiply(x: int, y: int) -> int: """Multiply two numbers.""" return x * y - -mcp.add_transform(CodeMode()) - -if __name__ == "__main__": - mcp.run() ``` Clients now see only two tools. The LLM discovers the real tools through `search`, then orchestrates them through `execute`: @@ -71,11 +66,9 @@ Search uses BM25 ranking by default, matching against tool names and description ```python from fastmcp.server.transforms.search import RegexSearchTransform -mcp.add_transform( - CodeMode( - search_transform=RegexSearchTransform(), - ) -) +mcp = FastMCP("Server", transforms=[ + CodeMode(search_transform=RegexSearchTransform()) +]) ``` ## Execute @@ -95,9 +88,10 @@ Use `return` to produce the final output from the script. When tools share common parameters (like workspace IDs or API keys), `default_arguments` injects them automatically: ```python -mcp.add_transform(CodeMode( - default_arguments={"workspace_id": "ws-123"} -)) +mcp = FastMCP( + "Server", + transforms=[CodeMode(default_arguments={"workspace_id": "ws-123"})], +) ``` Defaults are only injected when the tool actually accepts the parameter and the LLM hasn't provided it explicitly. Parameters that a tool doesn't accept are silently skipped. @@ -121,8 +115,7 @@ provider = OpenAPIProvider( client=api_client, ) -mcp = FastMCP("API Code Mode", providers=[provider]) -mcp.add_transform(CodeMode()) +mcp = FastMCP("API Code Mode", providers=[provider], transforms=[CodeMode()]) ``` ## Configuration @@ -132,11 +125,16 @@ mcp.add_transform(CodeMode()) The default `search` and `execute` names can be changed: ```python -mcp.add_transform(CodeMode( - search_tool_name="find_tools", - execute_tool_name="run_workflow", - execute_description="Run multi-step API workflows", -)) +mcp = FastMCP( + "Server", + transforms=[ + CodeMode( + search_tool_name="find_tools", + execute_tool_name="run_workflow", + execute_description="Run multi-step API workflows", + ) + ], +) ``` ### Custom Sandbox Providers @@ -160,7 +158,7 @@ class RemoteSandboxProvider: # Send code to your remote sandbox runtime ... -mcp.add_transform(CodeMode(sandbox_provider=RemoteSandboxProvider())) +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/tool-search.mdx b/docs/servers/transforms/tool-search.mdx index bc54ce051..5db4149f1 100644 --- a/docs/servers/transforms/tool-search.mdx +++ b/docs/servers/transforms/tool-search.mdx @@ -8,7 +8,7 @@ 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. diff --git a/docs/servers/transforms/transforms.mdx b/docs/servers/transforms/transforms.mdx index a3684c0a4..4008f86b4 100644 --- a/docs/servers/transforms/transforms.mdx +++ b/docs/servers/transforms/transforms.mdx @@ -81,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/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 2bdecf803..0dc47f143 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -227,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, @@ -275,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 From 14e64b3b22db1c70d098ba685b01baae707c4729 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:37:01 -0500 Subject: [PATCH 25/61] Use transforms= kwarg in docs examples --- docs/servers/transforms/code-mode.mdx | 7 ++++--- docs/servers/transforms/tool-search.mdx | 8 ++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/servers/transforms/code-mode.mdx b/docs/servers/transforms/code-mode.mdx index 755e94991..2a6db4921 100644 --- a/docs/servers/transforms/code-mode.mdx +++ b/docs/servers/transforms/code-mode.mdx @@ -66,9 +66,10 @@ Search uses BM25 ranking by default, matching against tool names and description ```python from fastmcp.server.transforms.search import RegexSearchTransform -mcp = FastMCP("Server", transforms=[ - CodeMode(search_transform=RegexSearchTransform()) -]) +mcp = FastMCP( + "Server", + transforms=[CodeMode(search_transform=RegexSearchTransform())], +) ``` ## Execute diff --git a/docs/servers/transforms/tool-search.mdx b/docs/servers/transforms/tool-search.mdx index 5db4149f1..204004f5c 100644 --- a/docs/servers/transforms/tool-search.mdx +++ b/docs/servers/transforms/tool-search.mdx @@ -37,7 +37,7 @@ FastMCP provides two search transforms. They share the same interface β€” two sy from fastmcp import FastMCP from fastmcp.server.transforms.search import RegexSearchTransform -mcp = FastMCP("My Server") +mcp = FastMCP("My Server", transforms=[RegexSearchTransform()]) @mcp.tool def search_database(query: str, limit: int = 10) -> list[dict]: @@ -53,8 +53,6 @@ def delete_record(record_id: str) -> bool: def send_email(to: str, subject: str, body: str) -> bool: """Send an email to the given recipient.""" ... - -mcp.add_transform(RegexSearchTransform()) ``` The LLM's `search_tools` call takes a `pattern` parameter β€” a regex string: @@ -79,11 +77,9 @@ Results are returned in catalog order. If the pattern is invalid regex, the sear from fastmcp import FastMCP from fastmcp.server.transforms.search import BM25SearchTransform -mcp = FastMCP("My Server") +mcp = FastMCP("My Server", transforms=[BM25SearchTransform()]) # ... define tools ... - -mcp.add_transform(BM25SearchTransform()) ``` The LLM's `search_tools` call takes a `query` parameter β€” natural language: From 18d5902367e5ede22b7ad47b0190c880b1b6dcf7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:26:27 -0500 Subject: [PATCH 26/61] Document transforms kwarg in server constructor reference --- docs/servers/server.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index b28985df9..ed84b5e48 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -67,7 +67,11 @@ 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 + + + 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 + How to handle duplicate component registrations From 75d6e2fc109d6886fb01d152e30af0bfb168cb95 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:25:40 -0500 Subject: [PATCH 27/61] Document resource limits for MontySandboxProvider --- docs/servers/transforms/code-mode.mdx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/servers/transforms/code-mode.mdx b/docs/servers/transforms/code-mode.mdx index 2a6db4921..df4e094ba 100644 --- a/docs/servers/transforms/code-mode.mdx +++ b/docs/servers/transforms/code-mode.mdx @@ -138,6 +138,30 @@ mcp = FastMCP( ) ``` +### Resource Limits + +The default `MontySandboxProvider` can enforce execution limits on sandboxed code β€” timeouts, memory caps, recursion depth, and more. Without limits, LLM-generated scripts can run indefinitely. + +```python +from fastmcp.experimental.transforms import CodeMode, MontySandboxProvider + +mcp.add_transform(CodeMode( + sandbox_provider=MontySandboxProvider( + limits={"max_duration_secs": 10, "max_memory": 50_000_000}, + ), +)) +``` + +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 The default `MontySandboxProvider` uses [pydantic-monty](https://github.com/pydantic/pydantic-monty) for sandboxed execution. You can replace it with any object implementing the `SandboxProvider` protocol: From 0afd990ee6b392b35b5cabbc7687ca953aa0fec3 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:30:05 -0500 Subject: [PATCH 28/61] Improve code-mode docs formatting; add docs formatting guideline --- docs/servers/transforms/code-mode.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/servers/transforms/code-mode.mdx b/docs/servers/transforms/code-mode.mdx index df4e094ba..f1caecc2d 100644 --- a/docs/servers/transforms/code-mode.mdx +++ b/docs/servers/transforms/code-mode.mdx @@ -145,11 +145,11 @@ The default `MontySandboxProvider` can enforce execution limits on sandboxed cod ```python from fastmcp.experimental.transforms import CodeMode, MontySandboxProvider -mcp.add_transform(CodeMode( - sandbox_provider=MontySandboxProvider( - limits={"max_duration_secs": 10, "max_memory": 50_000_000}, - ), -)) +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: From 72129c3d0379e1f866fb2bb47e56f9d32388bb8b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:30:46 -0500 Subject: [PATCH 29/61] Fix symlink note in CLAUDE.md --- CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2d3d96b5..9b9ca8214 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ > **Audience**: LLM-driven engineering agents and human developers -> **Note**: `CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` directly. +> **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. @@ -106,6 +106,7 @@ When modifying MCP functionality, changes typically need to be applied across al ### 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 From 7a92b8209792cba0cbd81e70bf933aac1d256ea6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 12:20:06 +0000 Subject: [PATCH 30/61] Don't advertise sampling.tools capability by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Claude Code https://claude.ai/code/session_01BsxYNsUhJPx14QiJ4FHqx6 --- src/fastmcp/client/client.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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( From 7690e995b82cb6d842fd9bb3e8f90890eb1e52ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 15:49:07 +0000 Subject: [PATCH 31/61] Add tests for default sampling capability (issue #3329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Claude Code https://claude.ai/code/session_01BsxYNsUhJPx14QiJ4FHqx6 --- tests/client/test_sampling.py | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py index e5a45adc7..9df997124 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.""" From 610551c7b610e8f7b9367672c42acc80a2b12038 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:21:11 -0500 Subject: [PATCH 32/61] Split large test files to comply with loq line limit (#3328) --- loq.toml | 68 - tests/client/test_elicitation.py | 474 +------ tests/client/test_elicitation_enums.py | 516 +++++++ tests/client/test_sampling.py | 1200 ----------------- tests/client/test_sampling_result_types.py | 442 ++++++ tests/client/test_sampling_tool_loop.py | 769 +++++++++++ tests/resources/test_resource_template.py | 340 ----- .../test_resource_template_query_params.py | 343 +++++ tests/server/auth/providers/test_azure.py | 685 +--------- .../auth/providers/test_azure_scopes.py | 694 ++++++++++ tests/server/auth/test_cimd.py | 669 +-------- tests/server/auth/test_cimd_validators.py | 682 ++++++++++ tests/server/auth/test_jwt_provider.py | 547 +------- tests/server/auth/test_jwt_provider_bearer.py | 610 +++++++++ tests/server/auth/test_oauth_consent_flow.py | 615 --------- tests/server/auth/test_oauth_consent_page.py | 696 ++++++++++ tests/server/auth/test_oidc_proxy.py | 283 ---- tests/server/auth/test_oidc_proxy_token.py | 331 +++++ tests/server/middleware/test_middleware.py | 508 ------- .../middleware/test_middleware_nested.py | 673 +++++++++ tests/server/test_auth_integration.py | 271 ---- tests/server/test_auth_integration_errors.py | 539 ++++++++ tests/server/test_dependencies.py | 447 ------ tests/server/test_dependencies_advanced.py | 462 +++++++ .../openapi/test_circular_references.py | 260 ++++ .../openapi/test_transitive_references.py | 252 ---- tests/utilities/test_inspect.py | 507 ------- tests/utilities/test_inspect_icons.py | 519 +++++++ 28 files changed, 7541 insertions(+), 6861 deletions(-) create mode 100644 tests/client/test_elicitation_enums.py create mode 100644 tests/client/test_sampling_result_types.py create mode 100644 tests/client/test_sampling_tool_loop.py create mode 100644 tests/resources/test_resource_template_query_params.py create mode 100644 tests/server/auth/providers/test_azure_scopes.py create mode 100644 tests/server/auth/test_cimd_validators.py create mode 100644 tests/server/auth/test_jwt_provider_bearer.py create mode 100644 tests/server/auth/test_oauth_consent_page.py create mode 100644 tests/server/auth/test_oidc_proxy_token.py create mode 100644 tests/server/middleware/test_middleware_nested.py create mode 100644 tests/server/test_auth_integration_errors.py create mode 100644 tests/server/test_dependencies_advanced.py create mode 100644 tests/utilities/openapi/test_circular_references.py create mode 100644 tests/utilities/test_inspect_icons.py 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/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 9df997124..b23276778 100644 --- a/tests/client/test_sampling.py +++ b/tests/client/test_sampling.py @@ -344,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/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..92a4178c8 --- /dev/null +++ b/tests/server/auth/providers/test_azure_scopes.py @@ -0,0 +1,694 @@ +"""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.""" + 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/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_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 "