Compare commits

...

63 commits

Author SHA1 Message Date
Chris Guidry
e21a6c0530 Bump docket to main branch for CI testing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:01:03 -05:00
Jeremiah Lowin
280885db59
fix: wire up dereference_refs() in tool schema pipeline (#3170) 2026-02-12 15:50:59 -06:00
SrzStephen
392d38326d
Use MemoryStore for OAuth proxy tests (#3111)
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
2026-02-09 21:07:32 -05:00
SrzStephen
05eefe3330
Updated deprecation URL for V2 (#3109)
* Updated deprecation URL for V2

For V3 this should be https://gofastmcp.com/servers/dependency-injection#using-depends
For V2 this should be https://gofastmcp.com/v2/servers/context#using-depends

current url fails for both v2 and v3 documentation

https://gofastmcp.com/servers/dependencies
https://gofastmcp.com/v2/servers/dependencies

* Updated url to point to v2 docs as outlined in review https://github.com/jlowin/fastmcp/pull/3109#pullrequestreview-3766965732
2026-02-07 08:19:15 -05:00
Jeremiah Lowin
21221b4ab1
Add release notes for v2.14.4 and v2.14.5 (#3063) 2026-02-03 10:34:12 -05:00
Chris Guidry
7d32409505
Merge pull request #2992 from jlowin/pydocket-github-validation
Bump pydocket to 0.17.2 (memory leak fix)
2026-01-26 11:32:38 -05:00
Chris Guidry
65d6f0654d Bump pydocket to >=0.17.2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:13:31 -05:00
Chris Guidry
5f680787f1 Bump pydocket to >=0.17.2b3
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:55:39 -05:00
Chris Guidry
0afa02908e Make read_resource test flexible for MCP version differences
MCP 1.26+ includes "meta":null in serialization, earlier versions don't.
Parse JSON and check the meaningful fields instead of exact string match.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 16:48:23 -05:00
Chris Guidry
7ad97d92cd Update test snapshot for MCP 1.26.0 serialization change
MCP 1.26.0 now includes `meta: null` in ReadResourceContents JSON
serialization. Update the test snapshot to match.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 16:45:04 -05:00
Chris Guidry
cd0274c0da Bump pydocket to >=0.17.2b2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 16:12:48 -05:00
Chris Guidry
f1a89eb84b Bump pydocket to >=0.17.2b1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:11:35 -05:00
Chris Guidry
c9ed36e651 Point to fix-cancellation-handling branch
Updates pydocket dependency to the new branch with proper asyncio
cancellation handling that works on Python 3.10+.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:31:16 -05:00
Chris Guidry
8cec853c58 Point to fix-redis-connection-guards branch
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:50:01 -05:00
Chris Guidry
006b1ae3d9 Install pydocket from GitHub for 0.17 compatibility testing
Temporarily pulls pydocket from chrisguidry/docket to explore
compatibility with the upcoming 0.17 release.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:48:37 -05:00
Jeremiah Lowin
ab935b3a5b
Add missing packaging dependency (#2989) 2026-01-22 12:25:06 -05:00
Chris Guidry
0e5677cefb
Merge pull request #2874 from jlowin/fix-task-capabilities-2.x
Fix task capabilities location (issue #2870)
2026-01-14 09:18:03 -05:00
Jeremiah Lowin
b0b68269cd Add type ignores for extra_data kwargs 2026-01-13 22:54:53 -05:00
Jeremiah Lowin
ed72c4b668 Fix type: use TasksCallCapability instead of dict 2026-01-13 22:51:47 -05:00
Jeremiah Lowin
dec3c6d449 Fix task capabilities location (issue #2870)
Tasks belong in capabilities.tasks (first-class field) per SEP-1686,
not capabilities.experimental.tasks. This fixes VS Code Copilot 1.107+
integration which checks capabilities.tasks?.requests?.tools?.call.

Changes:
- Update get_task_capabilities() to return ServerTasksCapability types
- Override get_capabilities() in LowLevelServer to set tasks field
- Remove experimental_capabilities parameter usage
- Update test to verify correct location

Fixes #2870
2026-01-13 22:25:52 -05:00
Jeremiah Lowin
bc2f601e52
Backport: Dereference $ref in tool schemas for MCP client compatibility (#2861) 2026-01-13 07:46:36 -05:00
Jeremiah Lowin
559b778135
Add release notes for v2.14.2 and v2.14.3 (#2851) 2026-01-12 17:57:33 -05:00
Jeremiah Lowin
e6005703e7
Fix HTTP transport timeout defaulting to 5 seconds (#2848)
Fixes #2842, fixes #2845
2026-01-12 12:18:59 -05:00
Chris Guidry
9e86dbcabb
Merge pull request #2843 from jlowin/debug-task-lifecycle
Fix ContextVar propagation for ASGI-mounted servers with tasks
2026-01-12 10:38:44 -05:00
Chris Guidry
964b23b071
Merge branch 'release/2.x' into debug-task-lifecycle 2026-01-12 10:32:57 -05:00
Chris Guidry
c127dd979f Remove debug logging, keep minimal ContextVar fix
Removes all the verbose debug logging added during diagnosis while
preserving the essential fix: Context.__aenter__ sets _current_docket
and _current_worker from server instance attributes. This ensures
ContextVars work in ASGI environments where lifespan and request
handlers run in sibling async contexts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:21:04 -05:00
Chris Guidry
7b24bd58c8 Make all task handlers consistent with debug logging
All three handlers (tool, prompt, resource) now have identical patterns:
- Debug logging for docket access, Redis writes, docket.add, subscriptions
- Try/except with traceback logging around Redis and docket operations
- Consistent error messages with instance_id

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:00:42 -05:00
Chris Guidry
687889c598 Simplify ContextVar management - trust Context to set them at request time
Remove redundant ContextVar handling:
- _lifespan_manager no longer re-sets ContextVars in early-return branch
- Handler fallback logic removed (no more `if docket is None: docket = server._docket`)

The authoritative place for request-context ContextVars is now Context.__aenter__,
which sets _current_docket and _current_worker from server instance attributes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:59:11 -05:00
Jeremiah Lowin
df5765cef3
Use pip instead of uv pip in upgrade instructions (#2841) 2026-01-10 18:15:28 -05:00
Jeremiah Lowin
7628c4380f
Add CLI update notifications (#2839) 2026-01-10 18:11:40 -05:00
Chris Guidry
5cea6ba575 Bump pydocket to >=0.16.6 for py-key-value Redis username workaround
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 17:10:53 -05:00
Chris Guidry
6824d56aec Bump pydocket to >=0.16.5 for Redis ACL fix
pydocket 0.16.5 fixes an issue where worker_group_name was passed as
a KEY instead of ARGV in Lua scripts, causing ACL failures when Redis
users are restricted to key patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:06:02 -05:00
Chris Guidry
c72b5dd7f0 Add detailed logging around docket.add() and subscription tasks
Tracing where the Redis ACL error occurs - the initial Redis writes
succeed but error happens somewhere after.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:37:09 -05:00
Chris Guidry
726976a99f Add verbose Redis error logging with full traceback
When Redis operations fail, log the full traceback to help diagnose
ACL and permission issues in production environments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:28:52 -05:00
Chris Guidry
6e8c60b65e Add verbose logging to Context for debugging Lambda ContextVar issue
Adds detailed logging to Context.__aenter__ and __aexit__ to track:
- When Context is entered/exited
- Values of server._docket and server._worker
- ContextVar values before and after setting
- Token values for debugging reset issues

This will help diagnose why ContextVars might not propagate in Lambda.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:05:57 -05:00
Chris Guidry
b32f7f5f99 Set docket/worker ContextVars at request time in Context
Instead of relying on ContextVar propagation from lifespan (which fails
in Lambda), set _current_docket and _current_worker when entering a
Context for each request. This ensures user dependencies like
CurrentDocket() and CurrentWorker() work in all environments.

The values come from server._docket and server._worker which are always
available after lifespan initialization.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:03:52 -05:00
Chris Guidry
4c169a80de Add server._docket fallback for Lambda ContextVar issue
ContextVars set during lifespan don't propagate to request handlers
in Lambda (works fine locally). As a workaround, fall back to using
server._docket when the ContextVar returns None.

This is a Lambda-specific issue - possibly related to how Lambda Web
Adapter or Lambda's asyncio runtime handles context propagation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:02:32 -05:00
Chris Guidry
fc4d0114db Fix ContextVar propagation for nested ASGI lifespan calls
When FastMCP runs with uvicorn, the lifespan is entered twice:
1. FastMCP's outer context (during http_app setup)
2. Starlette's ASGI lifespan (which request handlers inherit from)

The second call was skipping ContextVar setup because _lifespan_result_set
was already True. This caused _current_docket.get() to return None in
request handlers even though server._docket was correctly set.

Fix: Always set ContextVars when entering _lifespan_manager, using the
already-initialized values from self._docket and self._worker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 11:47:01 -05:00
Chris Guidry
8e927fbfed Add debug logging for task lifecycle 2026-01-09 11:27:15 -05:00
Chris Guidry
3df88260eb
Prefix Redis keys with docket name for ACL isolation (#2812)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:04:15 -05:00
Jeremiah Lowin
ae255277cc
Backport OAuth token storage TTL fix to release/2.x (#2798)
Fixes #2670, #2682
2026-01-06 17:46:58 -05:00
Jeremiah Lowin
e3a4e596fc
Add debug logging for OAuth token expiry diagnostics (#2789) 2025-12-31 13:37:53 -05:00
Adam Azzam
99832a9faf
Merge pull request #2787 from jlowin/confernece-to-2.x-branch
conference to 2.x branch
2025-12-30 22:09:15 -06:00
Adam Azzam
7fd365c868 Update docs.json 2025-12-30 21:41:01 -05:00
Jeremiah Lowin
9cf9aa9b37
Lazy import DiskStore to avoid sqlite3 dependency on import (#2785) 2025-12-30 19:20:10 -05:00
Jeremiah Lowin
1b637522d1
Fix base_url fallback when url is not set (#2776) (#2782)
Co-authored-by: Taisei Mima <bhbstar.me@gmail.com>
fix for httpx.URL("") being truthy but stringifying to empty string.
2025-12-30 17:51:57 -05:00
Jeremiah Lowin
33ff356c0f
Fix titled enum elicitation schema to comply with MCP spec (#2774) 2025-12-29 09:46:20 -05:00
Jeremiah Lowin
820f74e8ed
[BugFix] Fix openapi_version Check So 3.1 Is Included (#2768) (#2769)
Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
2025-12-28 07:39:21 -05:00
Jeremiah Lowin
bbf6c200ee
Update CLI banner with FastMCP 3.0 notice (#2765) 2025-12-26 22:13:05 -05:00
Jeremiah Lowin
97327893bb
Fix OAuth Proxy resource parameter validation (#2763) 2025-12-26 21:24:08 -05:00
Jeremiah Lowin
6bade1cbd9
Add auth_route parameter to SupabaseProvider (#2760)
Co-authored-by: Eloi Zalczer <eloi@entropia.io>
2025-12-26 16:23:41 -05:00
Jeremiah Lowin
4963b516c2
Fix: resolve root-level $ref in outputSchema for MCP spec compliance (#2720) (#2727)
Co-authored-by: lif <1835304752@qq.com>
2025-12-25 08:53:52 -05:00
Jeremiah Lowin
7f56291204
Add changelog entries for v2.13.1 through v2.14.1 (#2724) 2025-12-25 08:22:24 -05:00
Jeremiah Lowin
ee9ee777c5
Add v3 breaking changes notice to README and docs (#2713) 2025-12-24 16:04:46 -05:00
Jeremiah Lowin
39c4855580
Pin MCP under 2 (#2709) 2025-12-24 15:28:31 -05:00
Jeremiah Lowin
88349bd68f
Fix Prefect website URL in docs footer (#2705) 2025-12-24 08:30:26 -05:00
Chris Guidry
53b609f262
Merge pull request #2700 from jlowin/2679-task-cancel-fix-2.x
Bump pydocket to 0.16.3 to fix worker cleanup race condition
2025-12-23 18:54:32 -05:00
Chris Guidry
34112a17df Bump pydocket to 0.16.3 to fix worker cleanup race condition
pydocket 0.16.3 fixes a race condition in `_worker_loop` where cancellation
arriving between `_worker_done.clear()` and the try block would cause
`_worker_done.set()` to never run, blocking `Worker.__aexit__` forever.

Also fixes:
- Simplified `_docket_lifespan` cleanup (timeout wrapper no longer needed)
- Fixed `nested_server` test fixture to use graceful uvicorn shutdown
- Fixed uv transport tests to use local fastmcp in dev mode

Closes #2679

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 18:45:42 -05:00
Jeremiah Lowin
5daabf0b84
Fix test cleanup for uvicorn 0.39+ context isolation (#2696) 2025-12-23 18:40:20 -05:00
Jeremiah Lowin
7a46e2130b
Let FastMCPError propagate unchanged from managers (#2697) 2025-12-23 18:29:36 -05:00
Jeremiah Lowin
bf60ac4e44 Fix task execution for tools with custom names (#2645)
Uses pydocket 0.16.0's `names=` parameter for explicit registration keys:
- Tools/prompts: registered by `.key`
- Resources/templates: registered by `.name`

Removes `_create_named_fn_wrapper` function.

Closes #2642
2025-12-23 18:05:29 -05:00
Chris Guidry
afb22c4a74 Let FastMCPError propagate from dependencies
When a dependency raises ToolError or other FastMCPError subclasses, they
were getting wrapped in RuntimeError with a generic "Failed to resolve
dependency" message. This made it hard to use ToolError for validation
in dependencies.

Now FastMCPError subclasses propagate unchanged, matching the pattern
used elsewhere in the codebase.

Closes #2633

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 18:00:40 -05:00
Rafi Olaverria
6a434e4005 fix: check the cause of the tool error (#2674) 2025-12-23 18:00:25 -05:00
62 changed files with 3191 additions and 906 deletions

1
.loq_cache Normal file

File diff suppressed because one or more lines are too long

View file

@ -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.

View file

@ -4,6 +4,256 @@ icon: "list-check"
rss: true
---
<Update label="v2.14.5" description="2026-02-03">
**[v2.14.5: Sealed Docket](https://github.com/jlowin/fastmcp/releases/tag/v2.14.5)**
Fixes a memory leak in the memory:// docket broker where cancelled tasks accumulated instead of being cleaned up. Bumps pydocket to ≥0.17.2.
## What's Changed
### Enhancements 🔧
* Bump pydocket to 0.17.2 (memory leak fix) by [@chrisguidry](https://github.com/chrisguidry) in [#2992](https://github.com/jlowin/fastmcp/pull/2992)
**Full Changelog**: [v2.14.4...v2.14.5](https://github.com/jlowin/fastmcp/compare/v2.14.4...v2.14.5)
</Update>
<Update label="v2.14.4" description="2026-01-22">
**[v2.14.4: Package Deal](https://github.com/jlowin/fastmcp/releases/tag/v2.14.4)**
Fixes a fresh install bug where the packaging library was missing as a direct dependency, plus backports from 3.x for $ref dereferencing in tool schemas and a task capabilities location fix.
## What's Changed
### Enhancements 🔧
* Add release notes for v2.14.2 and v2.14.3 by [@jlowin](https://github.com/jlowin) in [#2851](https://github.com/jlowin/fastmcp/pull/2851)
### Fixes 🐞
* Backport: Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2861](https://github.com/jlowin/fastmcp/pull/2861)
* Fix task capabilities location (issue #2870) by [@jlowin](https://github.com/jlowin) in [#2874](https://github.com/jlowin/fastmcp/pull/2874)
* Add missing packaging dependency by [@jlowin](https://github.com/jlowin) in [#2989](https://github.com/jlowin/fastmcp/pull/2989)
**Full Changelog**: [v2.14.3...v2.14.4](https://github.com/jlowin/fastmcp/compare/v2.14.3...v2.14.4)
</Update>
<Update label="v2.14.3" description="2026-01-12">
**[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)
</Update>
<Update label="v2.14.2" description="2025-12-31">
**[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)
</Update>
<Update label="v2.14.1" description="2025-12-15">
**[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)
</Update>
<Update label="v2.14.0" description="2025-12-11">
**[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)
</Update>
<Update label="v2.13.3" description="2025-12-03">
**[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)
</Update>
<Update label="v2.13.2" description="2025-12-01">
**[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)
</Update>
<Update label="v2.13.1" description="2025-11-15">
**[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)
</Update>
<Update label="v2.13.0" description="2025-10-25">
**[v2.13.0: Cache Me If You Can](https://github.com/jlowin/fastmcp/releases/tag/v2.13.0)**

View file

@ -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"
}
},

View file

@ -23,6 +23,10 @@ Alternatively, you can install it directly with `pip` or `uv pip`:
```
</CodeGroup>
<Warning>
**FastMCP 3.0** is in development and may include breaking changes. To avoid unexpected issues, pin your dependency to v2: `fastmcp<3`
</Warning>
### Verify Installation
To verify that FastMCP is installed correctly, you can run the following command:

View file

@ -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/).
<Warning>
**FastMCP 3.0** is in development and may include breaking changes. To avoid unexpected issues, pin your dependency to v2: `fastmcp<3`
</Warning>
## 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:

View file

@ -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)
</ParamField>
<ParamField path="FASTMCP_SERVER_AUTH_SUPABASE_AUTH_ROUTE" default="/auth/v1">
Your Supabase auth route (e.g., `/auth/v1`)
</ParamField>
<ParamField path="FASTMCP_SERVER_AUTH_SUPABASE_REQUIRED_SCOPES" default="[]">
Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid email` or `["openid", "email"]`)
</ParamField>

View file

@ -173,6 +173,10 @@ def my_tool() -> None:
By default, FastMCP converts Python functions into MCP tools by inspecting the function's signature and type annotations. This allows you to use standard Python type annotations for your tools. In general, the framework strives to "just work": idiomatic Python behaviors like parameter defaults and type annotations are automatically translated into MCP schemas. However, there are a number of ways to customize the behavior of your tools.
<Note>
FastMCP automatically dereferences `$ref` entries in tool schemas to ensure compatibility with MCP clients that don't fully support JSON Schema references (e.g., VS Code Copilot, Claude Desktop). This means complex Pydantic models with shared types are inlined in the schema rather than using `$defs` references.
</Note>
### Type Annotations
MCP tools have typed arguments, and FastMCP uses type annotations to determine those types. Therefore, you should use standard Python type annotations for tool arguments:

View file

@ -5,6 +5,118 @@ icon: "sparkles"
tag: NEW
---
<Update label="FastMCP 2.14.5" description="February 3, 2026" tags={["Releases"]}>
<Card
title="FastMCP 2.14.5: Sealed Docket"
href="https://github.com/jlowin/fastmcp/releases/tag/v2.14.5"
cta="Read the release notes"
>
Fixes a memory leak in the memory:// docket broker where cancelled tasks accumulated instead of being cleaned up. Bumps pydocket to ≥0.17.2.
</Card>
</Update>
<Update label="FastMCP 2.14.4" description="January 22, 2026" tags={["Releases"]}>
<Card
title="FastMCP 2.14.4: Package Deal"
href="https://github.com/jlowin/fastmcp/releases/tag/v2.14.4"
cta="Read the release notes"
>
Fixes a fresh install bug where the packaging library was missing as a direct dependency, plus backports $ref dereferencing in tool schemas and a task capabilities location fix.
</Card>
</Update>
<Update label="FastMCP 2.14.3" description="January 12, 2026" tags={["Releases"]}>
<Card
title="FastMCP 2.14.3: Time After Timeout"
href="https://github.com/jlowin/fastmcp/releases/tag/v2.14.3"
cta="Read the release notes"
>
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.
</Card>
</Update>
<Update label="FastMCP 2.14.2" description="December 31, 2025" tags={["Releases"]}>
<Card
title="FastMCP 2.14.2: Port Authority"
href="https://github.com/jlowin/fastmcp/releases/tag/v2.14.2"
cta="Read the release notes"
>
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.
</Card>
</Update>
<Update label="FastMCP 2.14.1" description="December 15, 2025" tags={["Releases"]}>
<Card
title="FastMCP 2.14.1: 'Tis a Gift to Be Sample"
href="https://github.com/jlowin/fastmcp/releases/tag/v2.14.1"
cta="Read the release notes"
>
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.
</Card>
</Update>
<Update label="FastMCP 2.14.0" description="December 11, 2025" tags={["Releases"]}>
<Card
title="FastMCP 2.14.0: Task and You Shall Receive"
href="https://github.com/jlowin/fastmcp/releases/tag/v2.14.0"
cta="Read the release notes"
>
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.
</Card>
</Update>
<Update label="FastMCP 2.13.3" description="December 3, 2025" tags={["Releases"]}>
<Card
title="FastMCP 2.13.3: Pin-ish Line"
href="https://github.com/jlowin/fastmcp/releases/tag/v2.13.3"
cta="Read the release notes"
>
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.
</Card>
</Update>
<Update label="FastMCP 2.13.2" description="December 1, 2025" tags={["Releases"]}>
<Card
title="FastMCP 2.13.2: Refreshing Changes"
href="https://github.com/jlowin/fastmcp/releases/tag/v2.13.2"
cta="Read the release notes"
>
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.
</Card>
</Update>
<Update label="FastMCP 2.13.1" description="November 15, 2025" tags={["Releases"]}>
<Card
title="FastMCP 2.13.1: Heavy Meta"
href="https://github.com/jlowin/fastmcp/releases/tag/v2.13.1"
cta="Read the release notes"
>
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.
</Card>
</Update>
<Update label="FastMCP 2.13.0" description="October 25, 2025" tags={["Releases"]}>
<Card
title="FastMCP 2.13.0: Cache Me If You Can"

View file

@ -7,10 +7,10 @@ dependencies = [
"python-dotenv>=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 @ git+https://github.com/chrisguidry/docket.git@main",
"rich>=13.9.4",
"cyclopts>=4.0.0",
"authlib>=1.6.5",
@ -20,6 +20,8 @@ dependencies = [
"uvicorn>=0.35",
"websockets>=15.0.1",
"jsonschema-path>=0.3.4",
"jsonref>=1.1.0",
"packaging>=20.0",
]
requires-python = ">=3.10"
@ -101,7 +103,6 @@ style = "pep440"
bump = true
fallback-version = "0.0.0"
[tool.pytest.ini_options]
asyncio_mode = "auto"
# filterwarnings = ["error::DeprecationWarning"]

View file

@ -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(

View file

@ -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

View file

@ -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
@ -36,7 +36,6 @@ from fastmcp.client.auth.oauth import OAuth
from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.server import FastMCP
from fastmcp.server.tasks.capabilities import get_task_capabilities
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
@ -284,25 +283,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 (
@ -894,16 +899,11 @@ class FastMCPTransport(ClientTransport):
anyio.create_task_group() as tg,
_enter_server_lifespan(server=self.server),
):
# Build experimental capabilities
experimental_capabilities = get_task_capabilities()
tg.start_soon(
lambda: self.server._mcp_server.run(
server_read,
server_write,
self.server._mcp_server.create_initialization_options(
experimental_capabilities=experimental_capabilities
),
self.server._mcp_server.create_initialization_options(),
raise_exceptions=self.raise_exceptions,
)
)

View file

@ -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:

View file

@ -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}"

View file

@ -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,
)

View file

@ -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)

View file

@ -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()

View file

@ -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")

View file

@ -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(

View file

@ -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]:

View file

@ -21,7 +21,6 @@ from starlette.types import Lifespan, Receive, Scope, Send
from fastmcp.server.auth import AuthProvider
from fastmcp.server.auth.middleware import RequireAuthMiddleware
from fastmcp.server.tasks.capabilities import get_task_capabilities
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
@ -160,15 +159,10 @@ def create_sse_app(
# Create handler for SSE connections
async def handle_sse(scope: Scope, receive: Receive, send: Send) -> Response:
async with sse.connect_sse(scope, receive, send) as streams:
# Build experimental capabilities
experimental_capabilities = get_task_capabilities()
await server._mcp_server.run(
streams[0],
streams[1],
server._mcp_server.create_initialization_options(
experimental_capabilities=experimental_capabilities
),
server._mcp_server.create_initialization_options(),
)
return Response()

View file

@ -163,6 +163,31 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]):
**kwargs,
)
def get_capabilities(
self,
notification_options: NotificationOptions,
experimental_capabilities: dict[str, dict[str, Any]],
) -> mcp.types.ServerCapabilities:
"""Override to set capabilities.tasks as a first-class field per SEP-1686.
This ensures task capabilities appear in capabilities.tasks instead of
capabilities.experimental.tasks, which is required by the MCP spec and
enables proper task detection by clients like VS Code Copilot 1.107+.
"""
from fastmcp.server.tasks.capabilities import get_task_capabilities
# Get base capabilities from SDK (pass empty dict for experimental)
# since we'll set tasks as a first-class field instead
capabilities = super().get_capabilities(
notification_options,
experimental_capabilities or {},
)
# Set tasks as a first-class field (not experimental) per SEP-1686
capabilities.tasks = get_task_capabilities()
return capabilities
async def run(
self,
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],

View file

@ -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(

View file

@ -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 = (

View file

@ -75,7 +75,6 @@ from fastmcp.server.http import (
)
from fastmcp.server.low_level import LowLevelServer
from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.server.tasks.capabilities import get_task_capabilities
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.server.tasks.handlers import (
handle_prompt_as_task,
@ -105,23 +104,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 +196,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 +420,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 +428,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 +468,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 +477,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 +518,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 +527,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 +539,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 +548,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 +564,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 +2498,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():
@ -2528,9 +2507,6 @@ class FastMCP(Generic[LifespanResultT]):
f"Starting MCP server {self.name!r} with transport 'stdio'"
)
# Build experimental capabilities
experimental_capabilities = get_task_capabilities()
await self._mcp_server.run(
read_stream,
write_stream,
@ -2538,7 +2514,6 @@ class FastMCP(Generic[LifespanResultT]):
notification_options=NotificationOptions(
tools_changed=True
),
experimental_capabilities=experimental_capabilities,
),
)
@ -2582,22 +2557,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] = {

View file

@ -1,22 +1,30 @@
"""SEP-1686 task capabilities declaration."""
from typing import Any
from mcp.types import (
ServerTasksCapability,
ServerTasksRequestsCapability,
TasksCallCapability,
TasksCancelCapability,
TasksListCapability,
TasksToolsCapability,
)
def get_task_capabilities() -> dict[str, Any]:
"""Return the SEP-1686 task capabilities structure.
def get_task_capabilities() -> ServerTasksCapability:
"""Return the SEP-1686 task capabilities.
This is the standard capabilities map advertised to clients,
declaring support for list, cancel, and request operations.
Returns task capabilities as a first-class ServerCapabilities field,
declaring support for list, cancel, and request operations per SEP-1686.
Note: prompts/resources are passed via extra_data since the SDK types
don't include them yet (FastMCP supports them ahead of the spec).
"""
return {
"tasks": {
"list": {},
"cancel": {},
"requests": {
"tools": {"call": {}},
"prompts": {"get": {}},
"resources": {"read": {}},
},
}
}
return ServerTasksCapability(
list=TasksListCapability(),
cancel=TasksCancelCapability(),
requests=ServerTasksRequestsCapability(
tools=TasksToolsCapability(call=TasksCallCapability()),
prompts={"get": {}}, # type: ignore[call-arg] # extra_data for forward compat
resources={"read": {}}, # type: ignore[call-arg] # extra_data for forward compat
),
)

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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,
@ -323,7 +323,7 @@ class FunctionTool(Tool):
warnings.warn(
"The `exclude_args` parameter is deprecated as of FastMCP 2.14. "
"Use dependency injection with `Depends()` instead for better lifecycle management. "
"See https://gofastmcp.com/servers/dependencies for examples.",
"See https://gofastmcp.com/v2/servers/context#using-depends for examples.",
DeprecationWarning,
stacklevel=2,
)
@ -497,7 +497,10 @@ class ParsedFunction:
# Compress and handle exclude_args
prune_params = list(exclude_args) if exclude_args else None
input_schema = compress_schema(
input_schema, prune_params=prune_params, prune_titles=True
input_schema,
prune_params=prune_params,
prune_titles=True,
dereference=True,
)
output_schema = None
@ -557,7 +560,13 @@ class ParsedFunction:
else:
output_schema = base_schema
output_schema = compress_schema(output_schema, prune_titles=True)
output_schema = compress_schema(
output_schema, prune_titles=True, dereference=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):

View file

@ -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:

View file

@ -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))

View file

@ -1,9 +1,196 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any
from jsonref import JsonRefError, replace_refs
def _prune_param(schema: dict, param: str) -> dict:
def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]:
"""Resolve all $ref references in a JSON schema by inlining definitions.
This function resolves $ref references that point to $defs, replacing them
with the actual definition content while preserving sibling keywords (like
description, default, examples) that Pydantic places alongside $ref.
This is necessary because some MCP clients (e.g., VS Code Copilot) don't
properly handle $ref in tool input schemas.
For self-referencing/circular schemas where full dereferencing is not possible,
this function falls back to resolving only the root-level $ref while preserving
$defs for nested references.
Args:
schema: JSON schema dict that may contain $ref references
Returns:
A new schema dict with $ref resolved where possible and $defs removed
when no longer needed
Example:
>>> schema = {
... "$defs": {"Category": {"enum": ["a", "b"], "type": "string"}},
... "properties": {"cat": {"$ref": "#/$defs/Category", "default": "a"}}
... }
>>> resolved = dereference_refs(schema)
>>> # Result: {"properties": {"cat": {"enum": ["a", "b"], "type": "string", "default": "a"}}}
"""
try:
# Use jsonref to resolve all $ref references
# proxies=False returns plain dicts (not proxy objects)
# lazy_load=False resolves immediately
dereferenced = replace_refs(schema, proxies=False, lazy_load=False)
# Merge sibling keywords that were lost during dereferencing
# Pydantic puts description, default, examples as siblings to $ref
defs = schema.get("$defs", {})
merged = _merge_ref_siblings(schema, dereferenced, defs)
# Type assertion: top-level schema is always a dict
assert isinstance(merged, dict)
dereferenced = merged
# Remove $defs since all references have been resolved
if "$defs" in dereferenced:
dereferenced = {k: v for k, v in dereferenced.items() if k != "$defs"}
# Remove discriminator.mapping entries that referenced $defs
_strip_discriminator_mappings(dereferenced)
return dereferenced
except JsonRefError:
# Self-referencing/circular schemas can't be fully dereferenced
# Fall back to resolving only root-level $ref (for MCP spec compliance)
return resolve_root_ref(schema)
def _strip_discriminator_mappings(schema: Any, depth: int = 0) -> None:
"""Remove discriminator.mapping entries whose values are $defs references.
Pydantic emits discriminator.mapping with plain-string references like
``"#/$defs/Cat"`` that become dangling after $defs are removed by
dereference_refs(). The oneOf/anyOf variants already carry their own
const fields, so the mapping is redundant once refs are inlined.
"""
if depth > 50 or not isinstance(schema, dict):
return
if "discriminator" in schema and isinstance(schema["discriminator"], dict):
schema["discriminator"].pop("mapping", None)
for value in schema.values():
if isinstance(value, dict):
_strip_discriminator_mappings(value, depth + 1)
elif isinstance(value, list):
for item in value:
_strip_discriminator_mappings(item, depth + 1)
def _merge_ref_siblings(
original: Any,
dereferenced: Any,
defs: dict[str, Any],
visited: set[str] | None = None,
) -> Any:
"""Merge sibling keywords from original $ref nodes into dereferenced schema.
When jsonref resolves $ref, it replaces the entire node with the referenced
definition, losing any sibling keywords like description, default, or examples.
This function walks both trees in parallel and merges those siblings back.
Args:
original: The original schema with $ref and potential siblings
dereferenced: The schema after jsonref processing
defs: The $defs from the original schema, for looking up referenced definitions
visited: Set of definition names already being processed (prevents cycles)
Returns:
The dereferenced schema with sibling keywords restored
"""
if visited is None:
visited = set()
if isinstance(original, dict) and isinstance(dereferenced, dict):
# Check if original had a $ref
if "$ref" in original:
ref = original["$ref"]
siblings = {k: v for k, v in original.items() if k not in ("$ref", "$defs")}
# Look up the referenced definition to process its nested siblings
if isinstance(ref, str) and ref.startswith("#/$defs/"):
def_name = ref.split("/")[-1]
# Prevent infinite recursion on circular references
if def_name in defs and def_name not in visited:
# Recursively process the definition's content for nested siblings
dereferenced = _merge_ref_siblings(
defs[def_name], dereferenced, defs, visited | {def_name}
)
if siblings:
# Merge local siblings, which take precedence
merged = dict(dereferenced)
merged.update(siblings)
return merged
return dereferenced
# Recurse into nested structures
result = {}
for key, value in dereferenced.items():
if key in original:
result[key] = _merge_ref_siblings(original[key], value, defs, visited)
else:
result[key] = value
return result
elif isinstance(original, list) and isinstance(dereferenced, list):
# Process list items in parallel
min_len = min(len(original), len(dereferenced))
return [
_merge_ref_siblings(o, d, defs, visited)
for o, d in zip(original[:min_len], dereferenced[:min_len], strict=False)
] + dereferenced[min_len:]
return dereferenced
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[str, Any], param: str) -> dict[str, Any]:
"""Return a new schema with *param* removed from `properties`, `required`,
and (if no longer referenced) `$defs`.
"""
@ -25,11 +212,11 @@ def _prune_param(schema: dict, param: str) -> dict:
def _single_pass_optimize(
schema: dict,
schema: dict[str, Any],
prune_titles: bool = False,
prune_additional_properties: bool = False,
prune_defs: bool = True,
) -> dict:
) -> dict[str, Any]:
"""
Optimize JSON schemas in a single traversal for better performance.
@ -198,12 +385,13 @@ def _single_pass_optimize(
def compress_schema(
schema: dict,
schema: dict[str, Any],
prune_params: list[str] | None = None,
prune_defs: bool = True,
prune_additional_properties: bool = True,
prune_titles: bool = False,
) -> dict:
dereference: bool = False,
) -> dict[str, Any]:
"""
Remove the given parameters from the schema.
@ -213,6 +401,7 @@ def compress_schema(
prune_defs: Whether to remove unused definitions
prune_additional_properties: Whether to remove additionalProperties: false
prune_titles: Whether to remove title fields from the schema
dereference: Whether to inline $ref references for client compatibility
"""
# Remove specific parameters if requested
for param in prune_params or []:
@ -227,4 +416,8 @@ def compress_schema(
prune_defs=prune_defs,
)
# Inline $ref references for MCP clients that don't handle them
if dereference:
schema = dereference_refs(schema)
return schema

View file

@ -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()):

View file

@ -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

View file

@ -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"

View file

@ -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": {

View file

@ -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):

View file

@ -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):

View file

@ -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})

View file

@ -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"

View file

@ -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(

View file

@ -8,6 +8,7 @@ This test suite covers:
"""
import pytest
from key_value.aio.stores.memory import MemoryStore
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
from starlette.applications import Starlette
@ -42,6 +43,7 @@ class TestEnhancedAuthorizationHandler:
),
base_url="https://myserver.com",
jwt_signing_key="test-secret",
client_storage=MemoryStore(),
)
def test_unregistered_client_returns_html_for_browser(self, oauth_proxy):
@ -303,6 +305,7 @@ class TestContentNegotiation:
),
base_url="https://myserver.com",
jwt_signing_key="test-secret",
client_storage=MemoryStore(),
)
def test_html_preferred_when_both_accepted(self, oauth_proxy):

View file

@ -8,6 +8,7 @@ The fix uses MCP SDK 1.17+ which implements RFC 9728 path-scoped well-known URLs
import httpx
import pytest
from key_value.aio.stores.memory import MemoryStore
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.routing import Mount
@ -220,6 +221,7 @@ class TestOAuthMounting:
token_verifier=token_verifier,
base_url="https://api.example.com/api", # Includes mount prefix
issuer_url="https://api.example.com", # Root level
client_storage=MemoryStore(),
)
mcp = FastMCP("test-server", auth=auth_provider)
@ -290,6 +292,7 @@ class TestOAuthMounting:
upstream_client_secret="test-client-secret",
token_verifier=token_verifier,
base_url="https://api.example.com/api", # Has path, no explicit issuer_url
client_storage=MemoryStore(),
)
mcp = FastMCP("test-server", auth=auth_provider)
@ -366,6 +369,7 @@ class TestOAuthMounting:
token_verifier=token_verifier,
base_url="https://api.example.com/api",
issuer_url="https://api.example.com", # Explicitly root
client_storage=MemoryStore(),
)
well_known_routes = auth_provider.get_well_known_routes(mcp_path="/mcp")

View file

@ -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

View file

@ -1,6 +1,7 @@
"""Tests for OAuth proxy redirect URI validation."""
import pytest
from key_value.aio.stores.memory import MemoryStore
from mcp.shared.auth import InvalidRedirectUriError
from pydantic import AnyUrl
@ -111,6 +112,7 @@ class TestOAuthProxyRedirectValidation:
token_verifier=MockTokenVerifier(),
base_url="http://localhost:8000",
jwt_signing_key="test-secret",
client_storage=MemoryStore(),
)
# The proxy should store None for default (allow all)
@ -129,6 +131,7 @@ class TestOAuthProxyRedirectValidation:
base_url="http://localhost:8000",
allowed_client_redirect_uris=custom_patterns,
jwt_signing_key="test-secret",
client_storage=MemoryStore(),
)
assert proxy._allowed_client_redirect_uris == custom_patterns
@ -144,6 +147,7 @@ class TestOAuthProxyRedirectValidation:
base_url="http://localhost:8000",
allowed_client_redirect_uris=[],
jwt_signing_key="test-secret",
client_storage=MemoryStore(),
)
assert proxy._allowed_client_redirect_uris == []
@ -161,6 +165,7 @@ class TestOAuthProxyRedirectValidation:
base_url="http://localhost:8000",
allowed_client_redirect_uris=custom_patterns,
jwt_signing_key="test-secret",
client_storage=MemoryStore(),
)
# Register a client
@ -194,6 +199,7 @@ class TestOAuthProxyRedirectValidation:
base_url="http://localhost:8000",
allowed_client_redirect_uris=custom_patterns,
jwt_signing_key="test-secret",
client_storage=MemoryStore(),
)
# Get an unregistered client

View file

@ -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()

View file

@ -477,6 +477,8 @@ class TestResourceToolMiddleware:
async def test_read_resource_tool_works(self, server_with_resources: FastMCP):
"""Test that the read_resource tool can be called."""
import json
middleware = ResourceToolMiddleware()
server_with_resources.add_middleware(middleware)
@ -485,14 +487,18 @@ class TestResourceToolMiddleware:
name="read_resource", arguments={"uri": "file://config.txt"}
)
assert result.content == snapshot(
[
TextContent(
type="text",
text='[{"content":"debug=true","mime_type":"text/plain"}]',
)
]
)
assert result.structured_content == snapshot(
{"result": [{"content": "debug=true", "mime_type": "text/plain"}]}
)
# Parse the JSON text content to check the actual values
# (MCP 1.26+ includes "meta":null in serialization, earlier versions don't)
assert len(result.content) == 1
assert result.content[0].type == "text"
parsed = json.loads(result.content[0].text) # type: ignore[attr-defined]
assert len(parsed) == 1
assert parsed[0]["content"] == "debug=true"
assert parsed[0]["mime_type"] == "text/plain"
# Check structured content similarly
assert result.structured_content is not None
assert "result" in result.structured_content
assert len(result.structured_content["result"]) == 1
assert result.structured_content["result"][0]["content"] == "debug=true"
assert result.structured_content["result"][0]["mime_type"] == "text/plain"

View file

@ -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
):

View file

@ -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"

View file

@ -11,7 +11,7 @@ from fastmcp.server.tasks import get_task_capabilities
async def test_capabilities_include_tasks():
"""Server capabilities always include tasks."""
"""Server capabilities always include tasks in first-class field (SEP-1686)."""
mcp = FastMCP("capability-test")
@mcp.tool()
@ -22,11 +22,11 @@ async def test_capabilities_include_tasks():
# Get server initialization result which includes capabilities
init_result = client.initialize_result
# Verify tasks capability is present
assert init_result.capabilities.experimental is not None
assert "tasks" in init_result.capabilities.experimental
tasks_cap = init_result.capabilities.experimental["tasks"]
assert tasks_cap == get_task_capabilities()["tasks"]
# Verify tasks capability is present as a first-class field (not experimental)
assert init_result.capabilities.tasks is not None
assert init_result.capabilities.tasks == get_task_capabilities()
# Verify it's NOT in experimental
assert "tasks" not in (init_result.capabilities.experimental or {})
async def test_client_uses_task_capable_session():

View file

@ -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"
)

View file

@ -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]

View file

@ -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

View file

@ -194,18 +194,15 @@ class TestToolFromFunction:
"tags": set(),
"enabled": True,
"parameters": {
"$defs": {
"UserInput": {
"properties": {
"user": {
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
"required": ["name", "age"],
"type": "object",
}
},
"properties": {
"user": {"$ref": "#/$defs/UserInput"},
},
"flag": {"type": "boolean"},
},
"required": ["user", "flag"],
@ -1296,6 +1293,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 +1618,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."""

View file

@ -75,8 +75,9 @@ class TestAddTools:
assert tool is not None
assert tool.name == "create_user"
assert tool.description == "Create a new user."
assert "name" in tool.parameters["$defs"]["UserInput"]["properties"]
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
# $refs are dereferenced so UserInput is inlined
assert "name" in tool.parameters["properties"]["user"]["properties"]
assert "age" in tool.parameters["properties"]["user"]["properties"]
assert "flag" in tool.parameters["properties"]
async def test_callable_object(self):

View file

@ -216,10 +216,14 @@ async def test_hidden_param_prunes_defs():
schema = new_tool.parameters
# Only 'a' should be visible
assert list(schema["properties"].keys()) == ["a"]
# $defs should only contain VisibleType, not HiddenType
defs = schema.get("$defs", {})
assert "VisibleType" in defs
assert "HiddenType" not in defs
# $refs are dereferenced so VisibleType is inlined, not in $defs
assert "$defs" not in schema
# VisibleType's structure should be inlined into the property
assert schema["properties"]["a"] == {
"properties": {"x": {"type": "integer"}},
"required": ["x"],
"type": "object",
}
async def test_forward_with_argument_mapping(add_tool):
@ -427,7 +431,8 @@ def test_transform_args_with_parent_defaults():
new_tool = Tool.from_tool(tool)
assert new_tool.parameters["$defs"] == tool.parameters["$defs"]
# $refs are dereferenced, so schemas should match (both inlined)
assert new_tool.parameters == tool.parameters
def test_transform_args_validation_unknown_arg(add_tool):
@ -1567,20 +1572,20 @@ class TestInputSchema:
complex_tool, transform_args={"unused_param": ArgTransform(hide=True)}
)
assert "UnusedType" not in transformed_tool.parameters["$defs"]
# $refs are dereferenced, so no $defs section
assert "$defs" not in transformed_tool.parameters
assert transformed_tool.parameters == snapshot(
{
"type": "object",
"properties": {"used_param": {"$ref": "#/$defs/UsedType"}},
"required": ["used_param"],
"$defs": {
"UsedType": {
"properties": {
"used_param": {
"properties": {"value": {"type": "string"}},
"required": ["value"],
"type": "object",
}
},
"required": ["used_param"],
}
)
@ -1610,15 +1615,14 @@ class TestInputSchema:
assert transformed.parameters == snapshot(
{
"type": "object",
"properties": {"renamed_input": {"$ref": "#/$defs/InputType"}},
"required": ["renamed_input"],
"$defs": {
"InputType": {
"properties": {
"renamed_input": {
"properties": {"data": {"type": "string"}},
"required": ["data"],
"type": "object",
}
},
"required": ["renamed_input"],
}
)
@ -1648,26 +1652,23 @@ class TestInputSchema:
{
"type": "object",
"properties": {
"param_a": {"$ref": "#/$defs/TypeA"},
"param_b": {"$ref": "#/$defs/TypeB"},
},
"required": IsList("param_b", "param_a", check_order=False),
"$defs": {
"TypeA": {
"param_a": {
"properties": {"a": {"type": "string"}},
"required": ["a"],
"type": "object",
},
"TypeB": {
"param_b": {
"properties": {"b": {"type": "integer"}},
"required": ["b"],
"type": "object",
},
},
"required": IsList("param_b", "param_a", check_order=False),
}
)
assert "TypeA" in transform1.parameters["$defs"]
# $refs are dereferenced, so TypeA is inlined
assert "$defs" not in transform1.parameters
# Second transform: hide param_b
transform2 = Tool.from_tool(
@ -1675,19 +1676,18 @@ class TestInputSchema:
transform_args={"param_b": ArgTransform(hide=True, default=TypeB(b=42))},
)
assert "TypeB" not in transform2.parameters["$defs"]
assert "$defs" not in transform2.parameters
assert transform2.parameters == snapshot(
{
"type": "object",
"properties": {"param_a": {"$ref": "#/$defs/TypeA"}},
"required": ["param_a"],
"$defs": {
"TypeA": {
"properties": {
"param_a": {
"properties": {"a": {"type": "string"}},
"required": ["a"],
"type": "object",
}
},
"required": ["param_a"],
}
)

View file

@ -1,6 +1,8 @@
from fastmcp.utilities.json_schema import (
_prune_param,
compress_schema,
dereference_refs,
resolve_root_ref,
)
# Wrapper for backward compatibility with tests
@ -505,3 +507,260 @@ class TestCompressSchema:
"title" not in compressed["properties"]["title"]["properties"]["subtitle"]
)
assert "title" not in compressed["properties"]["normal_field"]
class TestDereferenceRefs:
"""Tests for the dereference_refs function."""
def test_falls_back_for_circular_refs(self):
"""Test that circular references fall back to resolve_root_ref."""
schema = {
"$defs": {
"Node": {
"type": "object",
"properties": {
"children": {
"type": "array",
"items": {"$ref": "#/$defs/Node"},
}
},
}
},
"$ref": "#/$defs/Node",
}
result = dereference_refs(schema)
# Should fall back to resolve_root_ref behavior
# Root should be resolved but nested refs preserved
assert result.get("type") == "object"
assert "$defs" in result # $defs preserved for circular refs
def test_preserves_sibling_keywords(self):
"""Test that sibling keywords (default, description) are preserved.
Pydantic places description, default, examples as siblings to $ref.
These should not be lost during dereferencing.
"""
schema = {
"$defs": {
"Status": {"type": "string", "enum": ["active", "inactive"]},
},
"properties": {
"status": {
"$ref": "#/$defs/Status",
"default": "active",
"description": "The user status",
},
},
"type": "object",
}
result = dereference_refs(schema)
# $ref should be inlined with siblings preserved
status = result["properties"]["status"]
assert status["type"] == "string"
assert status["enum"] == ["active", "inactive"]
assert status["default"] == "active"
assert status["description"] == "The user status"
# $defs should be removed
assert "$defs" not in result
def test_preserves_siblings_in_lists(self):
"""Test that siblings are preserved for $refs inside lists (allOf, anyOf, etc)."""
schema = {
"$defs": {
"StringType": {"type": "string"},
"IntType": {"type": "integer"},
},
"properties": {
"field": {
"anyOf": [
{"$ref": "#/$defs/StringType", "description": "As string"},
{"$ref": "#/$defs/IntType", "description": "As integer"},
]
},
},
}
result = dereference_refs(schema)
# Both items in anyOf should have their siblings preserved
any_of = result["properties"]["field"]["anyOf"]
assert any_of[0]["type"] == "string"
assert any_of[0]["description"] == "As string"
assert any_of[1]["type"] == "integer"
assert any_of[1]["description"] == "As integer"
assert "$defs" not in result
def test_preserves_nested_siblings(self):
"""Test that siblings on nested $refs are preserved."""
schema = {
"$defs": {
"Address": {
"type": "object",
"properties": {
"country": {"$ref": "#/$defs/Country", "default": "US"},
},
},
"Country": {"type": "string", "enum": ["US", "UK", "CA"]},
},
"properties": {
"home_address": {"$ref": "#/$defs/Address"},
},
}
result = dereference_refs(schema)
# The nested $ref's sibling (default) should be preserved
country = result["properties"]["home_address"]["properties"]["country"]
assert country["type"] == "string"
assert country["enum"] == ["US", "UK", "CA"]
assert country["default"] == "US"
assert "$defs" not in result
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

View file

@ -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)

1031
uv.lock generated

File diff suppressed because it is too large Load diff