diff --git a/README.md b/README.md
index c1384c638..91593ea73 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,9 @@
>
> **For production MCP applications, install FastMCP:** `pip install fastmcp`
+> [!Important]
+> FastMCP 3.0 is in development and may include breaking changes. To avoid unexpected issues, pin your dependency to v2: `fastmcp<3`
+
---
**FastMCP is the standard framework for building MCP applications**, providing the fastest path from idea to production.
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index eec90a81d..4fd1dca91 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -4,6 +4,224 @@ icon: "list-check"
rss: true
---
+
+
+**[v2.14.3: Time After Timeout](https://github.com/jlowin/fastmcp/releases/tag/v2.14.3)**
+
+Sometimes five seconds just isn't enough. This release fixes an HTTP transport bug that was cutting connections short, along with OAuth and Redis fixes, better ASGI support, and CLI update notifications so you never miss a beat.
+
+## What's Changed
+### Enhancements ๐ง
+* Add debug logging for OAuth token expiry diagnostics by [@jlowin](https://github.com/jlowin) in [#2789](https://github.com/jlowin/fastmcp/pull/2789)
+* Add CLI update notifications by [@jlowin](https://github.com/jlowin) in [#2839](https://github.com/jlowin/fastmcp/pull/2839)
+* Use pip instead of uv pip in upgrade instructions by [@jlowin](https://github.com/jlowin) in [#2841](https://github.com/jlowin/fastmcp/pull/2841)
+### Fixes ๐
+* Backport OAuth token storage TTL fix to release/2.x by [@jlowin](https://github.com/jlowin) in [#2798](https://github.com/jlowin/fastmcp/pull/2798)
+* Prefix Redis keys with docket name for ACL isolation (2.x backport) by [@chrisguidry](https://github.com/chrisguidry) in [#2812](https://github.com/jlowin/fastmcp/pull/2812)
+* Fix ContextVar propagation for ASGI-mounted servers with tasks by [@chrisguidry](https://github.com/chrisguidry) in [#2843](https://github.com/jlowin/fastmcp/pull/2843)
+* Fix HTTP transport timeout defaulting to 5 seconds by [@jlowin](https://github.com/jlowin) in [#2848](https://github.com/jlowin/fastmcp/pull/2848)
+
+**Full Changelog**: [v2.14.2...v2.14.3](https://github.com/jlowin/fastmcp/compare/v2.14.2...v2.14.3)
+
+
+
+
+
+**[v2.14.2: Port Authority](https://github.com/jlowin/fastmcp/releases/tag/v2.14.2)**
+
+FastMCP 2.14.2 brings a wave of community contributions safely into the 2.x line. A variety of important fixes backported from 3.0 work improve OpenAPI 3.1 compatibility, MCP spec compliance for output schemas and elicitation, and correct a subtle base_url fallback issue. The CLI now gently reminds you that FastMCP 3.0 is on the horizon.
+
+## What's Changed
+### Enhancements ๐ง
+* Pin MCP under 2.x by [@jlowin](https://github.com/jlowin) in [#2709](https://github.com/jlowin/fastmcp/pull/2709)
+* Add auth_route parameter to SupabaseProvider by [@EloiZalczer](https://github.com/EloiZalczer) in [#2760](https://github.com/jlowin/fastmcp/pull/2760)
+* Update CLI banner with FastMCP 3.0 notice by [@jlowin](https://github.com/jlowin) in [#2765](https://github.com/jlowin/fastmcp/pull/2765)
+### Fixes ๐
+* Let FastMCPError propagate unchanged from managers by [@jlowin](https://github.com/jlowin) in [#2697](https://github.com/jlowin/fastmcp/pull/2697)
+* Fix test cleanup for uvicorn 0.39+ context isolation by [@jlowin](https://github.com/jlowin) in [#2696](https://github.com/jlowin/fastmcp/pull/2696)
+* Bump pydocket to 0.16.3 to fix worker cleanup race condition by [@chrisguidry](https://github.com/chrisguidry) in [#2700](https://github.com/jlowin/fastmcp/pull/2700)
+* Fix Prefect website URL in docs footer by [@mgoldsborough](https://github.com/mgoldsborough) in [#2705](https://github.com/jlowin/fastmcp/pull/2705)
+* Fix: resolve root-level $ref in outputSchema for MCP spec compliance by [@majiayu000](https://github.com/majiayu000) in [#2727](https://github.com/jlowin/fastmcp/pull/2727)
+* Fix OAuth Proxy resource parameter validation by [@jlowin](https://github.com/jlowin) in [#2763](https://github.com/jlowin/fastmcp/pull/2763)
+* Fix openapi_version check to include 3.1 by [@deeleeramone](https://github.com/deeleeramone) in [#2769](https://github.com/jlowin/fastmcp/pull/2769)
+* Fix titled enum elicitation schema to comply with MCP spec by [@jlowin](https://github.com/jlowin) in [#2774](https://github.com/jlowin/fastmcp/pull/2774)
+* Fix base_url fallback when url is not set by [@bhbs](https://github.com/bhbs) in [#2782](https://github.com/jlowin/fastmcp/pull/2782)
+* Lazy import DiskStore to avoid sqlite3 dependency on import by [@jlowin](https://github.com/jlowin) in [#2785](https://github.com/jlowin/fastmcp/pull/2785)
+### Docs ๐
+* Add v3 breaking changes notice to README and docs by [@jlowin](https://github.com/jlowin) in [#2713](https://github.com/jlowin/fastmcp/pull/2713)
+* Add changelog entries for v2.13.1 through v2.14.1 by [@jlowin](https://github.com/jlowin) in [#2724](https://github.com/jlowin/fastmcp/pull/2724)
+* conference to 2.x branch by [@aaazzam](https://github.com/aaazzam) in [#2787](https://github.com/jlowin/fastmcp/pull/2787)
+
+**Full Changelog**: [v2.14.1...v2.14.2](https://github.com/jlowin/fastmcp/compare/v2.14.1...v2.14.2)
+
+
+
+
+
+**[v2.14.1: 'Tis a Gift to Be Sample](https://github.com/jlowin/fastmcp/releases/tag/v2.14.1)**
+
+FastMCP 2.14.1 introduces sampling with tools (SEP-1577), enabling servers to pass tools to `ctx.sample()` for agentic workflows where the LLM can automatically execute tool calls in a loop. The new `ctx.sample_step()` method provides single LLM calls that return `SampleStep` objects for custom control flow, while `result_type` enables structured outputs via validated Pydantic models.
+
+๐ค **AnthropicSamplingHandler** joins the existing OpenAI handler, providing multi-provider sampling support out of the box.
+
+โก **OpenAISamplingHandler promoted** from experimental statusโsampling handlers are now production-ready with a unified API.
+
+## What's Changed
+### New Features ๐
+* Sampling with tools by [@jlowin](https://github.com/jlowin) in [#2538](https://github.com/jlowin/fastmcp/pull/2538)
+* Add AnthropicSamplingHandler by [@jlowin](https://github.com/jlowin) in [#2677](https://github.com/jlowin/fastmcp/pull/2677)
+### Enhancements ๐ง
+* Add Python 3.13 to ubuntu CI by [@jlowin](https://github.com/jlowin) in [#2648](https://github.com/jlowin/fastmcp/pull/2648)
+* Remove legacy task initialization workaround by [@jlowin](https://github.com/jlowin) in [#2649](https://github.com/jlowin/fastmcp/pull/2649)
+* Consolidate session state reset logic by [@jlowin](https://github.com/jlowin) in [#2651](https://github.com/jlowin/fastmcp/pull/2651)
+* Unify SamplingHandler; promote OpenAI from experimental by [@jlowin](https://github.com/jlowin) in [#2656](https://github.com/jlowin/fastmcp/pull/2656)
+* Add `tool_names` parameter to mount() for name customization by [@jlowin](https://github.com/jlowin) in [#2660](https://github.com/jlowin/fastmcp/pull/2660)
+* Use streamable HTTP client API from MCP SDK by [@jlowin](https://github.com/jlowin) in [#2678](https://github.com/jlowin/fastmcp/pull/2678)
+* Deprecate `exclude_args` in favor of Depends() by [@jlowin](https://github.com/jlowin) in [#2693](https://github.com/jlowin/fastmcp/pull/2693)
+### Fixes ๐
+* Fix prompt tasks to return mcp.types.PromptMessage by [@jlowin](https://github.com/jlowin) in [#2650](https://github.com/jlowin/fastmcp/pull/2650)
+* Fix Windows test warnings by [@jlowin](https://github.com/jlowin) in [#2653](https://github.com/jlowin/fastmcp/pull/2653)
+* Cleanup cancelled connection startup by [@jlowin](https://github.com/jlowin) in [#2679](https://github.com/jlowin/fastmcp/pull/2679)
+* Fix tool choice bug in sampling examples by [@shawnthapa](https://github.com/shawnthapa) in [#2686](https://github.com/jlowin/fastmcp/pull/2686)
+### Docs ๐
+* Simplify Docket tip wording by [@chrisguidry](https://github.com/chrisguidry) in [#2662](https://github.com/jlowin/fastmcp/pull/2662)
+### Other Changes ๐ฆพ
+* Bump pydocket to โฅ0.15.5 by [@jlowin](https://github.com/jlowin) in [#2694](https://github.com/jlowin/fastmcp/pull/2694)
+
+## New Contributors
+* [@shawnthapa](https://github.com/shawnthapa) made their first contribution in [#2686](https://github.com/jlowin/fastmcp/pull/2686)
+
+**Full Changelog**: [v2.14.0...v2.14.1](https://github.com/jlowin/fastmcp/compare/v2.14.0...v2.14.1)
+
+
+
+
+
+**[v2.14.0: Task and You Shall Receive](https://github.com/jlowin/fastmcp/releases/tag/v2.14.0)**
+
+FastMCP 2.14 begins adopting the MCP 2025-11-25 specification, introducing protocol-native background tasks (SEP-1686) that enable long-running operations to report progress without blocking clients. The experimental OpenAPI parser graduates to standard, the `OpenAISamplingHandler` is promoted from experimental, and deprecated APIs accumulated across the 2.x series are removed.
+
+โณ **Background Tasks** let you add `task=True` to any async tool decorator to run operations in the background with progress tracking. Powered by [Docket](https://github.com/chrisguidry/docket), an enterprise task scheduler handling millions of concurrent tasks dailyโin-memory backends work out-of-the-box, and Redis URLs enable persistence and horizontal scaling.
+
+๐ง **OpenAPI Parser Promoted** from experimental to standard with improved performance through single-pass schema processing and cleaner abstractions.
+
+๐ **MCP 2025-11-25 Specification Support** including SSE polling and event resumability (SEP-1699), multi-select enum elicitation schemas (SEP-1330), default values for elicitation (SEP-1034), and tool name validation at registration time (SEP-986).
+
+## Breaking Changes
+- Docket is always enabled; task execution is forbidden through proxies
+- Task protocol enabled by default
+- Removed deprecated settings, imports, and methods accumulated across 2.x series
+
+## What's Changed
+### New Features ๐
+* OpenAPI parser is now the default by [@jlowin](https://github.com/jlowin) in [#2583](https://github.com/jlowin/fastmcp/pull/2583)
+* Implement SEP-1686: Background Tasks by [@jlowin](https://github.com/jlowin) in [#2550](https://github.com/jlowin/fastmcp/pull/2550)
+### Enhancements ๐ง
+* Expose InitializeResult in middleware by [@jlowin](https://github.com/jlowin) in [#2562](https://github.com/jlowin/fastmcp/pull/2562)
+* Update MCP SDK auth compatibility by [@jlowin](https://github.com/jlowin) in [#2574](https://github.com/jlowin/fastmcp/pull/2574)
+* Validate tool names at registration (SEP-986) by [@jlowin](https://github.com/jlowin) in [#2588](https://github.com/jlowin/fastmcp/pull/2588)
+* Support SEP-1034 and SEP-1330 for elicitation by [@jlowin](https://github.com/jlowin) in [#2595](https://github.com/jlowin/fastmcp/pull/2595)
+* Implement SSE polling (SEP-1699) by [@jlowin](https://github.com/jlowin) in [#2612](https://github.com/jlowin/fastmcp/pull/2612)
+* Expose session ID callback by [@jlowin](https://github.com/jlowin) in [#2628](https://github.com/jlowin/fastmcp/pull/2628)
+### Fixes ๐
+* Fix OAuth metadata discovery by [@jlowin](https://github.com/jlowin) in [#2565](https://github.com/jlowin/fastmcp/pull/2565)
+* Fix fastapi.cli package structure by [@jlowin](https://github.com/jlowin) in [#2570](https://github.com/jlowin/fastmcp/pull/2570)
+* Correct OAuth error codes by [@jlowin](https://github.com/jlowin) in [#2578](https://github.com/jlowin/fastmcp/pull/2578)
+* Prevent function signature modification by [@jlowin](https://github.com/jlowin) in [#2590](https://github.com/jlowin/fastmcp/pull/2590)
+* Fix proxy client kwargs by [@jlowin](https://github.com/jlowin) in [#2605](https://github.com/jlowin/fastmcp/pull/2605)
+* Fix nested server routing by [@jlowin](https://github.com/jlowin) in [#2618](https://github.com/jlowin/fastmcp/pull/2618)
+* Use access token expiry fallback by [@jlowin](https://github.com/jlowin) in [#2635](https://github.com/jlowin/fastmcp/pull/2635)
+* Handle transport cleanup exceptions by [@jlowin](https://github.com/jlowin) in [#2642](https://github.com/jlowin/fastmcp/pull/2642)
+### Docs ๐
+* Add OCI and Supabase integration docs by [@jlowin](https://github.com/jlowin) in [#2580](https://github.com/jlowin/fastmcp/pull/2580)
+* Add v2.14.0 upgrade guide by [@jlowin](https://github.com/jlowin) in [#2598](https://github.com/jlowin/fastmcp/pull/2598)
+* Rewrite background tasks documentation by [@jlowin](https://github.com/jlowin) in [#2620](https://github.com/jlowin/fastmcp/pull/2620)
+* Document read-only tool patterns by [@jlowin](https://github.com/jlowin) in [#2632](https://github.com/jlowin/fastmcp/pull/2632)
+
+## New Contributors
+11 total contributors including 7 first-time participants.
+
+**Full Changelog**: [v2.13.3...v2.14.0](https://github.com/jlowin/fastmcp/compare/v2.13.3...v2.14.0)
+
+
+
+
+
+**[v2.13.3: Pin-ish Line](https://github.com/jlowin/fastmcp/releases/tag/v2.13.3)**
+
+FastMCP 2.13.3 pins `mcp<1.23` as a precautionary measure. MCP SDK 1.23 introduced changes related to the November 25, 2025 MCP protocol update that break certain FastMCP patches and workarounds, particularly around OAuth implementation details. FastMCP 2.14 introduces proper support for the updated protocol and requires `mcp>=1.23`.
+
+## What's Changed
+### Fixes ๐
+* Pin MCP SDK below 1.23 by [@jlowin](https://github.com/jlowin) in [#2545](https://github.com/jlowin/fastmcp/pull/2545)
+
+**Full Changelog**: [v2.13.2...v2.13.3](https://github.com/jlowin/fastmcp/compare/v2.13.2...v2.13.3)
+
+
+
+
+
+**[v2.13.2: Refreshing Changes](https://github.com/jlowin/fastmcp/releases/tag/v2.13.2)**
+
+FastMCP 2.13.2 polishes the authentication stack with improvements to token refresh, scope handling, and multi-instance deployments. Discord was added as a built-in OAuth provider, Azure and Google token handling became more reliable, and proxy classes now properly forward icons and titles.
+
+## What's Changed
+### New Features ๐
+* Add Discord OAuth provider by [@jlowin](https://github.com/jlowin) in [#2480](https://github.com/jlowin/fastmcp/pull/2480)
+### Enhancements ๐ง
+* Descope Provider updates for new well-known URLs by [@anvibanga](https://github.com/anvibanga) in [#2465](https://github.com/jlowin/fastmcp/pull/2465)
+* Scalekit provider improvements by [@jlowin](https://github.com/jlowin) in [#2472](https://github.com/jlowin/fastmcp/pull/2472)
+* Add CSP customization for consent screens by [@jlowin](https://github.com/jlowin) in [#2488](https://github.com/jlowin/fastmcp/pull/2488)
+* Add icon support to proxy classes by [@jlowin](https://github.com/jlowin) in [#2495](https://github.com/jlowin/fastmcp/pull/2495)
+### Fixes ๐
+* Google Provider now defaults to refresh token support by [@jlowin](https://github.com/jlowin) in [#2468](https://github.com/jlowin/fastmcp/pull/2468)
+* Fix Azure OAuth token refresh with unprefixed scopes by [@jlowin](https://github.com/jlowin) in [#2475](https://github.com/jlowin/fastmcp/pull/2475)
+* Prevent `$defs` mutation during tool transforms by [@jlowin](https://github.com/jlowin) in [#2482](https://github.com/jlowin/fastmcp/pull/2482)
+* Fix OAuth proxy refresh token storage for multi-instance deployments by [@jlowin](https://github.com/jlowin) in [#2490](https://github.com/jlowin/fastmcp/pull/2490)
+* Fix stale token issue after OAuth refresh by [@jlowin](https://github.com/jlowin) in [#2498](https://github.com/jlowin/fastmcp/pull/2498)
+* Fix Azure provider OIDC scope handling by [@jlowin](https://github.com/jlowin) in [#2505](https://github.com/jlowin/fastmcp/pull/2505)
+
+## New Contributors
+7 new contributors made their first FastMCP contributions in this release.
+
+**Full Changelog**: [v2.13.1...v2.13.2](https://github.com/jlowin/fastmcp/compare/v2.13.1...v2.13.2)
+
+
+
+
+
+**[v2.13.1: Heavy Meta](https://github.com/jlowin/fastmcp/releases/tag/v2.13.1)**
+
+FastMCP 2.13.1 introduces meta parameter support for `ToolResult`, enabling tools to return supplementary metadata alongside results. This supports emerging use cases like OpenAI's Apps SDK. The release also brings improved OAuth functionality with custom token verifiers including a new DebugTokenVerifier, and adds OCI and Supabase authentication providers.
+
+๐ท๏ธ **Meta parameters for ToolResult** enable tools to return supplementary metadata alongside results, supporting patterns like OpenAI's Apps SDK integration.
+
+๐ **Custom token verifiers** with DebugTokenVerifier for development, plus Azure Government support through a `base_authority` parameter and Supabase authentication algorithm configuration.
+
+๐ **Security fixes** address CVE-2025-61920 through authlib updates and validate Cursor deeplink URLs using safer Windows APIs.
+
+## What's Changed
+### New Features ๐
+* Add meta parameter support for ToolResult by [@jlowin](https://github.com/jlowin) in [#2350](https://github.com/jlowin/fastmcp/pull/2350)
+* Add OCI authentication provider by [@jlowin](https://github.com/jlowin) in [#2365](https://github.com/jlowin/fastmcp/pull/2365)
+* Add Supabase authentication provider by [@jlowin](https://github.com/jlowin) in [#2378](https://github.com/jlowin/fastmcp/pull/2378)
+### Enhancements ๐ง
+* Add custom token verifier support to OIDCProxy by [@jlowin](https://github.com/jlowin) in [#2355](https://github.com/jlowin/fastmcp/pull/2355)
+* Add DebugTokenVerifier for development by [@jlowin](https://github.com/jlowin) in [#2362](https://github.com/jlowin/fastmcp/pull/2362)
+* Add Azure Government support via base_authority parameter by [@jlowin](https://github.com/jlowin) in [#2385](https://github.com/jlowin/fastmcp/pull/2385)
+* Add Supabase authentication algorithm configuration by [@jlowin](https://github.com/jlowin) in [#2392](https://github.com/jlowin/fastmcp/pull/2392)
+### Fixes ๐
+* Security: Update authlib for CVE-2025-61920 by [@jlowin](https://github.com/jlowin) in [#2398](https://github.com/jlowin/fastmcp/pull/2398)
+* Validate Cursor deeplink URLs using safer Windows APIs by [@jlowin](https://github.com/jlowin) in [#2405](https://github.com/jlowin/fastmcp/pull/2405)
+* Exclude MCP SDK 1.21.1 due to integration test failures by [@jlowin](https://github.com/jlowin) in [#2422](https://github.com/jlowin/fastmcp/pull/2422)
+
+## New Contributors
+18 new contributors joined in this release across 70+ pull requests.
+
+**Full Changelog**: [v2.13.0...v2.13.1](https://github.com/jlowin/fastmcp/compare/v2.13.0...v2.13.1)
+
+
+
**[v2.13.0: Cache Me If You Can](https://github.com/jlowin/fastmcp/releases/tag/v2.13.0)**
diff --git a/docs/docs.json b/docs/docs.json
index 889e8f37b..f51daeeea 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -12,7 +12,7 @@
"decoration": "gradient"
},
"banner": {
- "content": "Join the [FastMCP community](https://discord.gg/uu8dJCgttd)!"
+ "content": "Join us at the inaugural PyAI Conf in San Francisco on March 10th! [Learn More](https://pyai.events?utm_source=gofastmcp)"
},
"colors": {
"dark": "#f72585",
@@ -41,7 +41,7 @@
"socials": {
"discord": "https://discord.gg/uu8dJCgttd",
"github": "https://github.com/jlowin/fastmcp",
- "website": "https://prefect.ai",
+ "website": "https://www.prefect.io",
"x": "https://x.com/fastmcp"
}
},
diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx
index edcaa5983..ff9bb929a 100644
--- a/docs/getting-started/installation.mdx
+++ b/docs/getting-started/installation.mdx
@@ -23,6 +23,10 @@ Alternatively, you can install it directly with `pip` or `uv pip`:
```
+
+**FastMCP 3.0** is in development and may include breaking changes. To avoid unexpected issues, pin your dependency to v2: `fastmcp<3`
+
+
### Verify Installation
To verify that FastMCP is installed correctly, you can run the following command:
diff --git a/docs/getting-started/welcome.mdx b/docs/getting-started/welcome.mdx
index f424497ee..bbba67535 100644
--- a/docs/getting-started/welcome.mdx
+++ b/docs/getting-started/welcome.mdx
@@ -35,7 +35,6 @@ if __name__ == "__main__":
mcp.run()
```
-
## Beyond Basic MCP
FastMCP pioneered Python MCP development, and FastMCP 1.0 was incorporated into the [official MCP SDK](https://github.com/modelcontextprotocol/python-sdk) in 2024.
@@ -46,6 +45,10 @@ Ready to build? Start with our [installation guide](/getting-started/installatio
FastMCP is made with ๐ by [Prefect](https://www.prefect.io/).
+
+**FastMCP 3.0** is in development and may include breaking changes. To avoid unexpected issues, pin your dependency to v2: `fastmcp<3`
+
+
## What is MCP?
The Model Context Protocol lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. It is often described as "the USB-C port for AI", providing a uniform way to connect LLMs to resources they can use. It may be easier to think of it as an API, but specifically designed for LLM interactions. MCP servers can:
diff --git a/docs/integrations/supabase.mdx b/docs/integrations/supabase.mdx
index cbaa48920..b98c9cb7d 100644
--- a/docs/integrations/supabase.mdx
+++ b/docs/integrations/supabase.mdx
@@ -17,7 +17,7 @@ This guide shows you how to secure your FastMCP server using **Supabase Auth**.
### Prerequisites
Before you begin, you will need:
-1. A **[Supabase Account](https://supabase.com/)** with a project
+1. A **[Supabase Account](https://supabase.com/)** with a project or a self-hosted **Supabase Auth** instance
2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`)
### Step 1: Get Supabase Project URL
@@ -37,7 +37,8 @@ from fastmcp.server.auth.providers.supabase import SupabaseProvider
# Configure Supabase Auth
auth = SupabaseProvider(
project_url="https://abc123.supabase.co",
- base_url="http://localhost:8000"
+ base_url="http://localhost:8000",
+ auth_route="/my/auth/route" # if self-hosting and using custom routes
)
mcp = FastMCP("Supabase Protected Server", auth=auth)
@@ -117,6 +118,10 @@ Your Supabase project URL (e.g., `https://abc123.supabase.co`)
Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000` for development)
+
+Your Supabase auth route (e.g., `/auth/v1`)
+
+
Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid email` or `["openid", "email"]`)
diff --git a/docs/updates.mdx b/docs/updates.mdx
index ac65c4ff4..021ac2357 100644
--- a/docs/updates.mdx
+++ b/docs/updates.mdx
@@ -5,6 +5,98 @@ icon: "sparkles"
tag: NEW
---
+
+
+Sometimes five seconds just isn't enough. This release fixes an HTTP transport bug that was cutting connections short, along with OAuth and Redis fixes, better ASGI support, and CLI update notifications so you never miss a beat.
+
+
+
+
+
+FastMCP 2.14.2 brings a wave of community contributions safely into the 2.x line. A variety of important fixes backported from 3.0 work improve OpenAPI 3.1 compatibility, MCP spec compliance for output schemas and elicitation, and correct a subtle base_url fallback issue. The CLI now gently reminds you that FastMCP 3.0 is on the horizon.
+
+
+
+
+
+FastMCP 2.14.1 introduces sampling with tools (SEP-1577), enabling servers to pass tools to `ctx.sample()` for agentic workflows where the LLM can automatically execute tool calls in a loop.
+
+๐ค **Sampling with tools** lets servers leverage client LLM capabilities for multi-step agentic workflows. The new `ctx.sample_step()` method provides single LLM calls with tool inspection, while `result_type` enables structured outputs via validated Pydantic models.
+
+๐ง **AnthropicSamplingHandler** joins the existing OpenAI handler, and both are now promoted from experimental to production-ready status with a unified API.
+
+
+
+
+
+FastMCP 2.14 begins adopting the MCP 2025-11-25 specification, introducing protocol-native background tasks that enable long-running operations to report progress without blocking clients.
+
+โณ **Background Tasks (SEP-1686)** let you add `task=True` to any async tool decorator. Powered by [Docket](https://github.com/chrisguidry/docket) for enterprise task schedulingโin-memory backends work out-of-the-box, Redis enables persistence and horizontal scaling.
+
+๐ง **OpenAPI Parser Promoted** from experimental to standard with improved performance through single-pass schema processing.
+
+๐ **MCP Spec Updates** including SSE polling (SEP-1699), multi-select elicitation (SEP-1330), and tool name validation (SEP-986). Also removes deprecated APIs accumulated across 2.x.
+
+
+
+
+
+Pins `mcp<1.23` as a precaution due to MCP SDK changes related to the 11/25/25 protocol update that break certain FastMCP patches and workarounds. FastMCP 2.14 introduces proper support for the updated protocol.
+
+
+
+
+
+Polishes the authentication stack with improvements to token refresh, scope handling, and multi-instance deployments.
+
+๐ฎ **Discord OAuth provider** added as a built-in authentication option.
+
+๐ **Token refresh fixes** for Azure and Google providers, plus OAuth proxy improvements for multi-instance deployments.
+
+๐จ **Icon support** added to proxy classes for richer UX.
+
+
+
+
+
+Introduces meta parameter support for `ToolResult`, enabling tools to return supplementary metadata alongside results for patterns like OpenAI's Apps SDK.
+
+๐ท๏ธ **Meta parameters** let tools return supplementary metadata alongside results.
+
+๐ **New auth providers** for OCI and Supabase, plus custom token verifiers with DebugTokenVerifier for development.
+
+๐ **Security fixes** for CVE-2025-61920 and safer Cursor deeplink URL validation on Windows.
+
+
+
=1.1.0",
"exceptiongroup>=1.2.2",
"httpx>=0.28.1",
- "mcp>=1.24.0",
+ "mcp>=1.24.0,<2.0",
"openapi-pydantic>=0.5.1",
"platformdirs>=4.0.0",
- "pydocket>=0.15.5",
+ "pydocket>=0.16.6",
"rich>=13.9.4",
"cyclopts>=4.0.0",
"authlib>=1.6.5",
diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py
index a5e5d6698..495aa2dce 100644
--- a/src/fastmcp/cli/cli.py
+++ b/src/fastmcp/cli/cli.py
@@ -28,6 +28,7 @@ from fastmcp.utilities.inspect import (
)
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.mcp_server_config import MCPServerConfig
+from fastmcp.utilities.version_check import check_for_newer_version
logger = get_logger("cli")
console = Console()
@@ -122,6 +123,14 @@ def version(
else:
console.print(g)
+ # Check for updates (not included in --copy output)
+ if newer_version := check_for_newer_version():
+ console.print()
+ console.print(
+ f"[bold]๐ FastMCP update available:[/bold] [green]{newer_version}[/green]"
+ )
+ console.print("[dim]Run: pip install --upgrade fastmcp[/dim]")
+
@app.command
async def dev(
diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py
index 2190b9a43..47e95843a 100644
--- a/src/fastmcp/client/auth/oauth.py
+++ b/src/fastmcp/client/auth/oauth.py
@@ -105,10 +105,13 @@ class TokenStorageAdapter(TokenStorage):
@override
async def set_tokens(self, tokens: OAuthToken) -> None:
+ # Don't set TTL based on access token expiry - the refresh token may be
+ # valid much longer. Use 1 year as a reasonable upper bound; the OAuth
+ # provider handles actual token expiry/refresh logic.
await self._storage_oauth_token.put(
key=self._get_token_cache_key(),
value=tokens,
- ttl=tokens.expires_in,
+ ttl=60 * 60 * 24 * 365, # 1 year
)
@override
diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py
index 2eaede2a1..85b3dacfb 100644
--- a/src/fastmcp/client/transports.py
+++ b/src/fastmcp/client/transports.py
@@ -25,7 +25,7 @@ from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client
from mcp.server.fastmcp import FastMCP as FastMCP1Server
-from mcp.shared._httpx_utils import McpHttpClientFactory
+from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
from mcp.shared.memory import create_client_server_memory_streams
from pydantic import AnyUrl
from typing_extensions import TypedDict, Unpack
@@ -284,25 +284,31 @@ class StreamableHttpTransport(ClientTransport):
# need to be forwarded to the remote server.
headers = get_http_headers() | self.headers
- # Build httpx client configuration
- httpx_client_kwargs: dict[str, Any] = {
- "headers": headers,
- "auth": self.auth,
- "follow_redirects": True,
- }
-
- # Configure timeout if provided (convert timedelta to seconds for httpx)
+ # Configure timeout if provided, preserving MCP's 30s connect default
+ timeout: httpx.Timeout | None = None
if session_kwargs.get("read_timeout_seconds") is not None:
read_timeout_seconds = cast(
datetime.timedelta, session_kwargs.get("read_timeout_seconds")
)
- httpx_client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
+ timeout = httpx.Timeout(30.0, read=read_timeout_seconds.total_seconds())
- # Create httpx client from factory or use default
+ # Create httpx client from factory or use default with MCP-appropriate timeouts
+ # create_mcp_http_client uses 30s connect/5min read timeout by default,
+ # and always enables follow_redirects
if self.httpx_client_factory is not None:
- http_client = self.httpx_client_factory(**httpx_client_kwargs)
+ # Factory clients get the full kwargs for backwards compatibility
+ http_client = self.httpx_client_factory(
+ headers=headers,
+ auth=self.auth,
+ follow_redirects=True,
+ **({"timeout": timeout} if timeout else {}),
+ )
else:
- http_client = httpx.AsyncClient(**httpx_client_kwargs)
+ http_client = create_mcp_http_client(
+ headers=headers,
+ timeout=timeout,
+ auth=self.auth,
+ )
# Ensure httpx client is closed after use
async with (
diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py
index 1563a1831..e66c1e878 100644
--- a/src/fastmcp/prompts/prompt_manager.py
+++ b/src/fastmcp/prompts/prompt_manager.py
@@ -7,7 +7,7 @@ from typing import Any
from mcp import GetPromptResult
from fastmcp import settings
-from fastmcp.exceptions import NotFoundError, PromptError
+from fastmcp.exceptions import FastMCPError, NotFoundError, PromptError
from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult
from fastmcp.settings import DuplicateBehavior
from fastmcp.utilities.logging import get_logger
@@ -107,9 +107,8 @@ class PromptManager:
try:
messages = await prompt.render(arguments)
return GetPromptResult(description=prompt.description, messages=messages)
- except PromptError as e:
- logger.exception(f"Error rendering prompt {name!r}")
- raise e
+ except FastMCPError:
+ raise
except Exception as e:
logger.exception(f"Error rendering prompt {name!r}")
if self.mask_error_details:
diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py
index 7367fb3e9..28416a692 100644
--- a/src/fastmcp/resources/resource_manager.py
+++ b/src/fastmcp/resources/resource_manager.py
@@ -10,7 +10,7 @@ from typing import Any
from pydantic import AnyUrl
from fastmcp import settings
-from fastmcp.exceptions import NotFoundError, ResourceError
+from fastmcp.exceptions import FastMCPError, NotFoundError, ResourceError
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import (
ResourceTemplate,
@@ -268,10 +268,9 @@ class ResourceManager:
uri_str,
params=params,
)
- # Pass through ResourceErrors as-is
- except ResourceError as e:
- logger.error(f"Error creating resource from template: {e}")
- raise e
+ # Pass through FastMCPErrors as-is
+ except FastMCPError:
+ raise
# Handle other exceptions
except Exception as e:
logger.error(f"Error creating resource from template: {e}")
@@ -299,10 +298,9 @@ class ResourceManager:
try:
return await resource.read()
- # raise ResourceErrors as-is
- except ResourceError as e:
- logger.exception(f"Error reading resource {uri_str!r}")
- raise e
+ # raise FastMCPErrors as-is
+ except FastMCPError:
+ raise
# Handle other exceptions
except Exception as e:
@@ -322,11 +320,8 @@ class ResourceManager:
try:
resource = await template.create_resource(uri_str, params=params)
return await resource.read()
- except ResourceError as e:
- logger.exception(
- f"Error reading resource from template {uri_str!r}"
- )
- raise e
+ except FastMCPError:
+ raise
except Exception as e:
logger.exception(
f"Error reading resource from template {uri_str!r}"
diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py
index bc255b394..c3bbd1b17 100644
--- a/src/fastmcp/server/auth/auth.py
+++ b/src/fastmcp/server/auth/auth.py
@@ -114,6 +114,8 @@ class AuthProvider(TokenVerifierProtocol):
base_url = AnyHttpUrl(base_url)
self.base_url = base_url
self.required_scopes = required_scopes or []
+ self._mcp_path: str | None = None
+ self._resource_url: AnyHttpUrl | None = None
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a bearer token and return access info if valid.
@@ -128,6 +130,20 @@ class AuthProvider(TokenVerifierProtocol):
"""
raise NotImplementedError("Subclasses must implement verify_token")
+ def set_mcp_path(self, mcp_path: str | None) -> None:
+ """Set the MCP endpoint path and compute resource URL.
+
+ This method is called by get_routes() to configure the expected
+ resource URL before route creation. Subclasses can override to
+ perform additional initialization that depends on knowing the
+ MCP endpoint path.
+
+ Args:
+ mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
+ """
+ self._mcp_path = mcp_path
+ self._resource_url = self._get_resource_url(mcp_path)
+
def get_routes(
self,
mcp_path: str | None = None,
@@ -407,6 +423,8 @@ class OAuthProvider(
Returns:
List of OAuth routes
"""
+ # Configure resource URL before creating routes
+ self.set_mcp_path(mcp_path)
# Create standard OAuth authorization server routes
# Pass base_url as issuer_url to ensure metadata declares endpoints where
@@ -451,11 +469,8 @@ class OAuthProvider(
else:
oauth_routes.append(route)
- # Get the resource URL based on the MCP path
- resource_url = self._get_resource_url(mcp_path)
-
# Add protected resource routes if this server is also acting as a resource server
- if resource_url:
+ if self._resource_url:
supported_scopes = (
self.client_registration_options.valid_scopes
if self.client_registration_options
@@ -463,7 +478,7 @@ class OAuthProvider(
else self.required_scopes
)
protected_routes = create_protected_resource_routes(
- resource_url=resource_url,
+ resource_url=self._resource_url,
authorization_servers=[cast(AnyHttpUrl, self.issuer_url)],
scopes_supported=supported_scopes,
)
diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py
index b802754f0..4f84a825a 100644
--- a/src/fastmcp/server/auth/oauth_proxy.py
+++ b/src/fastmcp/server/auth/oauth_proxy.py
@@ -34,7 +34,6 @@ from authlib.integrations.httpx_client import AsyncOAuth2Client
from cryptography.fernet import Fernet
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols import AsyncKeyValue
-from key_value.aio.stores.disk import DiskStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from mcp.server.auth.provider import (
AccessToken,
@@ -805,14 +804,16 @@ class OAuthProxy(OAuthProvider):
salt="fastmcp-jwt-signing-key",
)
- self._jwt_issuer: JWTIssuer = JWTIssuer(
- issuer=str(self.base_url),
- audience=f"{str(self.base_url).rstrip('/')}/mcp",
- signing_key=jwt_signing_key,
- )
+ # Store JWT signing key for deferred JWTIssuer creation in set_mcp_path()
+ self._jwt_signing_key: bytes = jwt_signing_key
+ # JWTIssuer will be created in set_mcp_path() with correct audience
+ self._jwt_issuer: JWTIssuer | None = None
# If the user does not provide a store, we will provide an encrypted disk store
if client_storage is None:
+ # Import lazily to avoid sqlite3 dependency when not using OAuthProxy
+ from key_value.aio.stores.disk import DiskStore
+
storage_encryption_key = derive_jwt_key(
high_entropy_material=jwt_signing_key.decode(),
salt="fastmcp-storage-encryption-key",
@@ -897,6 +898,47 @@ class OAuthProxy(OAuthProvider):
self._upstream_authorization_endpoint,
)
+ # -------------------------------------------------------------------------
+ # MCP Path Configuration
+ # -------------------------------------------------------------------------
+
+ def set_mcp_path(self, mcp_path: str | None) -> None:
+ """Set the MCP endpoint path and create JWTIssuer with correct audience.
+
+ This method is called by get_routes() to configure the resource URL
+ and create the JWTIssuer. The JWT audience is set to the full resource
+ URL (e.g., http://localhost:8000/mcp) to ensure tokens are bound to
+ this specific MCP endpoint.
+
+ Args:
+ mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
+ """
+ super().set_mcp_path(mcp_path)
+
+ # Create JWT issuer with correct audience based on actual MCP path
+ # This ensures tokens are bound to the specific resource URL
+ self._jwt_issuer = JWTIssuer(
+ issuer=str(self.base_url),
+ audience=str(self._resource_url),
+ signing_key=self._jwt_signing_key,
+ )
+
+ logger.debug("Configured OAuth proxy for resource URL: %s", self._resource_url)
+
+ @property
+ def jwt_issuer(self) -> JWTIssuer:
+ """Get the JWT issuer, ensuring it has been initialized.
+
+ The JWT issuer is created when set_mcp_path() is called (via get_routes()).
+ This property ensures a clear error if used before initialization.
+ """
+ if self._jwt_issuer is None:
+ raise RuntimeError(
+ "JWT issuer not initialized. Ensure get_routes() is called "
+ "before token operations."
+ )
+ return self._jwt_issuer
+
# -------------------------------------------------------------------------
# PKCE Helper Methods
# -------------------------------------------------------------------------
@@ -998,13 +1040,29 @@ class OAuthProxy(OAuthProvider):
"""Start OAuth transaction and route through consent interstitial.
Flow:
- 1. Store transaction with client details and PKCE (if forwarding)
- 2. Return local /consent URL; browser visits consent first
- 3. Consent handler redirects to upstream IdP if approved/already approved
+ 1. Validate client's resource matches server's resource URL (security check)
+ 2. Store transaction with client details and PKCE (if forwarding)
+ 3. Return local /consent URL; browser visits consent first
+ 4. Consent handler redirects to upstream IdP if approved/already approved
If consent is disabled (require_authorization_consent=False), skip the consent screen
and redirect directly to the upstream IdP.
"""
+ # Security check: validate client's requested resource matches this server
+ # This prevents tokens intended for one server from being used on another
+ client_resource = getattr(params, "resource", None)
+ if client_resource and self._resource_url:
+ if str(client_resource) != str(self._resource_url):
+ logger.warning(
+ "Resource mismatch: client requested %s but server is %s",
+ client_resource,
+ self._resource_url,
+ )
+ raise AuthorizeError(
+ error="invalid_target", # type: ignore[arg-type]
+ error_description="Resource does not match this server",
+ )
+
# Generate transaction ID for this authorization request
txn_id = secrets.token_urlsafe(32)
@@ -1163,12 +1221,24 @@ class OAuthProxy(OAuthProvider):
# - 1 year if no refresh token (likely API-key-style token like GitHub OAuth Apps)
if "expires_in" in idp_tokens:
expires_in = int(idp_tokens["expires_in"])
+ logger.debug(
+ "Access token TTL: %d seconds (from IdP expires_in)", expires_in
+ )
elif self._fallback_access_token_expiry_seconds is not None:
expires_in = self._fallback_access_token_expiry_seconds
+ logger.debug(
+ "Access token TTL: %d seconds (using configured fallback)", expires_in
+ )
elif idp_tokens.get("refresh_token"):
expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS
+ logger.debug(
+ "Access token TTL: %d seconds (default, has refresh token)", expires_in
+ )
else:
expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS
+ logger.debug(
+ "Access token TTL: %d seconds (default, no refresh token)", expires_in
+ )
# Calculate refresh token expiry if provided by upstream
# Some providers include refresh_expires_in, some don't
@@ -1208,15 +1278,16 @@ class OAuthProxy(OAuthProvider):
await self._upstream_token_store.put(
key=upstream_token_id,
value=upstream_token_set,
- ttl=refresh_expires_in
- or expires_in, # Auto-expire when refresh token, or access token expires
+ ttl=max(
+ refresh_expires_in or 0, expires_in, 1
+ ), # Keep until longest-lived token expires (min 1s for safety)
)
logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8])
# Issue minimal FastMCP access token (just a reference via JTI)
if client.client_id is None:
raise TokenError("invalid_client", "Client ID is required")
- fastmcp_access_token = self._jwt_issuer.issue_access_token(
+ fastmcp_access_token = self.jwt_issuer.issue_access_token(
client_id=client.client_id,
scopes=authorization_code.scopes,
jti=access_jti,
@@ -1227,7 +1298,7 @@ class OAuthProxy(OAuthProvider):
# Use upstream refresh token expiry to align lifetimes
fastmcp_refresh_token = None
if refresh_jti and refresh_expires_in:
- fastmcp_refresh_token = self._jwt_issuer.issue_refresh_token(
+ fastmcp_refresh_token = self.jwt_issuer.issue_refresh_token(
client_id=client.client_id,
scopes=authorization_code.scopes,
jti=refresh_jti,
@@ -1352,7 +1423,7 @@ class OAuthProxy(OAuthProvider):
"""
# Verify FastMCP refresh token
try:
- refresh_payload = self._jwt_issuer.verify_token(refresh_token.token)
+ refresh_payload = self.jwt_issuer.verify_token(refresh_token.token)
refresh_jti = refresh_payload["jti"]
except Exception as e:
logger.debug("FastMCP refresh token validation failed: %s", e)
@@ -1409,10 +1480,21 @@ class OAuthProxy(OAuthProvider):
# (user override still applies if set)
if "expires_in" in token_response:
new_expires_in = int(token_response["expires_in"])
+ logger.debug(
+ "Refreshed access token TTL: %d seconds (from IdP expires_in)",
+ new_expires_in,
+ )
elif self._fallback_access_token_expiry_seconds is not None:
new_expires_in = self._fallback_access_token_expiry_seconds
+ logger.debug(
+ "Refreshed access token TTL: %d seconds (using configured fallback)",
+ new_expires_in,
+ )
else:
new_expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS
+ logger.debug(
+ "Refreshed access token TTL: %d seconds (default)", new_expires_in
+ )
upstream_token_set.access_token = token_response["access_token"]
upstream_token_set.expires_at = time.time() + new_expires_in
@@ -1446,22 +1528,25 @@ class OAuthProxy(OAuthProvider):
)
upstream_token_set.raw_token_data = token_response
+ # Calculate refresh TTL for storage
+ refresh_ttl = new_refresh_expires_in or (
+ int(upstream_token_set.refresh_token_expires_at - time.time())
+ if upstream_token_set.refresh_token_expires_at
+ else 60 * 60 * 24 * 30 # Default to 30 days if unknown
+ )
await self._upstream_token_store.put(
key=upstream_token_set.upstream_token_id,
value=upstream_token_set,
- ttl=new_refresh_expires_in
- or (
- int(upstream_token_set.refresh_token_expires_at - time.time())
- if upstream_token_set.refresh_token_expires_at
- else 60 * 60 * 24 * 30 # Default to 30 days if unknown
- ), # Auto-expire when refresh token expires
+ ttl=max(
+ refresh_ttl, new_expires_in, 1
+ ), # Keep until longest-lived token expires (min 1s for safety)
)
# Issue new minimal FastMCP access token (just a reference via JTI)
if client.client_id is None:
raise TokenError("invalid_client", "Client ID is required")
new_access_jti = secrets.token_urlsafe(32)
- new_fastmcp_access = self._jwt_issuer.issue_access_token(
+ new_fastmcp_access = self.jwt_issuer.issue_access_token(
client_id=client.client_id,
scopes=scopes,
jti=new_access_jti,
@@ -1482,7 +1567,7 @@ class OAuthProxy(OAuthProvider):
# Issue NEW minimal FastMCP refresh token (rotation for security)
# Use upstream refresh token expiry to align lifetimes
new_refresh_jti = secrets.token_urlsafe(32)
- new_fastmcp_refresh = self._jwt_issuer.issue_refresh_token(
+ new_fastmcp_refresh = self.jwt_issuer.issue_refresh_token(
client_id=client.client_id,
scopes=scopes,
jti=new_refresh_jti,
@@ -1491,7 +1576,7 @@ class OAuthProxy(OAuthProvider):
)
# Store new refresh token JTI mapping with aligned expiry
- refresh_ttl = new_refresh_expires_in or 60 * 60 * 24 * 30
+ # (reuse refresh_ttl calculated above for upstream token store)
await self._jti_mapping_store.put(
key=new_refresh_jti,
value=JTIMapping(
@@ -1558,13 +1643,16 @@ class OAuthProxy(OAuthProvider):
"""
try:
# 1. Verify FastMCP JWT signature and claims
- payload = self._jwt_issuer.verify_token(token)
+ payload = self.jwt_issuer.verify_token(token)
jti = payload["jti"]
# 2. Look up upstream token via JTI mapping
jti_mapping = await self._jti_mapping_store.get(key=jti)
if not jti_mapping:
- logger.debug("JTI mapping not found: %s", jti)
+ logger.info(
+ "JTI mapping not found (token may have expired): jti=%s...",
+ jti[:16],
+ )
return None
upstream_token_set = await self._upstream_token_store.get(
@@ -1807,6 +1895,11 @@ class OAuthProxy(OAuthProvider):
logger.debug(
f"Successfully exchanged IdP code for tokens (transaction: {txn_id}, PKCE: {bool(proxy_code_verifier)})"
)
+ logger.debug(
+ "IdP token response: expires_in=%s, has_refresh_token=%s",
+ idp_tokens.get("expires_in"),
+ "refresh_token" in idp_tokens,
+ )
except Exception as e:
logger.error("IdP token exchange failed: %s", e)
diff --git a/src/fastmcp/server/auth/providers/supabase.py b/src/fastmcp/server/auth/providers/supabase.py
index 418831d48..50bb3be08 100644
--- a/src/fastmcp/server/auth/providers/supabase.py
+++ b/src/fastmcp/server/auth/providers/supabase.py
@@ -34,6 +34,7 @@ class SupabaseProviderSettings(BaseSettings):
project_url: AnyHttpUrl
base_url: AnyHttpUrl
+ auth_route: str = "/auth/v1"
algorithm: Literal["HS256", "RS256", "ES256"] = "ES256"
required_scopes: list[str] | None = None
@@ -59,8 +60,8 @@ class SupabaseProvider(RemoteAuthProvider):
- Asymmetric keys (RS256/ES256) are recommended for production
2. JWT Verification:
- - FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json
- - JWTs are issued by {project_url}/auth/v1
+ - FastMCP verifies JWTs using the JWKS endpoint at {project_url}{auth_route}/.well-known/jwks.json
+ - JWTs are issued by {project_url}{auth_route}
- Tokens are cached for up to 10 minutes by Supabase's edge servers
- Algorithm must match your Supabase Auth configuration
@@ -93,6 +94,7 @@ class SupabaseProvider(RemoteAuthProvider):
*,
project_url: AnyHttpUrl | str | NotSetT = NotSet,
base_url: AnyHttpUrl | str | NotSetT = NotSet,
+ auth_route: str | NotSetT = NotSet,
algorithm: Literal["HS256", "RS256", "ES256"] | NotSetT = NotSet,
required_scopes: list[str] | NotSetT | None = NotSet,
token_verifier: TokenVerifier | None = None,
@@ -102,6 +104,7 @@ class SupabaseProvider(RemoteAuthProvider):
Args:
project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co")
base_url: Public URL of this FastMCP server
+ auth_route: Supabase Auth route. Defaults to "/auth/v1".
algorithm: JWT signing algorithm (HS256, RS256, or ES256). Must match your
Supabase Auth configuration. Defaults to ES256.
required_scopes: Optional list of scopes to require for all requests.
@@ -115,6 +118,7 @@ class SupabaseProvider(RemoteAuthProvider):
for k, v in {
"project_url": project_url,
"base_url": base_url,
+ "auth_route": auth_route,
"algorithm": algorithm,
"required_scopes": required_scopes,
}.items()
@@ -124,12 +128,13 @@ class SupabaseProvider(RemoteAuthProvider):
self.project_url = str(settings.project_url).rstrip("/")
self.base_url = AnyHttpUrl(str(settings.base_url).rstrip("/"))
+ self.auth_route = settings.auth_route.strip("/")
# Create default JWT verifier if none provided
if token_verifier is None:
token_verifier = JWTVerifier(
- jwks_uri=f"{self.project_url}/auth/v1/.well-known/jwks.json",
- issuer=f"{self.project_url}/auth/v1",
+ jwks_uri=f"{self.project_url}/{self.auth_route}/.well-known/jwks.json",
+ issuer=f"{self.project_url}/{self.auth_route}",
algorithm=settings.algorithm,
required_scopes=settings.required_scopes,
)
@@ -137,7 +142,7 @@ class SupabaseProvider(RemoteAuthProvider):
# Initialize RemoteAuthProvider with Supabase as the authorization server
super().__init__(
token_verifier=token_verifier,
- authorization_servers=[AnyHttpUrl(f"{self.project_url}/auth/v1")],
+ authorization_servers=[AnyHttpUrl(f"{self.project_url}/{self.auth_route}")],
base_url=self.base_url,
)
@@ -162,7 +167,7 @@ class SupabaseProvider(RemoteAuthProvider):
try:
async with httpx.AsyncClient() as client:
response = await client.get(
- f"{self.project_url}/auth/v1/.well-known/oauth-authorization-server"
+ f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server"
)
response.raise_for_status()
metadata = response.json()
diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py
index 8cd4f7307..5370f0469 100644
--- a/src/fastmcp/server/context.py
+++ b/src/fastmcp/server/context.py
@@ -185,10 +185,24 @@ class Context:
self._tokens.append(token)
# Set current server for dependency injection (use weakref to avoid reference cycles)
- from fastmcp.server.dependencies import _current_server
+ from fastmcp.server.dependencies import (
+ _current_docket,
+ _current_server,
+ _current_worker,
+ )
self._server_token = _current_server.set(weakref.ref(self.fastmcp))
+ # Set docket/worker from server instance for this request's context.
+ # This ensures ContextVars work even in environments (like Lambda) where
+ # lifespan ContextVars don't propagate to request handlers.
+ server = self.fastmcp
+ if server._docket is not None:
+ self._docket_token = _current_docket.set(server._docket)
+
+ if server._worker is not None:
+ self._worker_token = _current_worker.set(server._worker)
+
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
@@ -196,10 +210,20 @@ class Context:
# Flush any remaining notifications before exiting
await self._flush_notifications()
- # Reset server token
- if hasattr(self, "_server_token"):
- from fastmcp.server.dependencies import _current_server
+ # Reset server/docket/worker tokens
+ from fastmcp.server.dependencies import (
+ _current_docket,
+ _current_server,
+ _current_worker,
+ )
+ if hasattr(self, "_worker_token"):
+ _current_worker.reset(self._worker_token)
+ delattr(self, "_worker_token")
+ if hasattr(self, "_docket_token"):
+ _current_docket.reset(self._docket_token)
+ delattr(self, "_docket_token")
+ if hasattr(self, "_server_token"):
_current_server.reset(self._server_token)
delattr(self, "_server_token")
diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py
index 31b170c79..3671ba42b 100644
--- a/src/fastmcp/server/dependencies.py
+++ b/src/fastmcp/server/dependencies.py
@@ -21,6 +21,7 @@ from mcp.server.auth.provider import (
from mcp.server.lowlevel.server import request_ctx
from starlette.requests import Request
+from fastmcp.exceptions import FastMCPError
from fastmcp.server.auth import AccessToken
from fastmcp.server.http import _current_http_request
from fastmcp.utilities.types import is_class_member_of_type
@@ -188,6 +189,10 @@ async def _resolve_fastmcp_dependencies(
resolved[parameter] = await stack.enter_async_context(
dependency
)
+ except FastMCPError:
+ # Let FastMCPError subclasses (ToolError, ResourceError, etc.)
+ # propagate unchanged so they can be handled appropriately
+ raise
except Exception as error:
fn_name = getattr(fn, "__name__", repr(fn))
raise RuntimeError(
diff --git a/src/fastmcp/server/elicitation.py b/src/fastmcp/server/elicitation.py
index c2a93a1f7..61fe1ca5b 100644
--- a/src/fastmcp/server/elicitation.py
+++ b/src/fastmcp/server/elicitation.py
@@ -304,15 +304,19 @@ def _dict_to_enum_schema(
multi_select: If True, use anyOf pattern; if False, use oneOf pattern
Returns:
- {"oneOf": [{"const": "low", "title": "Low Priority"}, ...]} for single-select
- {"anyOf": [{"const": "low", "title": "Low Priority"}, ...]} for multi-select
+ {"type": "string", "oneOf": [...]} for single-select
+ {"anyOf": [...]} for multi-select (used as array items)
"""
pattern_key = "anyOf" if multi_select else "oneOf"
pattern = []
for value, metadata in enum_dict.items():
title = metadata.get("title", value)
pattern.append({"const": value, "title": title})
- return {pattern_key: pattern}
+
+ result: dict[str, Any] = {pattern_key: pattern}
+ if not multi_select:
+ result["type"] = "string"
+ return result
def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]:
diff --git a/src/fastmcp/server/middleware/error_handling.py b/src/fastmcp/server/middleware/error_handling.py
index 7cb730d90..fc0e2a1c8 100644
--- a/src/fastmcp/server/middleware/error_handling.py
+++ b/src/fastmcp/server/middleware/error_handling.py
@@ -87,7 +87,7 @@ class ErrorHandlingMiddleware(Middleware):
return error
# Map common exceptions to appropriate MCP error codes
- error_type = type(error)
+ error_type = type(error.__cause__) if error.__cause__ else type(error)
if error_type in (ValueError, TypeError):
return McpError(
diff --git a/src/fastmcp/server/openapi/components.py b/src/fastmcp/server/openapi/components.py
index 40577cfa7..496a23327 100644
--- a/src/fastmcp/server/openapi/components.py
+++ b/src/fastmcp/server/openapi/components.py
@@ -64,10 +64,8 @@ class OpenAPITool(Tool):
try:
# Get base URL from client
base_url = (
- str(self._client.base_url)
- if hasattr(self._client, "base_url") and self._client.base_url
- else "http://localhost"
- )
+ str(self._client.base_url) if hasattr(self._client, "base_url") else ""
+ ) or "http://localhost"
# Get Headers from client
cli_headers = (
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 5a34a32f7..44ac0aab2 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -105,23 +105,6 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
-def _create_named_fn_wrapper(fn: Callable[..., Any], name: str) -> Callable[..., Any]:
- """Create a wrapper function with a custom __name__ for Docket registration.
-
- Docket uses fn.__name__ as the key for function registration and lookup.
- When mounting servers, we need unique names to avoid collisions between
- mounted servers that have identically-named functions.
- """
- import functools
-
- @functools.wraps(fn)
- async def wrapper(*args: Any, **kwargs: Any) -> Any:
- return await fn(*args, **kwargs)
-
- wrapper.__name__ = name
- return wrapper
-
-
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
Transport = Literal["stdio", "http", "sse", "streamable-http"]
@@ -214,8 +197,9 @@ class FastMCP(Generic[LifespanResultT]):
# Resolve server default for background task support
self._support_tasks_by_default: bool = tasks if tasks is not None else False
- # Docket instance (set during lifespan for cross-task access)
+ # Docket and Worker instances (set during lifespan for cross-task access)
self._docket = None
+ self._worker = None
self._additional_http_routes: list[BaseRoute] = []
self._mounted_servers: list[MountedServer] = []
@@ -437,7 +421,7 @@ class FastMCP(Generic[LifespanResultT]):
isinstance(tool, FunctionTool)
and tool.task_config.mode != "forbidden"
):
- docket.register(tool.fn)
+ docket.register(tool.fn, names=[tool.key])
for prompt in self._prompt_manager._prompts.values():
if (
@@ -445,21 +429,24 @@ class FastMCP(Generic[LifespanResultT]):
and prompt.task_config.mode != "forbidden"
):
# task execution requires async fn (validated at creation time)
- docket.register(cast(Callable[..., Awaitable[Any]], prompt.fn))
+ docket.register(
+ cast(Callable[..., Awaitable[Any]], prompt.fn),
+ names=[prompt.key],
+ )
for resource in self._resource_manager._resources.values():
if (
isinstance(resource, FunctionResource)
and resource.task_config.mode != "forbidden"
):
- docket.register(resource.fn)
+ docket.register(resource.fn, names=[resource.name])
for template in self._resource_manager._templates.values():
if (
isinstance(template, FunctionResourceTemplate)
and template.task_config.mode != "forbidden"
):
- docket.register(template.fn)
+ docket.register(template.fn, names=[template.name])
# Also register functions from mounted servers so tasks can
# execute in the parent's Docket context
@@ -482,6 +469,8 @@ class FastMCP(Generic[LifespanResultT]):
# Create and start Worker
async with Worker(docket, **worker_kwargs) as worker: # type: ignore[arg-type]
+ # Store on server instance for cross-context access
+ self._worker = worker
# Set Worker in ContextVar so CurrentWorker can access it
worker_token = _current_worker.set(worker)
try:
@@ -489,21 +478,16 @@ class FastMCP(Generic[LifespanResultT]):
try:
yield
finally:
- # Cancel worker task on exit with timeout to prevent hanging
worker_task.cancel()
- with suppress(
- asyncio.CancelledError, asyncio.TimeoutError
- ):
- await asyncio.wait_for(worker_task, timeout=2.0)
+ with suppress(asyncio.CancelledError):
+ await worker_task
finally:
_current_worker.reset(worker_token)
+ self._worker = None
finally:
- # Reset ContextVar
_current_docket.reset(docket_token)
- # Clear instance attribute
self._docket = None
finally:
- # Reset server ContextVar
_current_server.reset(server_token)
async def _register_mounted_server_functions(
@@ -535,8 +519,7 @@ class FastMCP(Generic[LifespanResultT]):
fn_name = f"{prefix}_{tool.key}"
else:
fn_name = tool.key
- named_fn = _create_named_fn_wrapper(tool.fn, fn_name)
- docket.register(named_fn)
+ docket.register(tool.fn, names=[fn_name])
# Register prompts with prefixed names
for prompt in server._prompt_manager._prompts.values():
@@ -545,10 +528,10 @@ class FastMCP(Generic[LifespanResultT]):
and prompt.task_config.mode != "forbidden"
):
fn_name = f"{prefix}_{prompt.key}" if prefix else prompt.key
- named_fn = _create_named_fn_wrapper(
- cast(Callable[..., Awaitable[Any]], prompt.fn), fn_name
+ docket.register(
+ cast(Callable[..., Awaitable[Any]], prompt.fn),
+ names=[fn_name],
)
- docket.register(named_fn)
# Register resources with prefixed names (use name, not key/URI)
for resource in server._resource_manager._resources.values():
@@ -557,8 +540,7 @@ class FastMCP(Generic[LifespanResultT]):
and resource.task_config.mode != "forbidden"
):
fn_name = f"{prefix}_{resource.name}" if prefix else resource.name
- named_fn = _create_named_fn_wrapper(resource.fn, fn_name)
- docket.register(named_fn)
+ docket.register(resource.fn, names=[fn_name])
# Register resource templates with prefixed names (use name, not key/URI)
for template in server._resource_manager._templates.values():
@@ -567,8 +549,7 @@ class FastMCP(Generic[LifespanResultT]):
and template.task_config.mode != "forbidden"
):
fn_name = f"{prefix}_{template.name}" if prefix else template.name
- named_fn = _create_named_fn_wrapper(template.fn, fn_name)
- docket.register(named_fn)
+ docket.register(template.fn, names=[fn_name])
# Recursively register from nested mounted servers with accumulated prefix
for nested in server._mounted_servers:
@@ -584,6 +565,8 @@ class FastMCP(Generic[LifespanResultT]):
@asynccontextmanager
async def _lifespan_manager(self) -> AsyncIterator[None]:
if self._lifespan_result_set:
+ # Lifespan already ran - ContextVars will be set by Context.__aenter__
+ # at request time, so we just yield here.
yield
return
@@ -2516,10 +2499,7 @@ class FastMCP(Generic[LifespanResultT]):
"""
# Display server banner
if show_banner:
- log_server_banner(
- server=self,
- transport="stdio",
- )
+ log_server_banner(server=self)
with temporary_log_level(log_level):
async with self._lifespan_manager():
@@ -2582,22 +2562,9 @@ class FastMCP(Generic[LifespanResultT]):
stateless_http=stateless_http,
)
- # Get the path for the server URL
- server_path = (
- app.state.path.lstrip("/")
- if hasattr(app, "state") and hasattr(app.state, "path")
- else path or ""
- )
-
# Display server banner
if show_banner:
- log_server_banner(
- server=self,
- transport=transport,
- host=host,
- port=port,
- path=server_path,
- )
+ log_server_banner(server=self)
uvicorn_config_from_user = uvicorn_config or {}
config_kwargs: dict[str, Any] = {
diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py
index 569330449..ecfa0c808 100644
--- a/src/fastmcp/server/tasks/handlers.py
+++ b/src/fastmcp/server/tasks/handlers.py
@@ -56,6 +56,7 @@ async def handle_tool_as_task(
ctx = get_context()
session_id = ctx.session_id
+ # Get Docket from ContextVar (set by Context.__aenter__ at request time)
docket = _current_docket.get()
if docket is None:
raise McpError(
@@ -72,13 +73,15 @@ async def handle_tool_as_task(
tool = await server.get_tool(tool_name)
# Store task key mapping and creation timestamp in Redis for protocol handlers
- redis_key = f"fastmcp:task:{session_id}:{server_task_id}"
- created_at_key = f"fastmcp:task:{session_id}:{server_task_id}:created_at"
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}")
+ created_at_key = docket.key(
+ f"fastmcp:task:{session_id}:{server_task_id}:created_at"
+ )
ttl_seconds = int(
docket.execution_ttl.total_seconds() + TASK_MAPPING_TTL_BUFFER_SECONDS
)
async with docket.redis() as redis:
- await redis.set(redis_key, task_key, ex=ttl_seconds)
+ await redis.set(task_meta_key, task_key, ex=ttl_seconds)
await redis.set(created_at_key, created_at, ex=ttl_seconds)
# Send notifications/tasks/created per SEP-1686 (mandatory)
@@ -165,6 +168,7 @@ async def handle_prompt_as_task(
ctx = get_context()
session_id = ctx.session_id
+ # Get Docket from ContextVar (set by Context.__aenter__ at request time)
docket = _current_docket.get()
if docket is None:
raise McpError(
@@ -181,13 +185,15 @@ async def handle_prompt_as_task(
prompt = await server.get_prompt(prompt_name)
# Store task key mapping and creation timestamp in Redis for protocol handlers
- redis_key = f"fastmcp:task:{session_id}:{server_task_id}"
- created_at_key = f"fastmcp:task:{session_id}:{server_task_id}:created_at"
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}")
+ created_at_key = docket.key(
+ f"fastmcp:task:{session_id}:{server_task_id}:created_at"
+ )
ttl_seconds = int(
docket.execution_ttl.total_seconds() + TASK_MAPPING_TTL_BUFFER_SECONDS
)
async with docket.redis() as redis:
- await redis.set(redis_key, task_key, ex=ttl_seconds)
+ await redis.set(task_meta_key, task_key, ex=ttl_seconds)
await redis.set(created_at_key, created_at, ex=ttl_seconds)
# Send notifications/tasks/created per SEP-1686 (mandatory)
@@ -272,12 +278,13 @@ async def handle_resource_as_task(
ctx = get_context()
session_id = ctx.session_id
+ # Get Docket from ContextVar (set by Context.__aenter__ at request time)
docket = _current_docket.get()
if docket is None:
raise McpError(
ErrorData(
code=INTERNAL_ERROR,
- message="Background tasks require Docket",
+ message="Background tasks require a running FastMCP server context",
)
)
@@ -285,13 +292,15 @@ async def handle_resource_as_task(
task_key = build_task_key(session_id, server_task_id, "resource", str(uri))
# Store task key mapping and creation timestamp in Redis for protocol handlers
- redis_key = f"fastmcp:task:{session_id}:{server_task_id}"
- created_at_key = f"fastmcp:task:{session_id}:{server_task_id}:created_at"
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}")
+ created_at_key = docket.key(
+ f"fastmcp:task:{session_id}:{server_task_id}:created_at"
+ )
ttl_seconds = int(
docket.execution_ttl.total_seconds() + TASK_MAPPING_TTL_BUFFER_SECONDS
)
async with docket.redis() as redis:
- await redis.set(redis_key, task_key, ex=ttl_seconds)
+ await redis.set(task_meta_key, task_key, ex=ttl_seconds)
await redis.set(created_at_key, created_at, ex=ttl_seconds)
# Send notifications/tasks/created per SEP-1686 (mandatory)
diff --git a/src/fastmcp/server/tasks/protocol.py b/src/fastmcp/server/tasks/protocol.py
index 902947862..8ada3749e 100644
--- a/src/fastmcp/server/tasks/protocol.py
+++ b/src/fastmcp/server/tasks/protocol.py
@@ -77,10 +77,12 @@ async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskR
)
# Look up full task key and creation timestamp from Redis
- redis_key = f"fastmcp:task:{session_id}:{client_task_id}"
- created_at_key = f"fastmcp:task:{session_id}:{client_task_id}:created_at"
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
+ created_at_key = docket.key(
+ f"fastmcp:task:{session_id}:{client_task_id}:created_at"
+ )
async with docket.redis() as redis:
- task_key_bytes = await redis.get(redis_key)
+ task_key_bytes = await redis.get(task_meta_key)
created_at_bytes = await redis.get(created_at_key)
task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
@@ -176,9 +178,9 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
)
# Look up full task key from Redis
- redis_key = f"fastmcp:task:{session_id}:{client_task_id}"
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
async with docket.redis() as redis:
- task_key_bytes = await redis.get(redis_key)
+ task_key_bytes = await redis.get(task_meta_key)
task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
@@ -309,10 +311,12 @@ async def tasks_cancel_handler(
)
# Look up full task key and creation timestamp from Redis
- redis_key = f"fastmcp:task:{session_id}:{client_task_id}"
- created_at_key = f"fastmcp:task:{session_id}:{client_task_id}:created_at"
+ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
+ created_at_key = docket.key(
+ f"fastmcp:task:{session_id}:{client_task_id}:created_at"
+ )
async with docket.redis() as redis:
- task_key_bytes = await redis.get(redis_key)
+ task_key_bytes = await redis.get(task_meta_key)
created_at_bytes = await redis.get(created_at_key)
task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
diff --git a/src/fastmcp/server/tasks/subscriptions.py b/src/fastmcp/server/tasks/subscriptions.py
index dae18675b..c22e88f04 100644
--- a/src/fastmcp/server/tasks/subscriptions.py
+++ b/src/fastmcp/server/tasks/subscriptions.py
@@ -70,7 +70,7 @@ async def subscribe_to_task_updates(
)
except Exception as e:
- logger.warning(f"Subscription task failed for {task_id}: {e}", exc_info=True)
+ logger.error(f"subscribe_to_task_updates failed for {task_id}: {e}")
async def _send_status_notification(
@@ -101,8 +101,7 @@ async def _send_status_notification(
key_parts = parse_task_key(task_key)
session_id = key_parts["session_id"]
- # Retrieve createdAt timestamp from Redis
- created_at_key = f"fastmcp:task:{session_id}:{task_id}:created_at"
+ created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at")
async with docket.redis() as redis:
created_at_bytes = await redis.get(created_at_key)
@@ -175,8 +174,7 @@ async def _send_progress_notification(
key_parts = parse_task_key(task_key)
session_id = key_parts["session_id"]
- # Retrieve createdAt timestamp from Redis
- created_at_key = f"fastmcp:task:{session_id}:{task_id}:created_at"
+ created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at")
async with docket.redis() as redis:
created_at_bytes = await redis.get(created_at_key)
diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py
index fc1940321..898d580dc 100644
--- a/src/fastmcp/settings.py
+++ b/src/fastmcp/settings.py
@@ -392,6 +392,21 @@ class Settings(BaseSettings):
),
] = True
+ check_for_updates: Annotated[
+ Literal["stable", "prerelease", "off"],
+ Field(
+ description=inspect.cleandoc(
+ """
+ Controls update checking when displaying the CLI banner.
+ - "stable": Check for stable releases only (default)
+ - "prerelease": Also check for pre-release versions (alpha, beta, rc)
+ - "off": Disable update checking entirely
+ Set via FASTMCP_CHECK_FOR_UPDATES environment variable.
+ """
+ ),
+ ),
+ ] = "stable"
+
@property
def server_auth_class(self) -> AuthProvider | None:
from fastmcp.utilities.types import get_cached_typeadapter
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index 191f4737a..e40a7115e 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -32,7 +32,7 @@ import fastmcp
from fastmcp.server.dependencies import get_context, without_injected_parameters
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.components import FastMCPComponent
-from fastmcp.utilities.json_schema import compress_schema
+from fastmcp.utilities.json_schema import compress_schema, resolve_root_ref
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import (
Audio,
@@ -559,6 +559,10 @@ class ParsedFunction:
output_schema = compress_schema(output_schema, prune_titles=True)
+ # Resolve root-level $ref to meet MCP spec requirement for type: object
+ # Self-referential Pydantic models generate schemas with $ref at root
+ output_schema = resolve_root_ref(output_schema)
+
except PydanticSchemaGenerationError as e:
if "_UnserializableType" not in str(e):
logger.debug(f"Unable to generate schema for type {output_type!r}")
diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py
index 9c6ee8860..e1d1f5c31 100644
--- a/src/fastmcp/tools/tool_manager.py
+++ b/src/fastmcp/tools/tool_manager.py
@@ -8,7 +8,7 @@ from mcp.types import ToolAnnotations
from pydantic import ValidationError
from fastmcp import settings
-from fastmcp.exceptions import NotFoundError, ToolError
+from fastmcp.exceptions import FastMCPError, NotFoundError, ToolError
from fastmcp.settings import DuplicateBehavior
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.tools.tool_transform import (
@@ -158,12 +158,10 @@ class ToolManager:
tool = await self.get_tool(key)
try:
return await tool.run(arguments)
- except ValidationError as e:
- logger.exception(f"Error validating tool {key!r}: {e}")
- raise e
- except ToolError as e:
- logger.exception(f"Error calling tool {key!r}")
- raise e
+ except FastMCPError:
+ raise
+ except ValidationError:
+ raise
except Exception as e:
logger.exception(f"Error calling tool {key!r}")
if self.mask_error_details:
diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py
index 24a776616..13d496587 100644
--- a/src/fastmcp/utilities/cli.py
+++ b/src/fastmcp/utilities/cli.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import os
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Literal
+from typing import TYPE_CHECKING, Any
from pydantic import ValidationError
from rich.align import Align
@@ -17,6 +17,7 @@ from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.mcp_server_config import MCPServerConfig
from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
from fastmcp.utilities.types import get_cached_typeadapter
+from fastmcp.utilities.version_check import check_for_newer_version
if TYPE_CHECKING:
from fastmcp import FastMCP
@@ -197,23 +198,11 @@ LOGO_ASCII_4 = (
)
-def log_server_banner(
- server: FastMCP[Any],
- transport: Literal["stdio", "http", "sse", "streamable-http"],
- *,
- host: str | None = None,
- port: int | None = None,
- path: str | None = None,
-) -> None:
- """Creates and logs a formatted banner with server information and logo.
+def log_server_banner(server: FastMCP[Any]) -> None:
+ """Creates and logs a formatted banner with server information and logo."""
- Args:
- transport: The transport protocol being used
- server_name: Optional server name to display
- host: Host address (for HTTP transports)
- port: Port number (for HTTP transports)
- path: Server path (for HTTP transports)
- """
+ # Check for updates (non-blocking, fails silently)
+ newer_version = check_for_newer_version()
# Create the logo text
# Use Text with no_wrap and markup disabled to preserve ANSI escape codes
@@ -228,39 +217,36 @@ def log_server_banner(
info_table.add_column(style="cyan", justify="left") # Label column
info_table.add_column(style="dim", justify="left") # Value column
- match transport:
- case "http" | "streamable-http":
- display_transport = "HTTP"
- case "sse":
- display_transport = "SSE"
- case "stdio":
- display_transport = "STDIO"
-
- info_table.add_row("๐ฅ", "Server name:", Text(server.name + "\n", style="bold blue"))
- info_table.add_row("๐ฆ", "Transport:", display_transport)
-
- # Show connection info based on transport
- if transport in ("http", "streamable-http", "sse") and host and port:
- server_url = f"http://{host}:{port}"
- if path:
- server_url += f"/{path.lstrip('/')}"
- info_table.add_row("๐", "Server URL:", server_url)
-
- # Add documentation link
- info_table.add_row("", "", "")
- info_table.add_row("๐", "Docs:", "https://gofastmcp.com")
- info_table.add_row("๐", "Hosting:", "https://fastmcp.cloud")
+ info_table.add_row("๐ฅ", "Server:", Text(server.name, style="dim"))
+ info_table.add_row("๐", "Deploy free:", "https://fastmcp.cloud")
# Create panel with logo, title, and information using Group
+ docs_url = Text("https://gofastmcp.com", style="dim")
panel_content = Group(
+ "",
Align.center(logo_text),
"",
- Align.center(title_text),
"",
+ Align.center(title_text),
+ Align.center(docs_url),
"",
Align.center(info_table),
)
+ # v3 notice banner (shown below main panel)
+ v3_line1 = Text("โจ FastMCP 3.0 is coming!", style="bold")
+ v3_line2 = Text.assemble(
+ ("Pin ", "dim"),
+ ("`fastmcp < 3`", "dim bold"),
+ (" in production, then upgrade when you're ready.", "dim"),
+ )
+ v3_notice = Panel(
+ Group(Align.center(v3_line1), Align.center(v3_line2)),
+ border_style="blue",
+ padding=(0, 2),
+ width=80,
+ )
+
panel = Panel(
panel_content,
border_style="dim",
@@ -270,5 +256,26 @@ def log_server_banner(
)
console = Console(stderr=True)
- # Center the panel itself
- console.print(Group("\n", Align.center(panel), "\n"))
+
+ # Build output elements
+ output_elements: list[Align | Panel | str] = ["\n", Align.center(panel)]
+ output_elements.append(Align.center(v3_notice))
+
+ # Add update notice if a newer version is available (shown last for visibility)
+ if newer_version:
+ update_line1 = Text.assemble(
+ ("๐ Update available: ", "bold"),
+ (newer_version, "bold green"),
+ )
+ update_line2 = Text("Run: pip install --upgrade fastmcp", style="dim")
+ update_notice = Panel(
+ Group(Align.center(update_line1), Align.center(update_line2)),
+ border_style="blue",
+ padding=(0, 2),
+ width=80,
+ )
+ output_elements.append(Align.center(update_notice))
+
+ output_elements.append("\n")
+
+ console.print(Group(*output_elements))
diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py
index b6364757f..7565c0dbb 100644
--- a/src/fastmcp/utilities/json_schema.py
+++ b/src/fastmcp/utilities/json_schema.py
@@ -1,6 +1,46 @@
from __future__ import annotations
from collections import defaultdict
+from typing import Any
+
+
+def resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
+ """Resolve $ref at root level to meet MCP spec requirements.
+
+ MCP specification requires outputSchema to have "type": "object" at the root level.
+ When Pydantic generates schemas for self-referential models, it uses $ref at the
+ root level pointing to $defs. This function resolves such references by inlining
+ the referenced definition while preserving $defs for nested references.
+
+ Args:
+ schema: JSON schema dict that may have $ref at root level
+
+ Returns:
+ A new schema dict with root-level $ref resolved, or the original schema
+ if no resolution is needed
+
+ Example:
+ >>> schema = {
+ ... "$defs": {"Node": {"type": "object", "properties": {...}}},
+ ... "$ref": "#/$defs/Node"
+ ... }
+ >>> resolved = resolve_root_ref(schema)
+ >>> # Result: {"type": "object", "properties": {...}, "$defs": {...}}
+ """
+ # Only resolve if we have $ref at root level with $defs but no explicit type
+ if "$ref" in schema and "$defs" in schema and "type" not in schema:
+ ref = schema["$ref"]
+ # Only handle local $defs references
+ if isinstance(ref, str) and ref.startswith("#/$defs/"):
+ def_name = ref.split("/")[-1]
+ defs = schema["$defs"]
+ if def_name in defs:
+ # Create a new schema by copying the referenced definition
+ resolved = dict(defs[def_name])
+ # Preserve $defs for nested references (other fields may still use them)
+ resolved["$defs"] = defs
+ return resolved
+ return schema
def _prune_param(schema: dict, param: str) -> dict:
diff --git a/src/fastmcp/utilities/openapi/schemas.py b/src/fastmcp/utilities/openapi/schemas.py
index 679fe2397..5142f12a5 100644
--- a/src/fastmcp/utilities/openapi/schemas.py
+++ b/src/fastmcp/utilities/openapi/schemas.py
@@ -539,9 +539,9 @@ def extract_output_schema_from_responses(
# Replace $ref with the actual schema definition
output_schema = _replace_ref_with_defs(schema_definitions[schema_name])
- # Convert OpenAPI schema to JSON Schema format
- # Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
- if openapi_version and openapi_version.startswith("3.0"):
+ if openapi_version and openapi_version.startswith("3"):
+ # Convert OpenAPI 3.x schema to JSON Schema format for proper handling
+ # of constructs like oneOf, anyOf, and nullable fields
from .json_schema_converter import convert_openapi_schema_to_json_schema
output_schema = convert_openapi_schema_to_json_schema(
@@ -570,7 +570,7 @@ def extract_output_schema_from_responses(
processed_defs[name] = _replace_ref_with_defs(schema)
# Convert OpenAPI schema definitions to JSON Schema format if needed
- if openapi_version and openapi_version.startswith("3.0"):
+ if openapi_version and openapi_version.startswith("3"):
from .json_schema_converter import convert_openapi_schema_to_json_schema
for def_name in list(processed_defs.keys()):
diff --git a/src/fastmcp/utilities/version_check.py b/src/fastmcp/utilities/version_check.py
new file mode 100644
index 000000000..5c3aba634
--- /dev/null
+++ b/src/fastmcp/utilities/version_check.py
@@ -0,0 +1,153 @@
+"""Version checking utilities for FastMCP."""
+
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+
+import httpx
+from packaging.version import Version
+
+from fastmcp.utilities.logging import get_logger
+
+logger = get_logger(__name__)
+
+PYPI_URL = "https://pypi.org/pypi/fastmcp/json"
+CACHE_TTL_SECONDS = 60 * 60 * 12 # 12 hours
+REQUEST_TIMEOUT_SECONDS = 2.0
+
+
+def _get_cache_path(include_prereleases: bool = False) -> Path:
+ """Get the path to the version cache file."""
+ import fastmcp
+
+ suffix = "_prerelease" if include_prereleases else ""
+ return fastmcp.settings.home / f"version_cache{suffix}.json"
+
+
+def _read_cache(include_prereleases: bool = False) -> tuple[str | None, float]:
+ """Read cached version info.
+
+ Returns:
+ Tuple of (cached_version, cache_timestamp) or (None, 0) if no cache.
+ """
+ cache_path = _get_cache_path(include_prereleases)
+ if not cache_path.exists():
+ return None, 0
+
+ try:
+ data = json.loads(cache_path.read_text())
+ return data.get("latest_version"), data.get("timestamp", 0)
+ except (json.JSONDecodeError, OSError):
+ return None, 0
+
+
+def _write_cache(latest_version: str, include_prereleases: bool = False) -> None:
+ """Write version info to cache."""
+ cache_path = _get_cache_path(include_prereleases)
+ try:
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
+ cache_path.write_text(
+ json.dumps({"latest_version": latest_version, "timestamp": time.time()})
+ )
+ except OSError:
+ # Silently ignore cache write failures
+ pass
+
+
+def _fetch_latest_version(include_prereleases: bool = False) -> str | None:
+ """Fetch the latest version from PyPI.
+
+ Args:
+ include_prereleases: If True, include pre-release versions (alpha, beta, rc).
+
+ Returns:
+ The latest version string, or None if the fetch failed.
+ """
+ try:
+ response = httpx.get(PYPI_URL, timeout=REQUEST_TIMEOUT_SECONDS)
+ response.raise_for_status()
+ data = response.json()
+
+ releases = data.get("releases", {})
+ if not releases:
+ return None
+
+ versions = []
+ for version_str in releases:
+ try:
+ v = Version(version_str)
+ # Skip prereleases if not requested
+ if not include_prereleases and v.is_prerelease:
+ continue
+ versions.append(v)
+ except ValueError:
+ logger.debug(f"Skipping invalid version string: {version_str}")
+ continue
+
+ if not versions:
+ return None
+
+ return str(max(versions))
+
+ except (httpx.HTTPError, json.JSONDecodeError, KeyError):
+ return None
+
+
+def get_latest_version(include_prereleases: bool = False) -> str | None:
+ """Get the latest version of FastMCP from PyPI, using cache when available.
+
+ Args:
+ include_prereleases: If True, include pre-release versions.
+
+ Returns:
+ The latest version string, or None if unavailable.
+ """
+ # Check cache first
+ cached_version, cache_timestamp = _read_cache(include_prereleases)
+ if cached_version and (time.time() - cache_timestamp) < CACHE_TTL_SECONDS:
+ return cached_version
+
+ # Fetch from PyPI
+ latest_version = _fetch_latest_version(include_prereleases)
+
+ # Update cache if we got a valid version
+ if latest_version:
+ _write_cache(latest_version, include_prereleases)
+ return latest_version
+
+ # Return stale cache if available
+ return cached_version
+
+
+def check_for_newer_version() -> str | None:
+ """Check if a newer version of FastMCP is available.
+
+ Returns:
+ The latest version string if newer than current, None otherwise.
+ """
+ import fastmcp
+
+ setting = fastmcp.settings.check_for_updates
+ if setting == "off":
+ return None
+
+ include_prereleases = setting == "prerelease"
+ latest_version = get_latest_version(include_prereleases)
+ if not latest_version:
+ return None
+
+ try:
+ current = Version(fastmcp.__version__)
+ latest = Version(latest_version)
+
+ if latest > current:
+ return latest_version
+ except ValueError:
+ logger.debug(
+ f"Could not compare versions: current={fastmcp.__version__!r}, "
+ f"latest={latest_version!r}"
+ )
+
+ return None
diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py
index 832d7ce02..cc6d6e373 100644
--- a/tests/client/auth/test_oauth_client.py
+++ b/tests/client/auth/test_oauth_client.py
@@ -171,3 +171,81 @@ class TestOAuthClientUrlHandling:
# Token storage should key by the full URL, not just the host
assert oauth.token_storage_adapter._server_url == mcp_url
+
+
+class TestTokenStorageTTL:
+ """Tests for client token storage TTL behavior (issue #2670).
+
+ The token storage TTL should NOT be based on access token expiry, because
+ the refresh token may be valid much longer. Using access token expiry would
+ cause both tokens to be deleted when the access token expires, preventing
+ refresh.
+ """
+
+ async def test_token_storage_uses_long_ttl(self):
+ """Token storage should use a long TTL, not access token expiry.
+
+ This is the ianw case: IdP returns expires_in=300 (5 min access token)
+ but the refresh token is valid for much longer. The entire token entry
+ should NOT be deleted after 5 minutes.
+ """
+ from key_value.aio.stores.memory import MemoryStore
+ from mcp.shared.auth import OAuthToken
+
+ from fastmcp.client.auth.oauth import TokenStorageAdapter
+
+ # Create storage adapter
+ storage = MemoryStore()
+ adapter = TokenStorageAdapter(
+ async_key_value=storage, server_url="https://test"
+ )
+
+ # Create a token with short access expiry (5 minutes)
+ token = OAuthToken(
+ access_token="test-access-token",
+ token_type="Bearer",
+ expires_in=300, # 5 minutes - but we should NOT use this as storage TTL!
+ refresh_token="test-refresh-token",
+ scope="read write",
+ )
+
+ # Store the token
+ await adapter.set_tokens(token)
+
+ # Verify token is stored
+ stored = await adapter.get_tokens()
+ assert stored is not None
+ assert stored.access_token == "test-access-token"
+ assert stored.refresh_token == "test-refresh-token"
+
+ # The key assertion: the TTL should be 1 year (365 days), not 300 seconds
+ # We verify this by checking the raw storage entry
+ raw = await storage.get(collection="mcp-oauth-token", key="https://test/tokens")
+ assert raw is not None
+
+ async def test_token_storage_preserves_refresh_token(self):
+ """Refresh token should not be lost when access token would expire."""
+ from key_value.aio.stores.memory import MemoryStore
+ from mcp.shared.auth import OAuthToken
+
+ from fastmcp.client.auth.oauth import TokenStorageAdapter
+
+ storage = MemoryStore()
+ adapter = TokenStorageAdapter(
+ async_key_value=storage, server_url="https://test"
+ )
+
+ # Store token with short access expiry
+ token = OAuthToken(
+ access_token="access",
+ token_type="Bearer",
+ expires_in=300,
+ refresh_token="refresh-token-should-survive",
+ scope="read",
+ )
+ await adapter.set_tokens(token)
+
+ # Retrieve and verify refresh token is present
+ stored = await adapter.get_tokens()
+ assert stored is not None
+ assert stored.refresh_token == "refresh-token-should-survive"
diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py
index b762eef26..47e962cd7 100644
--- a/tests/client/test_elicitation.py
+++ b/tests/client/test_elicitation.py
@@ -742,6 +742,17 @@ async def test_dict_based_titled_single_select():
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:
@@ -800,14 +811,15 @@ async def test_list_dict_multi_select_titled():
return "declined"
async def elicitation_handler(message, response_type, params, ctx):
- # Verify schema has array with anyOf pattern
+ # 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"
- assert "anyOf" in value_schema["items"]
- any_of = value_schema["items"]["anyOf"]
+ 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
@@ -895,7 +907,7 @@ async def test_validation_allows_enum_arrays():
async def test_validation_allows_enum_arrays_with_anyof():
- """Test validation accepts arrays with anyOf enum pattern."""
+ """Test validation accepts arrays with anyOf enum pattern (SEP-1330 compliant)."""
schema = {
"type": "object",
"properties": {
diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py
index b0c9199b2..91530cf0d 100644
--- a/tests/client/test_sse.py
+++ b/tests/client/test_sse.py
@@ -117,17 +117,16 @@ async def nested_sse_server():
ws="websockets-sansio",
)
- server_task = asyncio.create_task(uvicorn.Server(config).serve())
+ uvicorn_server = uvicorn.Server(config)
+ server_task = asyncio.create_task(uvicorn_server.serve())
await asyncio.sleep(0.1)
try:
yield f"http://127.0.0.1:{port}/nest-outer/nest-inner/mcp/sse/"
finally:
- server_task.cancel()
- try:
- await server_task
- except asyncio.CancelledError:
- pass
+ # Graceful shutdown - required for uvicorn 0.39+ due to context isolation
+ uvicorn_server.should_exit = True
+ await server_task
async def test_run_server_on_path(sse_server_custom_path: str):
diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py
index 5c1753d9a..d64844b6a 100644
--- a/tests/client/test_streamable_http.py
+++ b/tests/client/test_streamable_http.py
@@ -1,6 +1,7 @@
import asyncio
import json
import sys
+from contextlib import suppress
from unittest.mock import AsyncMock, call
import pytest
@@ -106,8 +107,8 @@ async def nested_server():
from fastmcp.utilities.http import find_available_port
- server = create_test_server()
- mcp_app = server.http_app(path="/final/mcp")
+ mcp_server = create_test_server()
+ mcp_app = mcp_server.http_app(path="/final/mcp")
# Nest the app under multiple mounts to test URL resolution
inner = Starlette(routes=[Mount("/nest-inner", app=mcp_app)])
@@ -124,20 +125,19 @@ async def nested_server():
port=port,
log_level="critical",
ws="websockets-sansio",
+ timeout_graceful_shutdown=0,
)
- # Use the simple asyncio pattern
- server_task = asyncio.create_task(uvicorn.Server(config).serve())
+ uvicorn_server = uvicorn.Server(config)
+ server_task = asyncio.create_task(uvicorn_server.serve())
await asyncio.sleep(0.1)
yield f"http://127.0.0.1:{port}/nest-outer/nest-inner/final/mcp"
- # Cleanup
- server_task.cancel()
- try:
- await server_task
- except asyncio.CancelledError:
- pass
+ # Cleanup: signal uvicorn to shutdown, then cancel the task
+ uvicorn_server.should_exit = True
+ with suppress(asyncio.CancelledError, asyncio.TimeoutError):
+ await asyncio.wait_for(server_task, timeout=2.0)
async def test_ping(streamable_http_server: str):
diff --git a/tests/client/transports/test_uv_transport.py b/tests/client/transports/test_uv_transport.py
index 020f084ba..2917045b8 100644
--- a/tests/client/transports/test_uv_transport.py
+++ b/tests/client/transports/test_uv_transport.py
@@ -5,10 +5,15 @@ from pathlib import Path
import pytest
+import fastmcp
from fastmcp.client import Client
from fastmcp.client.client import CallToolResult
-from fastmcp.client.transports import (
- UvStdioTransport,
+from fastmcp.client.transports import StdioTransport, UvStdioTransport
+
+# Detect if running from dev install to use local source instead of PyPI
+_is_dev_install = "dev" in fastmcp.__version__
+_fastmcp_src_dir = (
+ Path(__file__).parent.parent.parent.parent if _is_dev_install else None
)
@@ -79,15 +84,32 @@ async def test_uv_transport_module():
main_file = module_dir / "__main__.py"
_ = main_file.write_text(main_script)
- client: Client[UvStdioTransport] = Client(
- transport=UvStdioTransport(
+ # In dev installs, use --with-editable to install local source.
+ # In releases, use --with to install from PyPI.
+ if _is_dev_install and _fastmcp_src_dir:
+ transport: StdioTransport = StdioTransport(
+ command="uv",
+ args=[
+ "run",
+ "--directory",
+ tmpdir,
+ "--with-editable",
+ str(_fastmcp_src_dir),
+ "--module",
+ "my_module",
+ ],
+ keep_alive=False,
+ )
+ else:
+ transport = UvStdioTransport(
with_packages=["fastmcp"],
command="my_module",
module=True,
project_directory=Path(tmpdir),
keep_alive=False,
)
- )
+
+ client: Client[StdioTransport] = Client(transport=transport)
async with client:
result: CallToolResult = await client.call_tool("add", {"x": 1, "y": 2})
diff --git a/tests/integration_tests/test_timeout_fix.py b/tests/integration_tests/test_timeout_fix.py
new file mode 100644
index 000000000..27776c1ba
--- /dev/null
+++ b/tests/integration_tests/test_timeout_fix.py
@@ -0,0 +1,51 @@
+"""Test that verifies the timeout fix for issue #2842 and #2845."""
+
+import asyncio
+
+import pytest
+
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.client.transports import StreamableHttpTransport
+from fastmcp.utilities.tests import run_server_async
+
+
+def create_test_server() -> FastMCP:
+ """Create a FastMCP server with a slow tool."""
+ server = FastMCP("TestServer")
+
+ @server.tool
+ async def slow_tool(duration: int = 6) -> str:
+ """A tool that takes some time to complete."""
+ await asyncio.sleep(duration)
+ return f"Completed in {duration} seconds"
+
+ return server
+
+
+@pytest.fixture
+async def streamable_http_server():
+ """Start a test server and return its URL."""
+ server = create_test_server()
+ async with run_server_async(server) as url:
+ yield url
+
+
+@pytest.mark.integration
+@pytest.mark.timeout(15)
+async def test_slow_tool_with_http_transport(streamable_http_server: str):
+ """Test that tools taking >5 seconds work correctly with HTTP transport.
+
+ This test verifies the fix for:
+ - Issue #2842: Client can't get result after upgrading to 2.14.2
+ - Issue #2845: Server doesn't return results when tool takes >5 seconds
+
+ The root cause was that the httpx client was created without explicit
+ timeout configuration, defaulting to httpx's 5-second timeout.
+ """
+ async with Client(
+ transport=StreamableHttpTransport(streamable_http_server)
+ ) as client:
+ # This should NOT timeout since we fixed the default timeout
+ result = await client.call_tool("slow_tool", {"duration": 6})
+ assert result.data == "Completed in 6 seconds"
diff --git a/tests/server/auth/providers/test_supabase.py b/tests/server/auth/providers/test_supabase.py
index 7cdc08130..5944fcfbb 100644
--- a/tests/server/auth/providers/test_supabase.py
+++ b/tests/server/auth/providers/test_supabase.py
@@ -40,12 +40,14 @@ class TestSupabaseProvider:
{
"FASTMCP_SERVER_AUTH_SUPABASE_PROJECT_URL": "https://env123.supabase.co",
"FASTMCP_SERVER_AUTH_SUPABASE_BASE_URL": "https://envserver.com",
+ "FASTMCP_SERVER_AUTH_SUPABASE_AUTH_ROUTE": "/custom/auth/route",
},
):
provider = SupabaseProvider()
assert provider.project_url == "https://env123.supabase.co"
assert str(provider.base_url) == "https://envserver.com/"
+ assert provider.auth_route == "custom/auth/route"
def test_environment_variable_loading(self):
"""Test that environment variables are loaded correctly."""
@@ -150,6 +152,28 @@ class TestSupabaseProvider:
assert provider.token_verifier.algorithm == "RS256" # type: ignore[attr-defined]
+ def test_custom_auth_route(self):
+ provider = SupabaseProvider(
+ project_url="https://abc123.supabase.co",
+ base_url="https://myserver.com",
+ auth_route="/custom/auth/route",
+ )
+
+ assert provider.auth_route == "custom/auth/route"
+ assert (
+ provider.token_verifier.jwks_uri
+ == "https://abc123.supabase.co/custom/auth/route/.well-known/jwks.json"
+ ) # type: ignore[attr-defined]
+
+ def test_custom_auth_route_trailing_slash(self):
+ provider = SupabaseProvider(
+ project_url="https://abc123.supabase.co",
+ base_url="https://myserver.com",
+ auth_route="/custom/auth/route/",
+ )
+
+ assert provider.auth_route == "custom/auth/route"
+
def run_mcp_server(host: str, port: int) -> None:
mcp = FastMCP(
diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py
index 8c214ca20..22d5a7c5b 100644
--- a/tests/server/auth/test_oauth_proxy.py
+++ b/tests/server/auth/test_oauth_proxy.py
@@ -641,6 +641,9 @@ class TestOAuthProxyTokenEndpointAuth:
jwt_signing_key="test-secret",
)
+ # Initialize JWT issuer before token operations
+ proxy.set_mcp_path("/mcp")
+
# First, create a valid FastMCP token via full OAuth flow
client = OAuthClientInformationFull(
client_id="test-client",
@@ -816,6 +819,9 @@ class TestOAuthProxyE2E:
jwt_signing_key="test-secret",
)
+ # Initialize JWT issuer before token operations
+ proxy.set_mcp_path("/mcp")
+
client = OAuthClientInformationFull(
client_id="test-client",
client_secret="test-secret",
@@ -1468,3 +1474,392 @@ class TestFallbackAccessTokenExpiry:
)
assert provider._fallback_access_token_expiry_seconds is None
+
+
+class TestResourceURLValidation:
+ """Tests for OAuth Proxy resource URL validation (GHSA-5h2m-4q8j-pqpj fix)."""
+
+ @pytest.fixture
+ def proxy_with_resource_url(self, jwt_verifier):
+ """Create an OAuthProxy with set_mcp_path called."""
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=jwt_verifier,
+ base_url="https://proxy.example.com",
+ jwt_signing_key="test-secret",
+ )
+ # Use non-default path to prove fix isn't relying on old hardcoded /mcp
+ proxy.set_mcp_path("/api/v2/mcp")
+ return proxy
+
+ async def test_authorize_rejects_mismatched_resource(self, proxy_with_resource_url):
+ """Test that authorization rejects requests with mismatched resource."""
+ from mcp.server.auth.provider import AuthorizeError
+
+ client = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+
+ await proxy_with_resource_url.register_client(client)
+
+ # Client requests a different resource than the server's
+ params = AuthorizationParams(
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ state="client-state",
+ code_challenge="challenge",
+ scopes=["read"],
+ resource="https://malicious-server.com/mcp", # Wrong resource
+ )
+
+ with pytest.raises(AuthorizeError) as exc_info:
+ await proxy_with_resource_url.authorize(client, params)
+
+ assert exc_info.value.error == "invalid_target"
+ assert "Resource does not match" in exc_info.value.error_description
+
+ async def test_authorize_accepts_matching_resource(self, proxy_with_resource_url):
+ """Test that authorization accepts requests with matching resource."""
+ client = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+
+ await proxy_with_resource_url.register_client(client)
+
+ # Client requests the correct resource (must match /api/v2/mcp path)
+ params = AuthorizationParams(
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ state="client-state",
+ code_challenge="challenge",
+ scopes=["read"],
+ resource="https://proxy.example.com/api/v2/mcp", # Correct resource
+ )
+
+ # Should succeed (redirect to consent page)
+ redirect_url = await proxy_with_resource_url.authorize(client, params)
+ assert "/consent" in redirect_url
+
+ async def test_authorize_rejects_old_hardcoded_mcp_path(
+ self, proxy_with_resource_url
+ ):
+ """Test that old hardcoded /mcp path is rejected when server uses different path."""
+ from mcp.server.auth.provider import AuthorizeError
+
+ client = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+
+ await proxy_with_resource_url.register_client(client)
+
+ # Client requests the old hardcoded /mcp path (would have worked before fix)
+ params = AuthorizationParams(
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ state="client-state",
+ code_challenge="challenge",
+ scopes=["read"],
+ resource="https://proxy.example.com/mcp", # Old hardcoded path
+ )
+
+ # Should fail because server is at /api/v2/mcp, not /mcp
+ with pytest.raises(AuthorizeError) as exc_info:
+ await proxy_with_resource_url.authorize(client, params)
+
+ assert exc_info.value.error == "invalid_target"
+
+ async def test_authorize_accepts_no_resource(self, proxy_with_resource_url):
+ """Test that authorization accepts requests without resource parameter."""
+ client = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+
+ await proxy_with_resource_url.register_client(client)
+
+ # Client doesn't specify resource
+ params = AuthorizationParams(
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ state="client-state",
+ code_challenge="challenge",
+ scopes=["read"],
+ # No resource parameter
+ )
+
+ # Should succeed (no resource check needed)
+ redirect_url = await proxy_with_resource_url.authorize(client, params)
+ assert "/consent" in redirect_url
+
+ def test_set_mcp_path_creates_jwt_issuer_with_correct_audience(self, jwt_verifier):
+ """Test that set_mcp_path creates JWTIssuer with correct audience."""
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=jwt_verifier,
+ base_url="https://proxy.example.com",
+ jwt_signing_key="test-secret",
+ )
+
+ # Before set_mcp_path, _jwt_issuer is None
+ assert proxy._jwt_issuer is None
+
+ # Call set_mcp_path with custom path
+ proxy.set_mcp_path("/custom/mcp")
+
+ # After set_mcp_path, _jwt_issuer should be created
+ assert proxy._jwt_issuer is not None
+ assert proxy.jwt_issuer.audience == "https://proxy.example.com/custom/mcp"
+ assert proxy.jwt_issuer.issuer == "https://proxy.example.com/"
+
+ def test_set_mcp_path_uses_base_url_if_no_path(self, jwt_verifier):
+ """Test that set_mcp_path uses base_url as audience if no path provided."""
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=jwt_verifier,
+ base_url="https://proxy.example.com",
+ jwt_signing_key="test-secret",
+ )
+
+ proxy.set_mcp_path(None)
+
+ assert proxy.jwt_issuer.audience == "https://proxy.example.com/"
+
+ def test_jwt_issuer_property_raises_if_not_initialized(self, jwt_verifier):
+ """Test that jwt_issuer property raises if set_mcp_path not called."""
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=jwt_verifier,
+ base_url="https://proxy.example.com",
+ jwt_signing_key="test-secret",
+ )
+
+ with pytest.raises(RuntimeError) as exc_info:
+ _ = proxy.jwt_issuer
+
+ assert "JWT issuer not initialized" in str(exc_info.value)
+
+ def test_get_routes_calls_set_mcp_path(self, jwt_verifier):
+ """Test that get_routes() calls set_mcp_path() to initialize JWT issuer."""
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=jwt_verifier,
+ base_url="https://proxy.example.com",
+ jwt_signing_key="test-secret",
+ )
+
+ # Before get_routes, _jwt_issuer is None
+ assert proxy._jwt_issuer is None
+
+ # get_routes should call set_mcp_path internally
+ proxy.get_routes("/api/mcp")
+
+ # After get_routes, _jwt_issuer should be created with correct audience
+ assert proxy._jwt_issuer is not None
+ assert proxy.jwt_issuer.audience == "https://proxy.example.com/api/mcp"
+
+
+class TestUpstreamTokenStorageTTL:
+ """Tests for upstream token storage TTL calculation (issue #2670).
+
+ The TTL should use max(refresh_expires_in, expires_in) to handle cases where
+ the refresh token has a shorter lifetime than the access token (e.g., Keycloak
+ with sliding session windows).
+ """
+
+ @pytest.fixture
+ def jwt_verifier(self):
+ """Create a mock JWT verifier."""
+ verifier = Mock(spec=TokenVerifier)
+ verifier.required_scopes = ["read", "write"]
+ verifier.verify_token = AsyncMock(return_value=None)
+ return verifier
+
+ @pytest.fixture
+ def proxy(self, jwt_verifier):
+ """Create an OAuth proxy for testing."""
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://idp.example.com/authorize",
+ upstream_token_endpoint="https://idp.example.com/token",
+ upstream_client_id="test-client",
+ upstream_client_secret="test-secret",
+ token_verifier=jwt_verifier,
+ base_url="https://proxy.example.com",
+ jwt_signing_key="test-secret-key",
+ )
+ proxy.set_mcp_path("/mcp")
+ return proxy
+
+ async def test_ttl_uses_max_when_refresh_shorter_than_access(self, proxy):
+ """TTL should use access token expiry when refresh is shorter.
+
+ This is the xsreality case: Keycloak returns refresh_expires_in=120 (2 min)
+ but expires_in=28800 (8 hours). The upstream tokens should persist for
+ 8 hours (the access token lifetime), not 2 minutes.
+ """
+ from fastmcp.server.auth.oauth_proxy import ClientCode
+
+ # Register client
+ client = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+ await proxy.register_client(client)
+
+ # Simulate xsreality's Keycloak setup: short refresh, long access
+ client_code = ClientCode(
+ code="test-auth-code",
+ client_id="test-client",
+ redirect_uri="http://localhost:12345/callback",
+ code_challenge="test-challenge",
+ code_challenge_method="S256",
+ scopes=["read", "write"],
+ idp_tokens={
+ "access_token": "upstream-access-token",
+ "refresh_token": "upstream-refresh-token",
+ "expires_in": 28800, # 8 hours (access token)
+ "refresh_expires_in": 120, # 2 minutes (refresh token) - SHORTER!
+ "token_type": "Bearer",
+ },
+ expires_at=time.time() + 300,
+ created_at=time.time(),
+ )
+ await proxy._code_store.put(key=client_code.code, value=client_code)
+
+ # Exchange the code
+ from mcp.server.auth.provider import AuthorizationCode
+
+ auth_code = AuthorizationCode(
+ code="test-auth-code",
+ scopes=["read", "write"],
+ expires_at=time.time() + 300,
+ client_id="test-client",
+ code_challenge="test-challenge",
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ )
+
+ result = await proxy.exchange_authorization_code(
+ client=client,
+ authorization_code=auth_code,
+ )
+
+ # Verify tokens were issued
+ assert result.access_token is not None
+ assert result.refresh_token is not None
+
+ # The key test: verify upstream tokens are stored with TTL=max(120, 28800)=28800
+ # We can verify this by checking the tokens are still accessible after 2 minutes
+ # would have passed (if TTL was incorrectly set to 120)
+ #
+ # Since we can't easily time-travel in tests, we verify the storage directly
+ # by checking that we can still look up the tokens for refresh purposes.
+ #
+ # Extract the JTI from the refresh token to look up the mapping
+ refresh_payload = proxy.jwt_issuer.verify_token(result.refresh_token)
+ refresh_jti = refresh_payload["jti"]
+
+ # The JTI mapping should exist
+ jti_mapping = await proxy._jti_mapping_store.get(key=refresh_jti)
+ assert jti_mapping is not None
+
+ # The upstream tokens should exist
+ upstream_tokens = await proxy._upstream_token_store.get(
+ key=jti_mapping.upstream_token_id
+ )
+ assert upstream_tokens is not None
+ assert upstream_tokens.access_token == "upstream-access-token"
+ assert upstream_tokens.refresh_token == "upstream-refresh-token"
+
+ async def test_ttl_uses_refresh_when_refresh_longer_than_access(self, proxy):
+ """TTL should use refresh token expiry when refresh is longer.
+
+ This is the ianw case: IdP returns expires_in=300 (5 min) but
+ refresh_expires_in=32318 (9 hours). The upstream tokens should persist
+ for 9 hours (the refresh token lifetime).
+ """
+ from fastmcp.server.auth.oauth_proxy import ClientCode
+
+ # Register client
+ client = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+ await proxy.register_client(client)
+
+ # Simulate ianw's setup: short access, long refresh (typical)
+ client_code = ClientCode(
+ code="test-auth-code-2",
+ client_id="test-client",
+ redirect_uri="http://localhost:12345/callback",
+ code_challenge="test-challenge",
+ code_challenge_method="S256",
+ scopes=["read", "write"],
+ idp_tokens={
+ "access_token": "upstream-access-token-2",
+ "refresh_token": "upstream-refresh-token-2",
+ "expires_in": 300, # 5 minutes (access token)
+ "refresh_expires_in": 32318, # 9 hours (refresh token) - LONGER
+ "token_type": "Bearer",
+ },
+ expires_at=time.time() + 300,
+ created_at=time.time(),
+ )
+ await proxy._code_store.put(key=client_code.code, value=client_code)
+
+ # Exchange the code
+ from mcp.server.auth.provider import AuthorizationCode
+
+ auth_code = AuthorizationCode(
+ code="test-auth-code-2",
+ scopes=["read", "write"],
+ expires_at=time.time() + 300,
+ client_id="test-client",
+ code_challenge="test-challenge",
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ )
+
+ result = await proxy.exchange_authorization_code(
+ client=client,
+ authorization_code=auth_code,
+ )
+
+ # Verify tokens were issued
+ assert result.access_token is not None
+ assert result.refresh_token is not None
+
+ # Verify upstream tokens are accessible
+ refresh_payload = proxy.jwt_issuer.verify_token(result.refresh_token)
+ refresh_jti = refresh_payload["jti"]
+
+ jti_mapping = await proxy._jti_mapping_store.get(key=refresh_jti)
+ assert jti_mapping is not None
+
+ upstream_tokens = await proxy._upstream_token_store.get(
+ key=jti_mapping.upstream_token_id
+ )
+ assert upstream_tokens is not None
diff --git a/tests/server/middleware/test_error_handling.py b/tests/server/middleware/test_error_handling.py
index 1ed534709..7da8c3119 100644
--- a/tests/server/middleware/test_error_handling.py
+++ b/tests/server/middleware/test_error_handling.py
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from mcp import McpError
-from fastmcp.exceptions import NotFoundError
+from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.server.middleware.error_handling import (
ErrorHandlingMiddleware,
RetryMiddleware,
@@ -215,6 +215,23 @@ class TestErrorHandlingMiddleware:
assert "Invalid params: test error" in exc_info.value.error.message
assert "Error in test_method: ValueError: test error" in caplog.text
+ async def test_on_message_error_transform_tool_error(self, mock_context, caplog):
+ """Test error handling with transformation and cause type."""
+ middleware = ErrorHandlingMiddleware()
+ tool_error = ToolError("test error")
+ tool_error.__cause__ = ValueError()
+ mock_call_next = AsyncMock(side_effect=tool_error)
+
+ with caplog_for_fastmcp(caplog):
+ with caplog.at_level(logging.ERROR):
+ with pytest.raises(McpError) as exc_info:
+ await middleware.on_message(mock_context, mock_call_next)
+
+ assert isinstance(exc_info.value, McpError)
+ assert exc_info.value.error.code == -32602
+ assert "Invalid params: test error" in exc_info.value.error.message
+ assert "Error in test_method: ToolError: test error" in caplog.text
+
def test_get_error_stats(self, mock_context):
"""Test getting error statistics."""
middleware = ErrorHandlingMiddleware()
diff --git a/tests/server/openapi/test_comprehensive.py b/tests/server/openapi/test_comprehensive.py
index e7602c06c..777cd54c8 100644
--- a/tests/server/openapi/test_comprehensive.py
+++ b/tests/server/openapi/test_comprehensive.py
@@ -498,6 +498,38 @@ class TestOpenAPIComprehensive:
assert "123" in str(request.url)
assert "users/123" in str(request.url)
+ async def test_request_uses_localhost_fallback_when_no_base_url(
+ self, comprehensive_openapi_spec
+ ):
+ """Test that tool uses localhost fallback when client has no base_url."""
+ mock_client = Mock(spec=httpx.AsyncClient)
+ mock_client.base_url = httpx.URL("") # Empty URL, same as httpx default
+ mock_client.headers = None
+
+ mock_response = Mock(spec=Response)
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "id": 123,
+ "name": "Test User",
+ "email": "test@example.com",
+ }
+ mock_response.raise_for_status = Mock()
+
+ mock_client.send = AsyncMock(return_value=mock_response)
+
+ server = FastMCPOpenAPI(
+ openapi_spec=comprehensive_openapi_spec,
+ client=mock_client,
+ )
+
+ async with Client(server) as mcp_client:
+ await mcp_client.call_tool("get_user", {"id": 123})
+
+ # Verify request was made to localhost fallback
+ mock_client.send.assert_called_once()
+ request = mock_client.send.call_args[0][0]
+ assert str(request.url).startswith("http://localhost")
+
async def test_complex_request_with_body_and_parameters(
self, comprehensive_openapi_spec
):
diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py
index c46e42ada..073de120a 100644
--- a/tests/server/tasks/test_server_tasks_parameter.py
+++ b/tests/server/tasks/test_server_tasks_parameter.py
@@ -308,3 +308,29 @@ async def test_multiple_components_same_name_different_tasks():
# Prompt inheriting False (mode="forbidden") raises McpError
with pytest.raises(McpError):
await client.get_prompt("shared_name_prompt", task=True)
+
+
+async def test_task_with_custom_tool_name():
+ """Tools with custom names work correctly as tasks (issue #2642).
+
+ When a tool is registered with a custom name different from the function
+ name, task execution should use the custom name for Docket lookup.
+ """
+ mcp = FastMCP("test", tasks=True)
+
+ async def my_function() -> str:
+ return "result from custom-named tool"
+
+ mcp.tool(my_function, name="custom-tool-name")
+
+ async with Client(mcp) as client:
+ # Verify the tool is registered with its custom name in Docket
+ docket = mcp.docket
+ assert docket is not None
+ assert "custom-tool-name" in docket.tasks
+
+ # Call the tool as a task using its custom name
+ task = await client.call_tool("custom-tool-name", task=True)
+ assert not task.returned_immediately
+ result = await task
+ assert result.data == "result from custom-named tool"
diff --git a/tests/server/tasks/test_task_methods.py b/tests/server/tasks/test_task_methods.py
index ba8b6e64d..ee971b2e7 100644
--- a/tests/server/tasks/test_task_methods.py
+++ b/tests/server/tasks/test_task_methods.py
@@ -172,3 +172,46 @@ async def test_task_cancellation_workflow(endpoint_server):
# Task should be in cancelled state
status = await task.status()
assert status.status == "cancelled"
+
+
+async def test_task_cancellation_interrupts_running_coroutine(endpoint_server):
+ """Task cancellation actually interrupts the running coroutine.
+
+ This verifies that when a task is cancelled, the underlying asyncio
+ coroutine receives CancelledError rather than continuing to completion.
+ Requires pydocket >= 0.16.2.
+
+ See: https://github.com/jlowin/fastmcp/issues/2679
+ """
+ started = asyncio.Event()
+ was_interrupted = asyncio.Event()
+ completed_normally = asyncio.Event()
+
+ @endpoint_server.tool(task=True)
+ async def interruptible_tool() -> str:
+ started.set()
+ try:
+ await asyncio.sleep(60)
+ completed_normally.set()
+ return "completed"
+ except asyncio.CancelledError:
+ was_interrupted.set()
+ raise
+
+ async with Client(endpoint_server) as client:
+ task = await client.call_tool("interruptible_tool", {}, task=True)
+
+ # Wait for the tool to actually start executing
+ await asyncio.wait_for(started.wait(), timeout=5.0)
+
+ # Cancel the task
+ await task.cancel()
+
+ # Wait for cancellation to propagate
+ await asyncio.wait_for(was_interrupted.wait(), timeout=5.0)
+
+ # The coroutine should have been interrupted, not completed normally
+ assert was_interrupted.is_set(), "Task was not interrupted by cancellation"
+ assert not completed_normally.is_set(), (
+ "Task completed instead of being cancelled"
+ )
diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py
index 208ae3106..a05d3d436 100644
--- a/tests/server/test_dependencies.py
+++ b/tests/server/test_dependencies.py
@@ -733,3 +733,47 @@ async def test_resource_template_uri_cannot_match_dependency_name(mcp: FastMCP):
@mcp.resource("auth://{token}/validate")
async def validate(token: str = Depends(get_token)) -> str:
return f"Validating with: {token}"
+
+
+async def test_toolerror_propagates_from_dependency(mcp: FastMCP):
+ """ToolError raised in a dependency should propagate unchanged (issue #2633).
+
+ When a dependency raises ToolError, it should not be wrapped in RuntimeError.
+ This allows developers to use ToolError for validation in dependencies.
+ """
+ from fastmcp.exceptions import ToolError
+
+ def validate_client_id() -> str:
+ raise ToolError("Client ID is required - select a client first")
+
+ @mcp.tool()
+ async def my_tool(client_id: str = Depends(validate_client_id)) -> str:
+ return f"Working with client: {client_id}"
+
+ async with Client(mcp) as client:
+ # ToolError is converted to an error result by the server
+ result = await client.call_tool("my_tool", {}, raise_on_error=False)
+ assert result.is_error
+ # The original error message should be preserved (not wrapped in RuntimeError)
+ assert result.content[0].text == "Client ID is required - select a client first" # type: ignore[attr-defined]
+
+
+async def test_validation_error_propagates_from_dependency(mcp: FastMCP):
+ """ValidationError raised in a dependency should propagate unchanged."""
+ from fastmcp.exceptions import ValidationError
+
+ def validate_input() -> str:
+ raise ValidationError("Invalid input format")
+
+ @mcp.tool()
+ async def tool_with_validation(val: str = Depends(validate_input)) -> str:
+ return val
+
+ async with Client(mcp) as client:
+ # ValidationError is re-raised by the server and becomes an error result
+ # The original error message should be preserved (not wrapped in RuntimeError)
+ result = await client.call_tool(
+ "tool_with_validation", {}, raise_on_error=False
+ )
+ assert result.is_error
+ assert result.content[0].text == "Invalid input format" # type: ignore[attr-defined]
diff --git a/tests/server/test_logging.py b/tests/server/test_logging.py
index cfc376c0b..e8810c4a9 100644
--- a/tests/server/test_logging.py
+++ b/tests/server/test_logging.py
@@ -49,9 +49,14 @@ async def test_uvicorn_logging_default_level(
)
mock_server_instance.serve.assert_awaited_once()
+ # Signal the mock to finish and cancel with timeout
+ # Required for uvicorn 0.39+ due to context isolation
+ serve_finished_event.set()
server_task.cancel()
- with pytest.raises(asyncio.CancelledError):
- await server_task
+ try:
+ await asyncio.wait_for(server_task, timeout=2.0)
+ except (asyncio.CancelledError, asyncio.TimeoutError):
+ pass
@patch("fastmcp.server.server.uvicorn.Server")
@@ -109,9 +114,14 @@ async def test_uvicorn_logging_with_custom_log_config(
)
mock_server_instance.serve.assert_awaited_once()
+ # Signal the mock to finish and cancel with timeout
+ # Required for uvicorn 0.39+ due to context isolation
+ serve_finished_event.set()
server_task.cancel()
- with pytest.raises(asyncio.CancelledError):
- await server_task
+ try:
+ await asyncio.wait_for(server_task, timeout=2.0)
+ except (asyncio.CancelledError, asyncio.TimeoutError):
+ pass
@patch("fastmcp.server.server.uvicorn.Server")
@@ -172,6 +182,11 @@ async def test_uvicorn_logging_custom_log_config_overrides_log_level_param(
)
mock_server_instance.serve.assert_awaited_once()
+ # Signal the mock to finish and cancel with timeout
+ # Required for uvicorn 0.39+ due to context isolation
+ serve_finished_event.set()
server_task.cancel()
- with pytest.raises(asyncio.CancelledError):
- await server_task
+ try:
+ await asyncio.wait_for(server_task, timeout=2.0)
+ except (asyncio.CancelledError, asyncio.TimeoutError):
+ pass
diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py
index 1f21ffc77..1c24e7834 100644
--- a/tests/tools/test_tool.py
+++ b/tests/tools/test_tool.py
@@ -1296,6 +1296,69 @@ class TestAutomaticStructuredContent:
"stuff": [{"value": 456, "stuff": []}],
}
+ async def test_self_referencing_pydantic_model_has_type_object_at_root(self):
+ """Test that self-referencing Pydantic models have type: object at root.
+
+ MCP spec requires outputSchema to have "type": "object" at the root level.
+ Pydantic generates schemas with $ref at root for self-referential models,
+ which violates this requirement. FastMCP should resolve the $ref.
+
+ Regression test for issue #2455.
+ """
+
+ class Issue(BaseModel):
+ id: str
+ title: str
+ dependencies: list["Issue"] = []
+ dependents: list["Issue"] = []
+
+ def get_issue(issue_id: str) -> Issue:
+ return Issue(id=issue_id, title="Test")
+
+ tool = Tool.from_function(get_issue)
+
+ # The output schema should have "type": "object" at root, not $ref
+ assert tool.output_schema is not None
+ assert tool.output_schema.get("type") == "object"
+ assert "properties" in tool.output_schema
+ # Should still have $defs for nested references
+ assert "$defs" in tool.output_schema
+ # Should NOT have $ref at root level
+ assert "$ref" not in tool.output_schema
+
+ async def test_self_referencing_model_outputschema_mcp_compliant(self):
+ """Test that self-referencing model schemas are MCP spec compliant.
+
+ The MCP spec requires:
+ - type: "object" at root level
+ - properties field
+ - required field (optional)
+
+ This ensures clients can properly validate the schema.
+
+ Regression test for issue #2455.
+ """
+
+ class Node(BaseModel):
+ id: str
+ children: list["Node"] = []
+
+ def get_node() -> Node:
+ return Node(id="1")
+
+ tool = Tool.from_function(get_node)
+
+ # Schema should be MCP-compliant
+ assert tool.output_schema is not None
+ assert tool.output_schema.get("type") == "object", (
+ "MCP spec requires 'type': 'object' at root"
+ )
+ assert "properties" in tool.output_schema
+ assert "id" in tool.output_schema["properties"]
+ assert "children" in tool.output_schema["properties"]
+ # Required should include 'id'
+ assert "id" in tool.output_schema.get("required", [])
+
async def test_int_return_no_structured_content_without_schema(self):
"""Test that int returns don't create structured content without output schema."""
@@ -1558,28 +1621,18 @@ class TestSerializationAlias:
# not the first validation alias 'id'
assert tool.output_schema is not None
- # For object types, the schema may use $ref at root (self-referencing types)
- # or have properties directly. Check both cases.
- if "$ref" in tool.output_schema:
- # Schema uses $ref - resolve to get the actual definition
- assert "$defs" in tool.output_schema
- ref_path = tool.output_schema["$ref"].replace("#/$defs/", "")
- component_def = tool.output_schema["$defs"][ref_path]
- else:
- # Schema has properties directly (wrapped case)
- assert "properties" in tool.output_schema
- assert "result" in tool.output_schema["properties"]
- assert "$defs" in tool.output_schema
- # Find the Component definition
- component_def = list(tool.output_schema["$defs"].values())[0]
+ # Object schemas have properties directly at root (MCP spec compliance)
+ # Root-level $refs are resolved to ensure type: object at root
+ assert "properties" in tool.output_schema
+ assert tool.output_schema.get("type") == "object"
# Should have 'componentId' not 'id' in properties
- assert "componentId" in component_def["properties"]
- assert "id" not in component_def["properties"]
+ assert "componentId" in tool.output_schema["properties"]
+ assert "id" not in tool.output_schema["properties"]
# Should require 'componentId' not 'id'
- assert "componentId" in component_def["required"]
- assert "id" not in component_def.get("required", [])
+ assert "componentId" in tool.output_schema.get("required", [])
+ assert "id" not in tool.output_schema.get("required", [])
async def test_tool_execution_with_serialization_alias(self):
"""Test that tool execution works correctly with serialization aliases."""
diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py
index 5fce3be68..00162e2c1 100644
--- a/tests/utilities/test_json_schema.py
+++ b/tests/utilities/test_json_schema.py
@@ -1,6 +1,7 @@
from fastmcp.utilities.json_schema import (
_prune_param,
compress_schema,
+ resolve_root_ref,
)
# Wrapper for backward compatibility with tests
@@ -505,3 +506,152 @@ class TestCompressSchema:
"title" not in compressed["properties"]["title"]["properties"]["subtitle"]
)
assert "title" not in compressed["properties"]["normal_field"]
+
+
+class TestResolveRootRef:
+ """Tests for the resolve_root_ref function.
+
+ This function resolves $ref at root level to meet MCP spec requirements.
+ MCP specification requires outputSchema to have "type": "object" at root.
+ """
+
+ def test_resolves_simple_root_ref(self):
+ """Test that simple $ref at root is resolved."""
+ schema = {
+ "$defs": {
+ "Node": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string"},
+ "name": {"type": "string"},
+ },
+ "required": ["id"],
+ }
+ },
+ "$ref": "#/$defs/Node",
+ }
+ result = resolve_root_ref(schema)
+
+ # Should have type: object at root now
+ assert result.get("type") == "object"
+ assert "properties" in result
+ assert "id" in result["properties"]
+ assert "name" in result["properties"]
+ # Should still have $defs for nested references
+ assert "$defs" in result
+ # Should NOT have $ref at root
+ assert "$ref" not in result
+
+ def test_resolves_self_referential_model(self):
+ """Test resolving schema for self-referential models like Issue."""
+ # This is the exact schema Pydantic generates for self-referential models
+ schema = {
+ "$defs": {
+ "Issue": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string"},
+ "title": {"type": "string"},
+ "dependencies": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/Issue"},
+ },
+ "dependents": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/Issue"},
+ },
+ },
+ "required": ["id", "title"],
+ }
+ },
+ "$ref": "#/$defs/Issue",
+ }
+ result = resolve_root_ref(schema)
+
+ # Should have type: object at root
+ assert result.get("type") == "object"
+ assert "properties" in result
+ assert "id" in result["properties"]
+ assert "dependencies" in result["properties"]
+ # Nested $refs should still point to $defs
+ assert result["properties"]["dependencies"]["items"]["$ref"] == "#/$defs/Issue"
+ # Should have $defs preserved for nested references
+ assert "$defs" in result
+ assert "Issue" in result["$defs"]
+
+ def test_does_not_modify_schema_with_type_at_root(self):
+ """Test that schemas already having type at root are not modified."""
+ schema = {
+ "type": "object",
+ "properties": {"id": {"type": "string"}},
+ "$defs": {"SomeType": {"type": "string"}},
+ "$ref": "#/$defs/SomeType", # This would be unusual but possible
+ }
+ result = resolve_root_ref(schema)
+
+ # Schema should be unchanged (returned as-is)
+ assert result is schema
+
+ def test_does_not_modify_schema_without_ref(self):
+ """Test that schemas without $ref are not modified."""
+ schema = {
+ "type": "object",
+ "properties": {"id": {"type": "string"}},
+ }
+ result = resolve_root_ref(schema)
+
+ assert result is schema
+
+ def test_does_not_modify_schema_without_defs(self):
+ """Test that schemas with $ref but without $defs are not modified."""
+ schema = {
+ "$ref": "#/$defs/Missing",
+ }
+ result = resolve_root_ref(schema)
+
+ assert result is schema
+
+ def test_does_not_modify_external_ref(self):
+ """Test that external $refs (not pointing to $defs) are not resolved."""
+ schema = {
+ "$defs": {"Node": {"type": "object"}},
+ "$ref": "https://example.com/schema.json#/definitions/Node",
+ }
+ result = resolve_root_ref(schema)
+
+ assert result is schema
+
+ def test_preserves_all_defs_for_nested_references(self):
+ """Test that $defs are preserved even if multiple definitions exist."""
+ schema = {
+ "$defs": {
+ "Node": {
+ "type": "object",
+ "properties": {
+ "child": {"$ref": "#/$defs/ChildNode"},
+ },
+ },
+ "ChildNode": {
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ },
+ },
+ "$ref": "#/$defs/Node",
+ }
+ result = resolve_root_ref(schema)
+
+ # Both defs should be preserved
+ assert "$defs" in result
+ assert "Node" in result["$defs"]
+ assert "ChildNode" in result["$defs"]
+
+ def test_handles_missing_def_gracefully(self):
+ """Test that missing definition in $defs doesn't cause error."""
+ schema = {
+ "$defs": {"OtherType": {"type": "string"}},
+ "$ref": "#/$defs/Missing",
+ }
+ result = resolve_root_ref(schema)
+
+ # Should return original schema unchanged
+ assert result is schema
diff --git a/tests/utilities/test_version_check.py b/tests/utilities/test_version_check.py
new file mode 100644
index 000000000..9cc9aab3b
--- /dev/null
+++ b/tests/utilities/test_version_check.py
@@ -0,0 +1,315 @@
+"""Tests for version checking utilities."""
+
+import json
+import time
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import httpx
+import pytest
+
+from fastmcp.utilities.version_check import (
+ CACHE_TTL_SECONDS,
+ _fetch_latest_version,
+ _get_cache_path,
+ _read_cache,
+ _write_cache,
+ check_for_newer_version,
+ get_latest_version,
+)
+
+
+class TestCachePath:
+ def test_cache_path_in_home_directory(self):
+ """Cache file should be in fastmcp home directory."""
+ cache_path = _get_cache_path()
+ assert cache_path.name == "version_cache.json"
+ assert "fastmcp" in str(cache_path).lower()
+
+ def test_cache_path_prerelease_suffix(self):
+ """Prerelease cache uses different file."""
+ cache_path = _get_cache_path(include_prereleases=True)
+ assert cache_path.name == "version_cache_prerelease.json"
+
+
+class TestReadCache:
+ def test_read_cache_no_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ """Reading non-existent cache returns None."""
+ monkeypatch.setattr(
+ "fastmcp.utilities.version_check._get_cache_path",
+ lambda include_prereleases=False: tmp_path / "nonexistent.json",
+ )
+ version, timestamp = _read_cache()
+ assert version is None
+ assert timestamp == 0
+
+ def test_read_cache_valid(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ """Reading valid cache returns version and timestamp."""
+ cache_file = tmp_path / "version_cache.json"
+ cache_file.write_text(
+ json.dumps({"latest_version": "2.5.0", "timestamp": 1000})
+ )
+ monkeypatch.setattr(
+ "fastmcp.utilities.version_check._get_cache_path",
+ lambda include_prereleases=False: cache_file,
+ )
+
+ version, timestamp = _read_cache()
+ assert version == "2.5.0"
+ assert timestamp == 1000
+
+ def test_read_cache_invalid_json(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ """Reading invalid JSON returns None."""
+ cache_file = tmp_path / "version_cache.json"
+ cache_file.write_text("not valid json")
+ monkeypatch.setattr(
+ "fastmcp.utilities.version_check._get_cache_path",
+ lambda include_prereleases=False: cache_file,
+ )
+
+ version, timestamp = _read_cache()
+ assert version is None
+ assert timestamp == 0
+
+
+class TestWriteCache:
+ def test_write_cache_creates_file(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ """Writing cache creates the cache file."""
+ cache_file = tmp_path / "subdir" / "version_cache.json"
+ monkeypatch.setattr(
+ "fastmcp.utilities.version_check._get_cache_path",
+ lambda include_prereleases=False: cache_file,
+ )
+
+ _write_cache("2.6.0")
+
+ assert cache_file.exists()
+ data = json.loads(cache_file.read_text())
+ assert data["latest_version"] == "2.6.0"
+ assert "timestamp" in data
+
+
+class TestFetchLatestVersion:
+ def test_fetch_success(self):
+ """Successful fetch returns highest stable version."""
+ mock_response = MagicMock()
+ mock_response.json.return_value = {
+ "releases": {
+ "2.5.0": [],
+ "2.4.0": [],
+ "2.6.0b1": [], # prerelease should be skipped
+ }
+ }
+
+ with patch("httpx.get", return_value=mock_response) as mock_get:
+ version = _fetch_latest_version()
+ assert version == "2.5.0"
+ mock_get.assert_called_once()
+
+ def test_fetch_network_error(self):
+ """Network error returns None."""
+ with patch("httpx.get", side_effect=httpx.HTTPError("Network error")):
+ version = _fetch_latest_version()
+ assert version is None
+
+ def test_fetch_invalid_response(self):
+ """Invalid response returns None."""
+ mock_response = MagicMock()
+ mock_response.json.return_value = {"unexpected": "format"}
+
+ with patch("httpx.get", return_value=mock_response):
+ version = _fetch_latest_version()
+ assert version is None
+
+ def test_fetch_prereleases(self):
+ """Fetching with prereleases returns highest version."""
+ mock_response = MagicMock()
+ mock_response.json.return_value = {
+ "info": {"version": "2.5.0"},
+ "releases": {
+ "2.5.0": [],
+ "2.6.0b1": [],
+ "2.6.0b2": [],
+ "2.4.0": [],
+ },
+ }
+
+ with patch("httpx.get", return_value=mock_response):
+ version = _fetch_latest_version(include_prereleases=True)
+ assert version == "2.6.0b2"
+
+ def test_fetch_prereleases_stable_is_highest(self):
+ """Prerelease mode still returns stable if it's highest."""
+ mock_response = MagicMock()
+ mock_response.json.return_value = {
+ "info": {"version": "2.5.0"},
+ "releases": {
+ "2.5.0": [],
+ "2.5.0b1": [],
+ "2.4.0": [],
+ },
+ }
+
+ with patch("httpx.get", return_value=mock_response):
+ version = _fetch_latest_version(include_prereleases=True)
+ assert version == "2.5.0"
+
+
+class TestGetLatestVersion:
+ def test_returns_cached_version_if_fresh(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ """Uses cached version if cache is fresh."""
+ cache_file = tmp_path / "version_cache.json"
+ cache_file.write_text(
+ json.dumps({"latest_version": "2.5.0", "timestamp": time.time()})
+ )
+ monkeypatch.setattr(
+ "fastmcp.utilities.version_check._get_cache_path",
+ lambda include_prereleases=False: cache_file,
+ )
+
+ with patch(
+ "fastmcp.utilities.version_check._fetch_latest_version"
+ ) as mock_fetch:
+ version = get_latest_version()
+ assert version == "2.5.0"
+ mock_fetch.assert_not_called()
+
+ def test_fetches_if_cache_stale(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ """Fetches from PyPI if cache is stale."""
+ cache_file = tmp_path / "version_cache.json"
+ old_timestamp = time.time() - CACHE_TTL_SECONDS - 100
+ cache_file.write_text(
+ json.dumps({"latest_version": "2.4.0", "timestamp": old_timestamp})
+ )
+ monkeypatch.setattr(
+ "fastmcp.utilities.version_check._get_cache_path",
+ lambda include_prereleases=False: cache_file,
+ )
+
+ with patch(
+ "fastmcp.utilities.version_check._fetch_latest_version",
+ return_value="2.5.0",
+ ) as mock_fetch:
+ version = get_latest_version()
+ assert version == "2.5.0"
+ mock_fetch.assert_called_once()
+
+ def test_returns_stale_cache_if_fetch_fails(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ """Returns stale cache if fetch fails."""
+ cache_file = tmp_path / "version_cache.json"
+ old_timestamp = time.time() - CACHE_TTL_SECONDS - 100
+ cache_file.write_text(
+ json.dumps({"latest_version": "2.4.0", "timestamp": old_timestamp})
+ )
+ monkeypatch.setattr(
+ "fastmcp.utilities.version_check._get_cache_path",
+ lambda include_prereleases=False: cache_file,
+ )
+
+ with patch(
+ "fastmcp.utilities.version_check._fetch_latest_version", return_value=None
+ ):
+ version = get_latest_version()
+ assert version == "2.4.0"
+
+
+class TestCheckForNewerVersion:
+ def test_returns_none_if_disabled(self, monkeypatch: pytest.MonkeyPatch):
+ """Returns None if check_for_updates is off."""
+ import fastmcp
+
+ monkeypatch.setattr(fastmcp.settings, "check_for_updates", "off")
+
+ result = check_for_newer_version()
+ assert result is None
+
+ def test_returns_none_if_current(self, monkeypatch: pytest.MonkeyPatch):
+ """Returns None if current version is latest."""
+ import fastmcp
+
+ monkeypatch.setattr(fastmcp.settings, "check_for_updates", "stable")
+ monkeypatch.setattr(fastmcp, "__version__", "2.5.0")
+
+ with patch(
+ "fastmcp.utilities.version_check.get_latest_version", return_value="2.5.0"
+ ):
+ result = check_for_newer_version()
+ assert result is None
+
+ def test_returns_version_if_newer(self, monkeypatch: pytest.MonkeyPatch):
+ """Returns new version if available."""
+ import fastmcp
+
+ monkeypatch.setattr(fastmcp.settings, "check_for_updates", "stable")
+ monkeypatch.setattr(fastmcp, "__version__", "2.4.0")
+
+ with patch(
+ "fastmcp.utilities.version_check.get_latest_version", return_value="2.5.0"
+ ):
+ result = check_for_newer_version()
+ assert result == "2.5.0"
+
+ def test_returns_none_if_older_available(self, monkeypatch: pytest.MonkeyPatch):
+ """Returns None if pypi version is older than current (dev version)."""
+ import fastmcp
+
+ monkeypatch.setattr(fastmcp.settings, "check_for_updates", "stable")
+ monkeypatch.setattr(fastmcp, "__version__", "3.0.0.dev1")
+
+ with patch(
+ "fastmcp.utilities.version_check.get_latest_version", return_value="2.5.0"
+ ):
+ result = check_for_newer_version()
+ assert result is None
+
+ def test_handles_invalid_versions(self, monkeypatch: pytest.MonkeyPatch):
+ """Handles invalid version strings gracefully."""
+ import fastmcp
+
+ monkeypatch.setattr(fastmcp.settings, "check_for_updates", "stable")
+ monkeypatch.setattr(fastmcp, "__version__", "invalid")
+
+ with patch(
+ "fastmcp.utilities.version_check.get_latest_version",
+ return_value="also-invalid",
+ ):
+ result = check_for_newer_version()
+ assert result is None
+
+ def test_prerelease_setting(self, monkeypatch: pytest.MonkeyPatch):
+ """Prerelease setting passes include_prereleases=True."""
+ import fastmcp
+
+ monkeypatch.setattr(fastmcp.settings, "check_for_updates", "prerelease")
+ monkeypatch.setattr(fastmcp, "__version__", "2.5.0")
+
+ with patch(
+ "fastmcp.utilities.version_check.get_latest_version", return_value="2.6.0b1"
+ ) as mock_get:
+ result = check_for_newer_version()
+ assert result == "2.6.0b1"
+ mock_get.assert_called_once_with(True)
+
+ def test_stable_setting(self, monkeypatch: pytest.MonkeyPatch):
+ """Stable setting passes include_prereleases=False."""
+ import fastmcp
+
+ monkeypatch.setattr(fastmcp.settings, "check_for_updates", "stable")
+ monkeypatch.setattr(fastmcp, "__version__", "2.4.0")
+
+ with patch(
+ "fastmcp.utilities.version_check.get_latest_version", return_value="2.5.0"
+ ) as mock_get:
+ result = check_for_newer_version()
+ assert result == "2.5.0"
+ mock_get.assert_called_once_with(False)
diff --git a/uv.lock b/uv.lock
index b26901997..b17a98f83 100644
--- a/uv.lock
+++ b/uv.lock
@@ -746,13 +746,13 @@ requires-dist = [
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonschema-path", specifier = ">=0.3.4" },
- { name = "mcp", specifier = ">=1.24.0" },
+ { name = "mcp", specifier = ">=1.24.0,<2.0" },
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
{ name = "platformdirs", specifier = ">=4.0.0" },
{ name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.3.0,<0.4.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
- { name = "pydocket", specifier = ">=0.15.5" },
+ { name = "pydocket", specifier = ">=0.16.6" },
{ name = "pyperclip", specifier = ">=1.9.0" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "rich", specifier = ">=13.9.4" },
@@ -1775,7 +1775,7 @@ wheels = [
[[package]]
name = "pydocket"
-version = "0.15.5"
+version = "0.16.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cloudpickle" },
@@ -1792,9 +1792,9 @@ dependencies = [
{ name = "typer" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/86/77/842e41be3cf3592b971cf42b24cae76e282294f474dc2dbf7cd6808d1b09/pydocket-0.15.5.tar.gz", hash = "sha256:b3af47702a293dd1da2e5e0f8f73f27fd3b3c95e36de72a2f71026d16908d5ba", size = 277245, upload-time = "2025-12-12T22:28:47.32Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/00/26befe5f58df7cd1aeda4a8d10bc7d1908ffd86b80fd995e57a2a7b3f7bd/pydocket-0.16.6.tar.gz", hash = "sha256:b96c96ad7692827214ed4ff25fcf941ec38371314db5dcc1ae792b3e9d3a0294", size = 299054, upload-time = "2026-01-09T22:09:15.405Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/c0/fdbc6e04e3369b90c6bf6567bc62871cf59e88550b94529821500dc807c1/pydocket-0.15.5-py3-none-any.whl", hash = "sha256:ad0d86c9a1bea394e875bcf8c793be2d0a7ebd1891bfe99e2e9eaf99ef0cb42e", size = 58517, upload-time = "2025-12-12T22:28:45.598Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/3f/7483e5a6dc6326b6e0c640619b5c5bd1d6e3c20e54d58f5fb86267cef00e/pydocket-0.16.6-py3-none-any.whl", hash = "sha256:683d21e2e846aa5106274e7d59210331b242d7fb0dce5b08d3b82065663ed183", size = 67697, upload-time = "2026-01-09T22:09:13.436Z" },
]
[[package]]