From 5a98ceb5caf0879dc75dc732f86e61db58042c20 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Tue, 28 Jul 2026 17:15:48 -0400
Subject: [PATCH 01/32] Add v4.0.0b1 changelog and updates entries (#4681)
* Add v4.0.0b1 changelog and updates entries
* Drop meta note from b1 intro; add #4682 under enhancements
* File #4682 under fixes
* Correct the camelCase rename claim: Python model fields, not the wire
* Baseline the b1 changelog on v3.4.5
* Simplify the beta banner
---
docs/changelog.mdx | 176 ++++++++++++++++++
docs/docs.json | 2 +-
.../upgrading/from-fastmcp-3.mdx | 2 +-
docs/updates.mdx | 22 +++
4 files changed, 200 insertions(+), 2 deletions(-)
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index c22d48e74..09a52bbd8 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -5,6 +5,182 @@ rss: true
tag: NEW
---
+
+
+**[v4.0.0b1: Fourgone Conclusion](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1)**
+
+FastMCP 4 rebuilds the framework on the MCP Python SDK v2, and this beta is the first release to run on the SDK's stable 2.0. The SDK v2 rewrote the protocol layer end to end โ protocol types moved into a standalone `mcp_types` package, every model field renamed from camelCase to snake_case in Python, and the server's request-handling model replaced โ and FastMCP absorbs nearly all of it, so most FastMCP 3 servers run untouched. On that foundation v4 serves the sessionless `2026-07-28` protocol and the older handshake from one server, adds stateless session state and background tasks, makes protocol extensions a first-class surface, and removes server-initiated sampling and roots from the server API.
+
+### New Features ๐
+* Migrate to MCP Python SDK v2 by [@jlowin](https://github.com/jlowin) in [#4437](https://github.com/PrefectHQ/fastmcp/pull/4437)
+* Teach fastmcp.Client the modern protocol: mode negotiation, MRTR driver, response cache by [@jlowin](https://github.com/jlowin) in [#4450](https://github.com/PrefectHQ/fastmcp/pull/4450)
+* Forward-port Hugging Face auth provider by [@jlowin](https://github.com/jlowin) in [#4475](https://github.com/PrefectHQ/fastmcp/pull/4475)
+* Add server-side identity assertion (SEP-990 ID-JAG) by [@jlowin](https://github.com/jlowin) in [#4483](https://github.com/PrefectHQ/fastmcp/pull/4483)
+* Add guard-mode multi-round-trip tools (SEP-2322) by [@jlowin](https://github.com/jlowin) in [#4544](https://github.com/PrefectHQ/fastmcp/pull/4544)
+* Add FastMCP-native server extension API (SEP-2133) by [@jlowin](https://github.com/jlowin) in [#4602](https://github.com/PrefectHQ/fastmcp/pull/4602)
+* Add stateless session state (UserSession / SessionId) by [@jlowin](https://github.com/jlowin) in [#4604](https://github.com/PrefectHQ/fastmcp/pull/4604)
+* Add background tasks via the io.modelcontextprotocol/tasks extension (SEP-2663) by [@jlowin](https://github.com/jlowin) in [#4603](https://github.com/PrefectHQ/fastmcp/pull/4603)
+### Breaking Changes โ ๏ธ
+* Emit one SERVER span per request and adopt spec-correct error codes by [@jlowin](https://github.com/jlowin) in [#4445](https://github.com/PrefectHQ/fastmcp/pull/4445)
+* Remove 3.x deprecated module shims and dead parameters by [@jlowin](https://github.com/jlowin) in [#4447](https://github.com/PrefectHQ/fastmcp/pull/4447)
+* Remove 3.0-deprecated FastMCP server methods by [@jlowin](https://github.com/jlowin) in [#4451](https://github.com/PrefectHQ/fastmcp/pull/4451)
+* Remove 3.x deprecated parameters and object-mode decorators by [@jlowin](https://github.com/jlowin) in [#4453](https://github.com/PrefectHQ/fastmcp/pull/4453)
+* Migrate to MCP SDK v2.0.0b2 (httpx2) by [@jlowin](https://github.com/jlowin) in [#4503](https://github.com/PrefectHQ/fastmcp/pull/4503)
+* Fix typos by [@szepeviktor](https://github.com/szepeviktor) in [#4498](https://github.com/PrefectHQ/fastmcp/pull/4498)
+* Stop proxies from validating backend results or mutating shared transports by [@jlowin](https://github.com/jlowin) in [#4552](https://github.com/PrefectHQ/fastmcp/pull/4552)
+* Surface resource, prompt, and proxy errors on the modern protocol by [@jlowin](https://github.com/jlowin) in [#4579](https://github.com/PrefectHQ/fastmcp/pull/4579)
+* Negotiate the best mutual protocol era by default by [@jlowin](https://github.com/jlowin) in [#4572](https://github.com/PrefectHQ/fastmcp/pull/4572)
+* Remove server-initiated sampling and roots from the server API by [@jlowin](https://github.com/jlowin) in [#4648](https://github.com/PrefectHQ/fastmcp/pull/4648)
+* Remove 3.x-era compatibility shims by [@jlowin](https://github.com/jlowin) in [#4661](https://github.com/PrefectHQ/fastmcp/pull/4661)
+### Enhancements โจ
+* Deprecate ctx.sample and add clear errors for push features on 2026 connections by [@jlowin](https://github.com/jlowin) in [#4448](https://github.com/PrefectHQ/fastmcp/pull/4448)
+* Add server-level cache hints (SEP-2549) by [@jlowin](https://github.com/jlowin) in [#4464](https://github.com/PrefectHQ/fastmcp/pull/4464)
+* Add KeyValueResponseCacheStore for distributed client response caching by [@jlowin](https://github.com/jlowin) in [#4479](https://github.com/PrefectHQ/fastmcp/pull/4479)
+* Test lifespan fires once per process over HTTP by [@jlowin](https://github.com/jlowin) in [#4480](https://github.com/PrefectHQ/fastmcp/pull/4480)
+* Add telemetry off-switch and mcp.protocol.version span attribute by [@jlowin](https://github.com/jlowin) in [#4481](https://github.com/PrefectHQ/fastmcp/pull/4481)
+* Trace client task management requests by [@jlowin](https://github.com/jlowin) in [#4525](https://github.com/PrefectHQ/fastmcp/pull/4525)
+* Stabilize upgraded ty checks by [@jlowin](https://github.com/jlowin) in [#4526](https://github.com/PrefectHQ/fastmcp/pull/4526)
+* Improve DescopeProvider scope discovery and well-known URL support by [@gaokevin1](https://github.com/gaokevin1) in [#4489](https://github.com/PrefectHQ/fastmcp/pull/4489)
+* Add examples/ to the ty static-analysis gate by [@jlowin](https://github.com/jlowin) in [#4466](https://github.com/PrefectHQ/fastmcp/pull/4466)
+* Expose telemetry attributes on span start by [@zzstoatzz](https://github.com/zzstoatzz) in [#4487](https://github.com/PrefectHQ/fastmcp/pull/4487)
+* Fix-issue-4284 : Add Auth0MCPProvider for Auth0 Auth for MCP by [@vijaydeepsinha](https://github.com/vijaydeepsinha) in [#4411](https://github.com/PrefectHQ/fastmcp/pull/4411)
+* Run FastMCP middleware for every inbound message by [@jlowin](https://github.com/jlowin) in [#4553](https://github.com/PrefectHQ/fastmcp/pull/4553)
+* Add 'prs welcome' label to waive the PR assignment gate by [@jlowin](https://github.com/jlowin) in [#4557](https://github.com/PrefectHQ/fastmcp/pull/4557)
+* Rename martian workflows to marvin by [@jlowin](https://github.com/jlowin) in [#4558](https://github.com/PrefectHQ/fastmcp/pull/4558)
+* Bump pinned Claude models to current versions by [@jlowin](https://github.com/jlowin) in [#4561](https://github.com/PrefectHQ/fastmcp/pull/4561)
+* Make the unit suite fast: in-process HTTP tests, no real sleeps, parallel Windows CI by [@jlowin](https://github.com/jlowin) in [#4554](https://github.com/PrefectHQ/fastmcp/pull/4554)
+* Mirror the frontend's protocol era on a proxy's backend connection by [@jlowin](https://github.com/jlowin) in [#4573](https://github.com/PrefectHQ/fastmcp/pull/4573)
+* Drop forked client protocol helpers in favor of the SDK's by [@jlowin](https://github.com/jlowin) in [#4574](https://github.com/PrefectHQ/fastmcp/pull/4574)
+* Bring the v4 developer notes up to date with what shipped by [@jlowin](https://github.com/jlowin) in [#4581](https://github.com/PrefectHQ/fastmcp/pull/4581)
+* Trim fastmcp.types to FastMCP-unique types by [@jlowin](https://github.com/jlowin) in [#4584](https://github.com/PrefectHQ/fastmcp/pull/4584)
+* Let a server answer argument-completion requests by [@jlowin](https://github.com/jlowin) in [#4582](https://github.com/PrefectHQ/fastmcp/pull/4582)
+* Add machine-to-machine client authentication by [@jlowin](https://github.com/jlowin) in [#4583](https://github.com/PrefectHQ/fastmcp/pull/4583)
+* Expose era-neutral client server metadata by [@zzstoatzz](https://github.com/zzstoatzz) in [#4599](https://github.com/PrefectHQ/fastmcp/pull/4599)
+* Support routable transport headers for gateways (SEP-2243) by [@jlowin](https://github.com/jlowin) in [#4622](https://github.com/PrefectHQ/fastmcp/pull/4622)
+* Emit scope step-up challenges for incremental authorization (SEP-2350) by [@jlowin](https://github.com/jlowin) in [#4623](https://github.com/PrefectHQ/fastmcp/pull/4623)
+* Honor OAuth application_type in DCR (SEP-837) by [@jlowin](https://github.com/jlowin) in [#4621](https://github.com/PrefectHQ/fastmcp/pull/4621)
+* Drop stale label-noting instructions from CLAUDE.md by [@jlowin](https://github.com/jlowin) in [#4654](https://github.com/PrefectHQ/fastmcp/pull/4654)
+* Add require_roles auth check by [@jlowin](https://github.com/jlowin) in [#4656](https://github.com/PrefectHQ/fastmcp/pull/4656)
+* Add `valid_scopes` parameter to OIDC proxy valid scopes by [@Educg550](https://github.com/Educg550) in [#4660](https://github.com/PrefectHQ/fastmcp/pull/4660)
+* feat: Add telemetry interop mode for FastMCP by [@strawgate](https://github.com/strawgate) in [#4046](https://github.com/PrefectHQ/fastmcp/pull/4046)
+* Note that review comment threads should get an acknowledgement by [@jlowin](https://github.com/jlowin) in [#4678](https://github.com/PrefectHQ/fastmcp/pull/4678)
+* Soften the review-comment reply guidance by [@jlowin](https://github.com/jlowin) in [#4683](https://github.com/PrefectHQ/fastmcp/pull/4683)
+* Resolve review threads on fix, reply on decline by [@jlowin](https://github.com/jlowin) in [#4685](https://github.com/PrefectHQ/fastmcp/pull/4685)
+* Move to the stable MCP Python SDK 2.0.0 by [@jlowin](https://github.com/jlowin) in [#4655](https://github.com/PrefectHQ/fastmcp/pull/4655)
+### Security ๐
+* Drive the FastMCP lifespan through the SDK session manager by [@jlowin](https://github.com/jlowin) in [#4446](https://github.com/PrefectHQ/fastmcp/pull/4446)
+* Route skill file access through SDK path-security primitives by [@jlowin](https://github.com/jlowin) in [#4449](https://github.com/PrefectHQ/fastmcp/pull/4449)
+* Screen templated resource parameters for path traversal by default by [@jlowin](https://github.com/jlowin) in [#4482](https://github.com/PrefectHQ/fastmcp/pull/4482)
+* [codex] Add OAuthProxy RFC 9207 issuer responses by [@jlowin](https://github.com/jlowin) in [#4438](https://github.com/PrefectHQ/fastmcp/pull/4438)
+* Apply app visibility where no host can by [@jlowin](https://github.com/jlowin) in [#4692](https://github.com/PrefectHQ/fastmcp/pull/4692)
+### Fixes ๐
+* Capture SharedContext for task-enabled Docket servers by [@jlowin](https://github.com/jlowin) in [#4443](https://github.com/PrefectHQ/fastmcp/pull/4443)
+* Fix stale mcp.types imports in examples by [@jlowin](https://github.com/jlowin) in [#4452](https://github.com/PrefectHQ/fastmcp/pull/4452)
+* Forward-port HTTP host guard compatibility by [@jlowin](https://github.com/jlowin) in [#4474](https://github.com/PrefectHQ/fastmcp/pull/4474)
+* Fix Azure scope fallback by [@zzstoatzz](https://github.com/zzstoatzz) in [#4469](https://github.com/PrefectHQ/fastmcp/pull/4469)
+* fix(server): omit ScalarElicitationType wrapper title from elicitation schemas by [@syf2211](https://github.com/syf2211) in [#4502](https://github.com/PrefectHQ/fastmcp/pull/4502)
+* Skip unsupported JWKS keys instead of failing the whole key set (#4515) by [@earfman](https://github.com/earfman) in [#4517](https://github.com/PrefectHQ/fastmcp/pull/4517)
+* Don't mutate the caller's schema in compress_schema by [@winklemad](https://github.com/winklemad) in [#4492](https://github.com/PrefectHQ/fastmcp/pull/4492)
+* Forward upstream instructions through create_proxy by [@verdie-g](https://github.com/verdie-g) in [#4512](https://github.com/PrefectHQ/fastmcp/pull/4512)
+* Serialize deep object query parameters by [@jlowin](https://github.com/jlowin) in [#4523](https://github.com/PrefectHQ/fastmcp/pull/4523)
+* Reject positional-only tool parameters by [@jlowin](https://github.com/jlowin) in [#4524](https://github.com/PrefectHQ/fastmcp/pull/4524)
+* Clarify PR-reopen flow and fix label-race that broke auto-reopen by [@jlowin](https://github.com/jlowin) in [#4518](https://github.com/PrefectHQ/fastmcp/pull/4518)
+* Clean up disconnected task sessions by [@jlowin](https://github.com/jlowin) in [#4519](https://github.com/PrefectHQ/fastmcp/pull/4519)
+* Handle expired OAuth client registrations by [@jlowin](https://github.com/jlowin) in [#4520](https://github.com/PrefectHQ/fastmcp/pull/4520)
+* Fix OAuth request annotation after httpx2 migration by [@jlowin](https://github.com/jlowin) in [#4534](https://github.com/PrefectHQ/fastmcp/pull/4534)
+* Fix docs banner contrast by [@jlowin](https://github.com/jlowin) in [#4522](https://github.com/PrefectHQ/fastmcp/pull/4522)
+* Preserve component metadata in response cache by [@jlowin](https://github.com/jlowin) in [#4521](https://github.com/PrefectHQ/fastmcp/pull/4521)
+* Clean up task sessions on connection exit by [@jlowin](https://github.com/jlowin) in [#4535](https://github.com/PrefectHQ/fastmcp/pull/4535)
+* Include scopes in auth challenges by [@jlowin](https://github.com/jlowin) in [#4527](https://github.com/PrefectHQ/fastmcp/pull/4527)
+* Make examples/ actually trigger the ty gate by [@jlowin](https://github.com/jlowin) in [#4541](https://github.com/PrefectHQ/fastmcp/pull/4541)
+* Add subject field to AccessToken initialization by [@piaudonn](https://github.com/piaudonn) in [#4267](https://github.com/PrefectHQ/fastmcp/pull/4267)
+* Restore Mintlify's fixed banner positioning by [@jlowin](https://github.com/jlowin) in [#4542](https://github.com/PrefectHQ/fastmcp/pull/4542)
+* Fix #4292: SSRF guard breaks OAuth/JWKS fetches behind a corporate HTTP proxy by [@endofcake](https://github.com/endofcake) in [#4412](https://github.com/PrefectHQ/fastmcp/pull/4412)
+* Preserve telemetry attributes when a sampler does not forward them by [@jlowin](https://github.com/jlowin) in [#4539](https://github.com/PrefectHQ/fastmcp/pull/4539)
+* Speed up the unit test suite, and fix the task-notification race it surfaced by [@jlowin](https://github.com/jlowin) in [#4550](https://github.com/PrefectHQ/fastmcp/pull/4550)
+* Fix label triage applying no labels, and make blocked tool calls fail by [@jlowin](https://github.com/jlowin) in [#4555](https://github.com/PrefectHQ/fastmcp/pull/4555)
+* Fix AI workflow allowlists being destroyed by tokenization by [@jlowin](https://github.com/jlowin) in [#4560](https://github.com/PrefectHQ/fastmcp/pull/4560)
+* Make transformed tool `required` order deterministic by [@Kludex](https://github.com/Kludex) in [#4564](https://github.com/PrefectHQ/fastmcp/pull/4564)
+* Stop gather() from creating coroutines it may never schedule by [@jlowin](https://github.com/jlowin) in [#4559](https://github.com/PrefectHQ/fastmcp/pull/4559)
+* Restore upgraded dependency checks by [@zzstoatzz](https://github.com/zzstoatzz) in [#4576](https://github.com/PrefectHQ/fastmcp/pull/4576)
+* Fix skill frontmatter parsing with UTF-8 BOM by [@hxaxd](https://github.com/hxaxd) in [#4533](https://github.com/PrefectHQ/fastmcp/pull/4533)
+* Fix File helper extension handling by [@VectorPeak](https://github.com/VectorPeak) in [#4531](https://github.com/PrefectHQ/fastmcp/pull/4531)
+* Fix percent-encoded skill file names unreadable in resources mode by [@jlowin](https://github.com/jlowin) in [#4590](https://github.com/PrefectHQ/fastmcp/pull/4590)
+* Fix flaky stdio crash-recovery tests by [@jlowin](https://github.com/jlowin) in [#4594](https://github.com/PrefectHQ/fastmcp/pull/4594)
+* Bridge camelCase ToolAnnotations reads by [@zzstoatzz](https://github.com/zzstoatzz) in [#4597](https://github.com/PrefectHQ/fastmcp/pull/4597)
+* Preserve raw CallToolResult tool returns by [@LarryHu0217](https://github.com/LarryHu0217) in [#4587](https://github.com/PrefectHQ/fastmcp/pull/4587)
+* Advertise only supported token endpoint auth methods in OAuthProxy metadata by [@jlowin](https://github.com/jlowin) in [#4608](https://github.com/PrefectHQ/fastmcp/pull/4608)
+* Fix OAuth proxy override typing by [@zzstoatzz](https://github.com/zzstoatzz) in [#4612](https://github.com/PrefectHQ/fastmcp/pull/4612)
+* Pin burner-redis below the Windows-crashing 0.1.7 release by [@jlowin](https://github.com/jlowin) in [#4618](https://github.com/PrefectHQ/fastmcp/pull/4618)
+* fix : canonical mime type mapping from formats to remove inconsistency #4627 by [@Aman071106](https://github.com/Aman071106) in [#4628](https://github.com/PrefectHQ/fastmcp/pull/4628)
+* fix: accept callable roots handlers by [@ShuyingZhang](https://github.com/ShuyingZhang) in [#4639](https://github.com/PrefectHQ/fastmcp/pull/4639)
+* Pass the MCP conformance suite's draft and pending scenarios by [@jlowin](https://github.com/jlowin) in [#4650](https://github.com/PrefectHQ/fastmcp/pull/4650)
+* Use issuer_url for OAuth issuer identity by [@jlowin](https://github.com/jlowin) in [#4652](https://github.com/PrefectHQ/fastmcp/pull/4652)
+* Fix the ty failure blocking upgrade checks on main by [@jlowin](https://github.com/jlowin) in [#4657](https://github.com/PrefectHQ/fastmcp/pull/4657)
+* Bind CIMD assertion audience to the advertised token endpoint by [@jlowin](https://github.com/jlowin) in [#4659](https://github.com/PrefectHQ/fastmcp/pull/4659)
+* Record effective scopes on the OAuth transaction by [@jlowin](https://github.com/jlowin) in [#4670](https://github.com/PrefectHQ/fastmcp/pull/4670)
+* Copy schemas iteratively so deep nesting still compresses by [@jlowin](https://github.com/jlowin) in [#4671](https://github.com/PrefectHQ/fastmcp/pull/4671)
+* Fix OpenAPI allOf reference fields by [@hxaxd](https://github.com/hxaxd) in [#4653](https://github.com/PrefectHQ/fastmcp/pull/4653)
+* Flatten OpenAPI discriminator subtypes into request bodies by [@jlowin](https://github.com/jlowin) in [#4677](https://github.com/PrefectHQ/fastmcp/pull/4677)
+* Let maintenance releases publish without fastmcp-tasks by [@jlowin](https://github.com/jlowin) in [#4676](https://github.com/PrefectHQ/fastmcp/pull/4676)
+* Read CLI-scanned MCP config files as UTF-8 explicitly by [@jlowin](https://github.com/jlowin) in [#4690](https://github.com/PrefectHQ/fastmcp/pull/4690)
+* Late-bind app tool names so UIs survive composition by [@jlowin](https://github.com/jlowin) in [#4682](https://github.com/PrefectHQ/fastmcp/pull/4682)
+### Docs ๐
+* Docs: forward-port v3.4.4 changelog entries by [@jlowin](https://github.com/jlowin) in [#4476](https://github.com/PrefectHQ/fastmcp/pull/4476)
+* Document icon theme support by [@jlowin](https://github.com/jlowin) in [#4537](https://github.com/PrefectHQ/fastmcp/pull/4537)
+* Add missing 4.0.0 version badge to Path Security docs by [@jlowin](https://github.com/jlowin) in [#4540](https://github.com/PrefectHQ/fastmcp/pull/4540)
+* Align server component docs by [@strawgate](https://github.com/strawgate) in [#4260](https://github.com/PrefectHQ/fastmcp/pull/4260)
+* Align CLI, deployment, and config docs by [@strawgate](https://github.com/strawgate) in [#4259](https://github.com/PrefectHQ/fastmcp/pull/4259)
+* Align client, Apps, and integration docs by [@strawgate](https://github.com/strawgate) in [#4261](https://github.com/PrefectHQ/fastmcp/pull/4261)
+* Fix stale MRTR/elicitation framing in client and upgrade docs by [@jlowin](https://github.com/jlowin) in [#4551](https://github.com/PrefectHQ/fastmcp/pull/4551)
+* docs: quote pip extras install examples by [@RachGranville](https://github.com/RachGranville) in [#4568](https://github.com/PrefectHQ/fastmcp/pull/4568)
+* Document Windows CI parallelism and the subprocess_heavy marker by [@jlowin](https://github.com/jlowin) in [#4575](https://github.com/PrefectHQ/fastmcp/pull/4575)
+* Document v3->v4 removals and add upgrade-reality tests by [@jlowin](https://github.com/jlowin) in [#4585](https://github.com/PrefectHQ/fastmcp/pull/4585)
+* Archive v3 docs and publish v4 as the primary version by [@jlowin](https://github.com/jlowin) in [#4613](https://github.com/PrefectHQ/fastmcp/pull/4613)
+* Document targeted v4 prerelease installation by [@zzstoatzz](https://github.com/zzstoatzz) in [#4598](https://github.com/PrefectHQ/fastmcp/pull/4598)
+* Fix stale Mac/Windows-vs-Linux OAuth key/storage docs by [@jlowin](https://github.com/jlowin) in [#4617](https://github.com/PrefectHQ/fastmcp/pull/4617)
+* v4 docs quality pass: stale task/era claims, broken links, polish by [@jlowin](https://github.com/jlowin) in [#4619](https://github.com/PrefectHQ/fastmcp/pull/4619)
+* whats-new: add the argument completion capability by [@jlowin](https://github.com/jlowin) in [#4620](https://github.com/PrefectHQ/fastmcp/pull/4620)
+* docs: fix ProxyProvider docstring example calling nonexistent with_namespace() by [@andrew-stelmach-fleet](https://github.com/andrew-stelmach-fleet) in [#4633](https://github.com/PrefectHQ/fastmcp/pull/4633)
+* Unpublish v4 development notes; prep docs for beta 1 by [@jlowin](https://github.com/jlowin) in [#4644](https://github.com/PrefectHQ/fastmcp/pull/4644)
+* Expand the FAQ for the v4 transition by [@jlowin](https://github.com/jlowin) in [#4649](https://github.com/PrefectHQ/fastmcp/pull/4649)
+* Document the issuer_url identity change for upgraders by [@jlowin](https://github.com/jlowin) in [#4658](https://github.com/PrefectHQ/fastmcp/pull/4658)
+* Cover require_roles in the v4 highlights by [@jlowin](https://github.com/jlowin) in [#4666](https://github.com/PrefectHQ/fastmcp/pull/4666)
+* Fix FAQ: sampling/roots/elicitation legacy-mode advice, SessionProvider registration by [@jlowin](https://github.com/jlowin) in [#4672](https://github.com/PrefectHQ/fastmcp/pull/4672)
+* Audit v4 docs: fix missing version badges, fill whats-new gaps by [@jlowin](https://github.com/jlowin) in [#4668](https://github.com/PrefectHQ/fastmcp/pull/4668)
+* Docs: add v3.4.5 changelog entries to main by [@jlowin](https://github.com/jlowin) in [#4674](https://github.com/PrefectHQ/fastmcp/pull/4674)
+* Split the SDK upgrade guides by SDK version by [@jlowin](https://github.com/jlowin) in [#4684](https://github.com/PrefectHQ/fastmcp/pull/4684)
+### Dependencies ๐ฆ
+* chore(deps): bump mcp from 1.26.0 to 1.27.2 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4514](https://github.com/PrefectHQ/fastmcp/pull/4514)
+* chore(deps): bump actions/setup-node from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4546](https://github.com/PrefectHQ/fastmcp/pull/4546)
+* Bump actions/upload-artifact from 4 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4640](https://github.com/PrefectHQ/fastmcp/pull/4640)
+* Bump actions/setup-python from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4641](https://github.com/PrefectHQ/fastmcp/pull/4641)
+* chore(deps): bump mcp from 1.27.2 to 1.28.1 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4614](https://github.com/PrefectHQ/fastmcp/pull/4614)
+### Other Changes ๐ฆพ
+* Test: HTTP lifespan fires once per process across sessions by [@jlowin](https://github.com/jlowin) in [#4470](https://github.com/PrefectHQ/fastmcp/pull/4470)
+## New Contributors
+* @syf2211 made their first contribution in [#4502](https://github.com/PrefectHQ/fastmcp/pull/4502)
+* @earfman made their first contribution in [#4517](https://github.com/PrefectHQ/fastmcp/pull/4517)
+* @winklemad made their first contribution in [#4492](https://github.com/PrefectHQ/fastmcp/pull/4492)
+* @verdie-g made their first contribution in [#4512](https://github.com/PrefectHQ/fastmcp/pull/4512)
+* @vijaydeepsinha made their first contribution in [#4411](https://github.com/PrefectHQ/fastmcp/pull/4411)
+* @piaudonn made their first contribution in [#4267](https://github.com/PrefectHQ/fastmcp/pull/4267)
+* @szepeviktor made their first contribution in [#4498](https://github.com/PrefectHQ/fastmcp/pull/4498)
+* @endofcake made their first contribution in [#4412](https://github.com/PrefectHQ/fastmcp/pull/4412)
+* @Kludex made their first contribution in [#4564](https://github.com/PrefectHQ/fastmcp/pull/4564)
+* @RachGranville made their first contribution in [#4568](https://github.com/PrefectHQ/fastmcp/pull/4568)
+* @hxaxd made their first contribution in [#4533](https://github.com/PrefectHQ/fastmcp/pull/4533)
+* @VectorPeak made their first contribution in [#4531](https://github.com/PrefectHQ/fastmcp/pull/4531)
+* @LarryHu0217 made their first contribution in [#4587](https://github.com/PrefectHQ/fastmcp/pull/4587)
+* @andrew-stelmach-fleet made their first contribution in [#4633](https://github.com/PrefectHQ/fastmcp/pull/4633)
+* @Aman071106 made their first contribution in [#4628](https://github.com/PrefectHQ/fastmcp/pull/4628)
+* @ShuyingZhang made their first contribution in [#4639](https://github.com/PrefectHQ/fastmcp/pull/4639)
+* @Educg550 made their first contribution in [#4660](https://github.com/PrefectHQ/fastmcp/pull/4660)
+
+**Full Changelog**: [v3.4.5...v4.0.0b1](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v4.0.0b1)
+
+
+
**[v3.4.5: Key Change](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.5)**
diff --git a/docs/docs.json b/docs/docs.json
index 7b560be71..417c30459 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -16,7 +16,7 @@
"dark": "#475569",
"light": "#1e3a5f"
},
- "content": "FastMCP 4 is in beta โ you're reading the v4 docs. [What's new](/getting-started/whats-new) ยท [FastMCP 3 docs](/v3/getting-started/welcome)"
+ "content": "FastMCP 4 is in beta โ check out [what's new](/getting-started/whats-new)!"
},
"colors": {
"dark": "#f72585",
diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx
index 859c1d911..37ff33f62 100644
--- a/docs/getting-started/upgrading/from-fastmcp-3.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx
@@ -5,7 +5,7 @@ description: What changes when you upgrade to FastMCP 4, which builds on the MCP
icon: up
---
-FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it moves the protocol types into a standalone `mcp_types` package (still importable as `mcp.types`), and it renames every protocol field from camelCase to snake_case (`inputSchema` โ `input_schema`, `mimeType` โ `mime_type`, `isError` โ `is_error`, and so on).
+FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it moves the protocol types into a standalone `mcp_types` package (still importable as `mcp.types`), and it renames every model field from camelCase to snake_case in Python (`inputSchema` โ `input_schema`, `mimeType` โ `mime_type`, `isError` โ `is_error`, and so on). The wire format does not change: the models keep their camelCase aliases and serialize under them, so this renames the attributes your code reads, not the JSON on the connection.
FastMCP 4 absorbs almost all of this for you. Field access is bridged so your existing reads keep working, and the imports you were taught have a stable home in FastMCP itself. What the SDK cannot hide is the protocol's own direction: the new sessionless era removes the server's ability to call back into a client mid-request, and background tasks moved out of the core spec into an extension. Those two shape the changes a working server is most likely to feel.
diff --git a/docs/updates.mdx b/docs/updates.mdx
index 47c0c8364..bb66069e9 100644
--- a/docs/updates.mdx
+++ b/docs/updates.mdx
@@ -5,6 +5,28 @@ icon: "sparkles"
tag: NEW
---
+
+
+FastMCP 4 rebuilds the framework on the MCP Python SDK v2, and this beta is the first release to run on the SDK's stable 2.0. The engine underneath changed completely, but FastMCP absorbs nearly all of it โ most FastMCP 3 servers run untouched.
+
+๐ **Every protocol era** โ one server answers both the sessionless `2026-07-28` protocol and the older session-based handshake, negotiated per connection.
+
+๐พ **State without a session** โ `UserSession` and `SessionId` give tools durable state on a protocol that deliberately has none, keyed per user when the request is authenticated.
+
+โณ **Background tasks** โ the `io.modelcontextprotocol/tasks` extension in the new `fastmcp-tasks` package, on the same Docket engine FastMCP 3 used.
+
+๐งฉ **Server extensions** โ `add_extension()` turns capability-negotiated protocol features into a supported plugin surface.
+
+๐ **Enterprise auth** โ server-side identity assertion (SEP-990), `require_roles`, scope step-up challenges, and DCR `application_type`.
+
+โ ๏ธ **Breaking** โ server-initiated sampling and roots are removed from the server API, and the 3.x-era compatibility shims are gone. See the [upgrade guide](/getting-started/upgrading/from-fastmcp-3).
+
+
+
Date: Tue, 28 Jul 2026 17:30:20 -0400
Subject: [PATCH 02/32] Always emit a tool title, derived from name when unset
(#4694)
* Always emit a tool title, derived from name when unset
Some MCP clients (e.g. ChatGPT) drop tools with no `title` instead of
falling back to `name` for display as the spec allows. Deriving a
default title in Tool.to_mcp_tool() fixes this for every tool built on
top of it, including the search-transform, code-mode, and session
proxy tools that never set one explicitly.
Fixes #4414
* Derive fallback title from the overridden name, document it
Addresses Codex review on #4694.
* Resolve title precedence from effective overrides
* Normalize mapping annotations before deriving the title
---
docs/servers/tools.mdx | 2 +-
fastmcp_slim/fastmcp/tools/base.py | 27 +++++++++++++---
tests/server/transforms/test_search.py | 22 +++++++++++++
tests/tools/tool/test_title.py | 44 +++++++++++++++++++++++---
4 files changed, 84 insertions(+), 11 deletions(-)
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index 527b16b9b..f7fe31ec0 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -74,7 +74,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l
- A human-readable display title for the tool. If omitted, FastMCP falls back to `annotations.title` when present.
+ A human-readable display title for the tool. If omitted, FastMCP falls back to `annotations.title` when present, then to a title derived from the tool's name (e.g. `find_products` becomes "Find Products") โ some MCP clients drop tools that have no title at all.
diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py
index ad21f1253..5e02246dd 100644
--- a/fastmcp_slim/fastmcp/tools/base.py
+++ b/fastmcp_slim/fastmcp/tools/base.py
@@ -52,6 +52,16 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
+def _default_title(name: str) -> str:
+ """Derive a display title from a tool name.
+
+ The MCP spec says clients should fall back to `name` for display when
+ `title` is absent, but some clients (e.g. ChatGPT) instead drop the tool
+ entirely. Always emitting a title avoids depending on that fallback.
+ """
+ return name.replace("_", " ").replace("-", " ").title()
+
+
def resolve_serialize_by_alias(value: Any) -> bool:
"""Resolve the effective ``by_alias`` setting for serializing *value*.
@@ -263,21 +273,28 @@ class Tool(FastMCPComponent):
**overrides: Any,
) -> MCPTool:
"""Convert the FastMCP tool to an MCP tool."""
- title = None
+ # Title precedence follows the effective (post-override) values, so a
+ # caller renaming or re-annotating a tool doesn't get a stale title.
+ name = overrides.get("name", self.name)
+ annotations = overrides.get("annotations", self.annotations)
+ if isinstance(annotations, dict):
+ annotations = ToolAnnotations(**annotations)
if self.title:
title = self.title
- elif self.annotations and self.annotations.title:
- title = self.annotations.title
+ elif annotations and annotations.title:
+ title = annotations.title
+ else:
+ title = _default_title(name)
mcp_tool = MCPTool(
- name=overrides.get("name", self.name),
+ name=name,
title=overrides.get("title", title),
description=overrides.get("description", self.description),
input_schema=overrides.get("inputSchema", self.parameters),
output_schema=overrides.get("outputSchema", self.output_schema),
icons=overrides.get("icons", self.icons),
- annotations=overrides.get("annotations", self.annotations),
+ annotations=annotations,
execution=overrides.get("execution", self.execution),
_meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field
"_meta", self.get_meta()
diff --git a/tests/server/transforms/test_search.py b/tests/server/transforms/test_search.py
index 9c508f893..810a05bdd 100644
--- a/tests/server/transforms/test_search.py
+++ b/tests/server/transforms/test_search.py
@@ -137,6 +137,28 @@ class TestBaseTransformBehavior:
assert await mcp.get_tool("find_tools") is not None
assert await mcp.get_tool("run_tool") is not None
+ @pytest.mark.parametrize(
+ "transform_cls", [RegexSearchTransform, BM25SearchTransform]
+ )
+ async def test_synthetic_tools_have_titles(self, transform_cls):
+ """Synthetic search/call tools must carry a title.
+
+ Some MCP clients (e.g. ChatGPT) drop tools with no `title` field,
+ which breaks tool-search discovery entirely. See #4414.
+ """
+ mcp = _make_server_with_tools()
+ mcp.add_transform(
+ transform_cls(
+ search_tool_name="find_tools", call_tool_name="call_read_tool"
+ )
+ )
+ tools = await mcp.list_tools()
+ titles = {t.name: t.to_mcp_tool().title for t in tools}
+ assert titles == {
+ "find_tools": "Find Tools",
+ "call_read_tool": "Call Read Tool",
+ }
+
async def test_search_respects_visibility_filtering(self):
"""Tools disabled via Visibility transform should not appear in search."""
mcp = _make_server_with_tools()
diff --git a/tests/tools/tool/test_title.py b/tests/tools/tool/test_title.py
index 29c0c1a73..fde193f70 100644
--- a/tests/tools/tool/test_title.py
+++ b/tests/tools/tool/test_title.py
@@ -1,3 +1,6 @@
+import pytest
+from mcp_types import ToolAnnotations
+
from fastmcp.tools.base import Tool
@@ -30,7 +33,13 @@ class TestToolTitle:
)
def test_tool_without_title(self):
- """Test that tools without titles use name as display name."""
+ """Test that tools without an explicit title derive one from the name.
+
+ Some MCP clients (e.g. ChatGPT) drop tools with no `title` rather
+ than falling back to `name` as the spec allows, so FastMCP always
+ emits a derived title on the wire instead of relying on that
+ fallback.
+ """
def multiply(a: int, b: int) -> int:
return a * b
@@ -40,14 +49,40 @@ class TestToolTitle:
assert tool.name == "multiply"
assert tool.title is None
- # Test MCP conversion doesn't include title when None
mcp_tool = tool.to_mcp_tool()
assert mcp_tool.name == "multiply"
- assert not hasattr(mcp_tool, "title") or mcp_tool.title is None
+ assert mcp_tool.title == "Multiply"
+
+ def test_derived_title_follows_name_override(self):
+ """The derived title should reflect a `name` override, not the original name."""
+
+ def multiply(a: int, b: int) -> int:
+ return a * b
+
+ tool = Tool.from_function(multiply, name="multiply_tool")
+
+ mcp_tool = tool.to_mcp_tool(name="renamed_tool")
+ assert mcp_tool.name == "renamed_tool"
+ assert mcp_tool.title == "Renamed Tool"
+
+ @pytest.mark.parametrize(
+ "annotations",
+ [ToolAnnotations(title="Custom"), {"title": "Custom"}],
+ ids=["object", "dict"],
+ )
+ def test_annotations_override_beats_derived_title(self, annotations):
+ """An `annotations` override still outranks the name-derived title."""
+
+ def multiply(a: int, b: int) -> int:
+ return a * b
+
+ tool = Tool.from_function(multiply)
+
+ mcp_tool = tool.to_mcp_tool(annotations=annotations)
+ assert mcp_tool.title == "Custom"
def test_tool_title_priority(self):
"""Test that explicit title takes priority over annotations.title."""
- from mcp_types import ToolAnnotations
def divide(x: int, y: int) -> float:
"""Divide two numbers."""
@@ -72,7 +107,6 @@ class TestToolTitle:
def test_tool_annotations_title_fallback(self):
"""Test that annotations.title is used when no explicit title is provided."""
- from mcp_types import ToolAnnotations
def modulo(x: int, y: int) -> int:
"""Get modulo of two numbers."""
From 7a77805159a7833520c00236a243c54023e5e0e9 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Tue, 28 Jul 2026 19:08:44 -0400
Subject: [PATCH 03/32] Use mcp.types directly instead of aliasing to mcp_types
in docs (#4696)
---
docs/clients/notifications.mdx | 30 +++++++++++++++---------------
docs/servers/context.mdx | 14 +++++++++-----
docs/servers/tasks.mdx | 17 +++++++++++------
3 files changed, 35 insertions(+), 26 deletions(-)
diff --git a/docs/clients/notifications.mdx b/docs/clients/notifications.mdx
index 1864dd23b..b771c903e 100644
--- a/docs/clients/notifications.mdx
+++ b/docs/clients/notifications.mdx
@@ -47,23 +47,23 @@ For fine-grained targeting, subclass `MessageHandler` to use specific hooks:
```python
from fastmcp import Client
from fastmcp.client.messages import MessageHandler
-import mcp.types as mcp_types
+import mcp.types
class MyMessageHandler(MessageHandler):
async def on_tool_list_changed(
- self, notification: mcp_types.ToolListChangedNotification
+ self, notification: mcp.types.ToolListChangedNotification
) -> None:
"""Handle tool list changes."""
print("Tool list changed - refreshing available tools")
async def on_resource_list_changed(
- self, notification: mcp_types.ResourceListChangedNotification
+ self, notification: mcp.types.ResourceListChangedNotification
) -> None:
"""Handle resource list changes."""
print("Resource list changed")
async def on_prompt_list_changed(
- self, notification: mcp_types.PromptListChangedNotification
+ self, notification: mcp.types.PromptListChangedNotification
) -> None:
"""Handle prompt list changes."""
print("Prompt list changed")
@@ -78,7 +78,7 @@ client = Client(
```python
from fastmcp.client.messages import MessageHandler
-import mcp.types as mcp_types
+import mcp.types
class MyMessageHandler(MessageHandler):
async def on_message(self, message) -> None:
@@ -86,49 +86,49 @@ class MyMessageHandler(MessageHandler):
pass
async def on_notification(
- self, notification: mcp_types.ServerNotification
+ self, notification: mcp.types.ServerNotification
) -> None:
"""Called for notifications (fire-and-forget)."""
pass
async def on_tool_list_changed(
- self, notification: mcp_types.ToolListChangedNotification
+ self, notification: mcp.types.ToolListChangedNotification
) -> None:
"""Called when the server's tool list changes."""
pass
async def on_resource_list_changed(
- self, notification: mcp_types.ResourceListChangedNotification
+ self, notification: mcp.types.ResourceListChangedNotification
) -> None:
"""Called when the server's resource list changes."""
pass
async def on_prompt_list_changed(
- self, notification: mcp_types.PromptListChangedNotification
+ self, notification: mcp.types.PromptListChangedNotification
) -> None:
"""Called when the server's prompt list changes."""
pass
async def on_progress(
- self, notification: mcp_types.ProgressNotification
+ self, notification: mcp.types.ProgressNotification
) -> None:
"""Called for progress updates during long-running operations."""
pass
async def on_resource_updated(
- self, notification: mcp_types.ResourceUpdatedNotification
+ self, notification: mcp.types.ResourceUpdatedNotification
) -> None:
"""Called when a specific resource changes."""
pass
async def on_cancelled(
- self, notification: mcp_types.CancelledNotification
+ self, notification: mcp.types.CancelledNotification
) -> None:
"""Called when a request is cancelled."""
pass
async def on_logging_message(
- self, notification: mcp_types.LoggingMessageNotification
+ self, notification: mcp.types.LoggingMessageNotification
) -> None:
"""Called for log messages from the server."""
pass
@@ -141,14 +141,14 @@ A practical example of maintaining a tool cache that refreshes when tools change
```python
from fastmcp import Client
from fastmcp.client.messages import MessageHandler
-import mcp.types as mcp_types
+import mcp.types
class ToolCacheHandler(MessageHandler):
def __init__(self):
self.cached_tools = []
async def on_tool_list_changed(
- self, notification: mcp_types.ToolListChangedNotification
+ self, notification: mcp.types.ToolListChangedNotification
) -> None:
"""Clear tool cache when tools change."""
print("Tools changed - clearing cache")
diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx
index 01a5372d3..667ce9764 100644
--- a/docs/servers/context.mdx
+++ b/docs/servers/context.mdx
@@ -179,7 +179,7 @@ content = resource_result.contents[0].content
```
**Method signatures:**
-- **`ctx.list_resources() -> list[mcp_types.Resource]`**: Returns list of all available resources
+- **`ctx.list_resources() -> list[mcp.types.Resource]`**: Returns list of all available resources
- **`ctx.read_resource(uri: str | AnyUrl) -> ResourceResult`**: Returns a `ResourceResult` whose `.contents` list contains the resource content parts
### Prompt Access
@@ -271,14 +271,18 @@ Tools can customize which components are visible to their current session using
FastMCP automatically sends list change notifications when components (such as tools, resources, or prompts) are added, removed, enabled, or disabled. In rare cases where you need to manually trigger these notifications, you can use the context's notification methods:
```python
-import mcp.types as mcp_types
+from mcp.types import (
+ PromptListChangedNotification,
+ ResourceListChangedNotification,
+ ToolListChangedNotification,
+)
@mcp.tool
async def custom_tool_management(ctx: Context) -> str:
"""Example of manual notification after custom tool changes."""
- await ctx.send_notification(mcp_types.ToolListChangedNotification())
- await ctx.send_notification(mcp_types.ResourceListChangedNotification())
- await ctx.send_notification(mcp_types.PromptListChangedNotification())
+ await ctx.send_notification(ToolListChangedNotification())
+ await ctx.send_notification(ResourceListChangedNotification())
+ await ctx.send_notification(PromptListChangedNotification())
return "Notifications sent"
```
diff --git a/docs/servers/tasks.mdx b/docs/servers/tasks.mdx
index 2b07bd440..b22a7b91d 100644
--- a/docs/servers/tasks.mdx
+++ b/docs/servers/tasks.mdx
@@ -225,30 +225,35 @@ A tool can ask the client a question partway through โ the same [guard pattern
```python
from fastmcp import Context, FastMCP
from fastmcp_tasks import TasksExtension
-import mcp.types as mcp_types
+from mcp.types import (
+ ElicitRequest,
+ ElicitRequestFormParams,
+ ElicitResult,
+ InputRequiredResult,
+)
mcp = FastMCP("MyServer")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
-async def plan_dinner(ctx: Context) -> str | mcp_types.InputRequiredResult:
+async def plan_dinner(ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses is None:
# First leg: ask a question and end here.
- request = mcp_types.ElicitRequest(
- params=mcp_types.ElicitRequestFormParams(
+ request = ElicitRequest(
+ params=ElicitRequestFormParams(
message="What are you in the mood for?",
requested_schema={"type": "object", "properties": {"cuisine": {"type": "string"}}},
)
)
- return mcp_types.InputRequiredResult(
+ return InputRequiredResult(
result_type="input_required",
input_requests={"prefs": request},
)
# Re-entered leg: the client's answer is on ctx.input_responses.
answer = responses["prefs"]
- assert isinstance(answer, mcp_types.ElicitResult)
+ assert isinstance(answer, ElicitResult)
return f"Tonight: {answer.content['cuisine']}!"
```
From 73399369806fc19a328376b0ded181c319c31591 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Tue, 28 Jul 2026 20:05:45 -0400
Subject: [PATCH 04/32] Rewrite the v4 What's New page and document server
extensions (#4698)
* Rewrite the v4 What's New page
Teach the headline features with code instead of asserting them, drop the
major-version throat-clearing and SEP list, and correct the elicitation
claim: ctx.elicit() is unchanged and handshake-only, while sampling and
roots are removed outright.
* Fix broken doc links and stale version references
Repoint five dead links and anchors, refresh v3-era version examples on the
v4 docs, and add the missing FastMCP 3 entry to the installation page's
upgrade section.
* Document server extensions
add_extension() shipped in v4 with no documentation page. Covers the
extension interface, request methods, tool-call interception, lifespan
ownership, and the client half.
* Link the FastMCP TypeScript library
* Address Codex review feedback
Gate the extension interceptor on the client's per-request opt-in rather
than claiming negotiation does it; show the v4 beta pin on the install
page instead of a version a reader cannot get; note that UserSession
requires authentication.
---
README.md | 3 +
docs/deployment/http.mdx | 4 +-
docs/development/releases.mdx | 4 +-
docs/docs.json | 1 +
docs/getting-started/installation.mdx | 32 ++--
docs/getting-started/quickstart.mdx | 7 +-
.../upgrading/from-fastmcp-2.mdx | 2 +-
docs/getting-started/welcome.mdx | 2 +
docs/getting-started/whats-new.mdx | 126 +++++++++----
docs/more/faq.mdx | 2 +-
docs/servers/extensions.mdx | 166 ++++++++++++++++++
docs/servers/providers/overview.mdx | 10 +-
docs/servers/storage-backends.mdx | 4 +-
docs/servers/tools.mdx | 2 +-
docs/servers/transforms/code-mode.mdx | 6 +-
15 files changed, 301 insertions(+), 70 deletions(-)
create mode 100644 docs/servers/extensions.mdx
diff --git a/README.md b/README.md
index e29ce7a9b..5d312c0b3 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@
[](https://gofastmcp.com)
[](https://discord.gg/uu8dJCgttd)
[](https://pypi.org/project/fastmcp)
+[](https://github.com/PrefectHQ/fastmcp-ts)
[](https://github.com/PrefectHQ/fastmcp/actions/workflows/run-tests.yml)
[](https://github.com/PrefectHQ/fastmcp/blob/main/LICENSE)
@@ -77,6 +78,8 @@ FastMCP has three pillars:
**[Servers](https://gofastmcp.com/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](https://gofastmcp.com/clients/client)** connect to any server with full protocol support. And **[Apps](https://gofastmcp.com/apps/overview)** give your tools interactive UIs rendered directly in the conversation.
+**Building in TypeScript?** [FastMCP for TypeScript](https://github.com/PrefectHQ/fastmcp-ts) is the official counterpart, built and maintained by the same team. Same pillars, same ideas, `npm install @prefecthq/fastmcp-ts`.
+
Ready to build? Start with the [installation guide](https://gofastmcp.com/getting-started/installation) or jump straight to the [quickstart](https://gofastmcp.com/getting-started/quickstart).
## Run FastMCP in production with Horizon
diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx
index 29d104d4e..f057efe4c 100644
--- a/docs/deployment/http.mdx
+++ b/docs/deployment/http.mdx
@@ -103,7 +103,7 @@ If you're mounting an authenticated server under a path prefix, see [Mounting Au
### Host and Origin Protection
-FastMCP can validate `Host` and browser `Origin` headers for Streamable HTTP requests before they reach MCP session handling. This request guard protects localhost-bound servers from DNS rebinding attacks, and it remains opt-in in FastMCP 3.x to preserve compatibility with existing ASGI, serverless, and reverse-proxy deployments.
+FastMCP can validate `Host` and browser `Origin` headers for Streamable HTTP requests before they reach MCP session handling. This request guard protects localhost-bound servers from DNS rebinding attacks, and it stays opt-in to preserve compatibility with existing ASGI, serverless, and reverse-proxy deployments.
Think of this as a request guard rather than CORS middleware. It decides whether a request can reach MCP session handling. CORS remains a separate browser response-header policy; configure CORS middleware separately when browser JavaScript must read cross-origin responses.
@@ -188,7 +188,7 @@ def query_tenant(
A gateway can now route on `Mcp-Param-Tenant` โ for example, pinning each tenant to a dedicated backend โ without inspecting the request body. The annotation is only permitted on `string`, `integer`, and `boolean` parameters. These headers advertise routing intent; treat them as untrusted hints, since the server still validates the request body as the source of truth.
-When you put a FastMCP [proxy](/servers/proxy) in front of another server, the proxy re-advertises each backend tool's `x-mcp-header` annotation, so routing headers work across the proxy hop as well. The headers themselves are regenerated per hop rather than forwarded verbatim, since each describes a single HTTP request.
+When you put a FastMCP [proxy](/servers/providers/proxy) in front of another server, the proxy re-advertises each backend tool's `x-mcp-header` annotation, so routing headers work across the proxy hop as well. The headers themselves are regenerated per hop rather than forwarded verbatim, since each describes a single HTTP request.
### Health Checks
diff --git a/docs/development/releases.mdx b/docs/development/releases.mdx
index 331fd810c..ecba9a25f 100644
--- a/docs/development/releases.mdx
+++ b/docs/development/releases.mdx
@@ -53,8 +53,8 @@ We expect this exemption to last through at least the 2.12.x and 2.13.x release
Pin to exact versions:
```
-fastmcp==2.11.0 # Good
-fastmcp>=2.11.0 # Bad - will install breaking changes
+fastmcp==4.0.0 # Good
+fastmcp>=4.0.0 # Bad - will install breaking changes
```
## Creating Releases
diff --git a/docs/docs.json b/docs/docs.json
index 417c30459..22bc3c4b9 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -162,6 +162,7 @@
"servers/lifespan",
"servers/storage-backends",
"servers/sessions",
+ "servers/extensions",
"servers/tasks",
"servers/versioning"
]
diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx
index 9bfbdbcb1..c3c1bdacd 100644
--- a/docs/getting-started/installation.mdx
+++ b/docs/getting-started/installation.mdx
@@ -7,15 +7,19 @@ icon: arrow-down-to-line
We recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) to install and manage FastMCP.
+```bash
+uv add fastmcp
+```
+
+Or with pip:
+
```bash
pip install fastmcp
```
-Or with uv:
-
-```bash
-uv add fastmcp
-```
+
+**FastMCP 4 is in prerelease.** The commands above install the latest stable release, which is still 3.x. To get v4, pin the beta explicitly with `pip install "fastmcp==4.0.0b1"`, or see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for the uv constraint you'll need.
+
### Optional Dependencies
@@ -40,8 +44,8 @@ You should see output like the following:
```bash
$ fastmcp version
-FastMCP version: 3.0.0
-MCP version: 1.25.0
+FastMCP version: 4.0.0b1
+MCP version: 2.0.0
Python version: 3.12.2
Platform: macOS-15.3.1-arm64-arm-64bit
FastMCP root path: ~/Developer/fastmcp
@@ -62,6 +66,10 @@ Alternatively, wait for the stable v5 release. See [this issue](https://github.c
## Upgrading
+### From FastMCP 3.0
+
+Most FastMCP 3 servers run on 4 without changes. See [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) for the breaks that do exist, and [What's New](/getting-started/whats-new) for what the new version adds.
+
### From FastMCP 2.0
See the [Upgrade Guide](/getting-started/upgrading/from-fastmcp-2) for a complete list of breaking changes and migration steps.
@@ -107,16 +115,12 @@ FastMCP follows semantic versioning with pragmatic adaptations for the rapidly e
For production use, always pin to exact versions:
```
-fastmcp==3.0.0 # Good
-fastmcp>=3.0.0 # Bad - may install breaking changes
+fastmcp==4.0.0 # Good
+fastmcp>=4.0.0 # Bad - may install breaking changes
```
See the full [versioning and release policy](/development/releases#versioning-policy) for details on our public API, deprecation practices, and breaking change philosophy.
## Contributing to FastMCP
-Interested in contributing to FastMCP? See the [Contributing Guide](/development/contributing) for details on:
-- Setting up your development environment
-- Running tests and pre-commit hooks
-- Submitting issues and pull requests
-- Code standards and review process
+The [Contributing Guide](/development/contributing) covers setting up a development environment, running the test suite and pre-commit hooks, and the standards we hold contributed code to.
diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx
index 97d9f3c79..ae157d906 100644
--- a/docs/getting-started/quickstart.mdx
+++ b/docs/getting-started/quickstart.mdx
@@ -3,7 +3,7 @@ title: Quickstart
icon: rocket-launch
---
-Welcome! This guide will help you quickly set up FastMCP, run your first MCP server, give it a visual UI, and deploy it to Prefect Horizon.
+This guide builds a working MCP server from scratch: a tool, a way to run it, a client that calls it, and a visual UI for the result. It ends with the server deployed and reachable over the internet.
If you haven't already installed FastMCP, follow the [installation instructions](/getting-started/installation).
@@ -112,10 +112,7 @@ async def call_tool(name: str):
asyncio.run(call_tool("Ford"))
```
-Note that:
-- FastMCP clients are asynchronous, so we need to use `asyncio.run` to run the client
-- We must enter a client context (`async with client:`) before using the client
-- You can make multiple client calls within the same context
+FastMCP clients are asynchronous, so the call goes through `asyncio.run`. Entering the client context with `async with client:` is what opens the connection, and it stays open for as many calls as you want to make inside the block.
## Give Your Tool a UI
diff --git a/docs/getting-started/upgrading/from-fastmcp-2.mdx b/docs/getting-started/upgrading/from-fastmcp-2.mdx
index 98f0b4883..c371f09f6 100644
--- a/docs/getting-started/upgrading/from-fastmcp-2.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-2.mdx
@@ -25,7 +25,7 @@ pip install --upgrade fastmcp
uv add --upgrade fastmcp
```
-If you pin versions in a requirements file or `pyproject.toml`, update your pin to `fastmcp>=3.0.0,<4`.
+If you pin versions in a requirements file or `pyproject.toml`, update your pin to `fastmcp>=3.0.0,<4`. Going on to FastMCP 4 is a second hop: finish this page, then work through [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) and move the pin to `fastmcp>=4.0.0` at the end of it.
**New repository home.** As part of the v3 release, FastMCP's GitHub repository has moved from `jlowin/fastmcp` to [`PrefectHQ/fastmcp`](https://github.com/PrefectHQ/fastmcp) under [Prefect](https://prefect.io)'s stewardship. GitHub automatically redirects existing clones and bookmarks, so nothing breaks โ but you can update your local remote whenever convenient:
diff --git a/docs/getting-started/welcome.mdx b/docs/getting-started/welcome.mdx
index d42dc39b7..1e3fe2a9d 100644
--- a/docs/getting-started/welcome.mdx
+++ b/docs/getting-started/welcome.mdx
@@ -80,6 +80,8 @@ FastMCP has three pillars:
**[Servers](/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](/clients/client)** connect to any server with full protocol support. And **[Apps](/apps/overview)** give your tools interactive UIs rendered directly in the conversation.
+**Building in TypeScript?** [FastMCP for TypeScript](https://github.com/PrefectHQ/fastmcp-ts) is the official counterpart, built and maintained by the same team. The three pillars work the same way there, so what you learn here carries over.
+
Ready to build? Start with the [installation guide](/getting-started/installation) or jump straight to the [quickstart](/getting-started/quickstart).
FastMCP is made with ๐ by [Prefect](https://www.prefect.io/).
diff --git a/docs/getting-started/whats-new.mdx b/docs/getting-started/whats-new.mdx
index b2dfffb8d..230bb5844 100644
--- a/docs/getting-started/whats-new.mdx
+++ b/docs/getting-started/whats-new.mdx
@@ -1,51 +1,114 @@
---
title: "What's New in FastMCP 4"
sidebarTitle: "What's New"
-description: The capabilities that define FastMCP 4 โ a rebuilt engine, a new protocol era, and a stateless protocol made practical.
+description: A sessionless MCP protocol, the state layer that replaces sessions, and enterprise identity.
icon: sparkles
---
-FastMCP 4 is a major version because its engine changed. The framework is now built on the MCP Python SDK v2, a ground-up rebuild of the protocol layer, and on that foundation it adds a new protocol era, first-class extensions, stateless state, enterprise identity, and more. Most FastMCP 3 servers run on it untouched โ the major version signals how much moved underneath, and what that movement unlocks.
+FastMCP 4 runs on version 2 of the MCP Python SDK, which rewrote the protocol layer to support MCP's new sessionless protocol, `2026-07-28`. That protocol drives most of this release. It changes how servers deploy, how clients connect, where state lives between calls, and how a running tool asks the user a question.
+
+Most FastMCP 3 servers run on 4 unchanged. Two things need attention: `ctx.sample()` and `ctx.list_roots()` are gone, and code that builds MCP protocol models by hand now uses snake_case field names where the SDK used camelCase. [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) covers every break in detail.
FastMCP 4 is in **beta**. Pin an exact version and expect sharp edges. See [Install the v4 prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
-## Built on the MCP Python SDK v2
-
-The defining change in FastMCP 4 is the one you mostly can't see. The MCP Python SDK v2 rewrote the protocol layer end to end: it moved the protocol types into a standalone `mcp_types` package that stays importable as `mcp.types`, renamed every model field from camelCase to snake_case in Python, replaced the server's request-handling model, and made server-side middleware and multi-era serving first-class. FastMCP absorbs nearly all of it โ your reads stay working through a compatibility bridge, and the handful of changes left in your code are mechanical.
-
-The major version is the signal. Even where your surface is unchanged, the behavior underneath is substantially different, and bumping to 4.0 is how we tell you that plainly rather than slipping a new engine in under a patch release.
-
-The rebuild also pulls the protocol's recent evolution forward in a single step. A batch of accepted MCP proposals arrives with SDK v2, and FastMCP 4 surfaces each one: capability-negotiated extensions (SEP-2133), multi-round-trip elicitation for sessionless connections (SEP-2322), response cache hints (SEP-2549), spec-standard error codes (SEP-2164), the enterprise identity-assertion grant (SEP-990), and the sessionless `2026-07-28` protocol itself, which removes server-initiated requests (SEP-2577). The rest of this page is what those add up to.
-
## Every protocol era
-A FastMCP 4 server answers clients across the protocol transition from one deployment. The MCP SDK negotiates the era per connection โ the sessionless `2026-07-28` protocol for clients that have moved forward, the session-based handshake for everyone else โ and any replica behind a plain load balancer can serve a modern request. This supersedes FastMCP's earlier "latest protocol only" stance: you adopt the new protocol without forking your deployment or gating clients by version.
+A FastMCP 4 server answers clients on both sides of the protocol transition from a single deployment. The SDK negotiates per connection: the sessionless protocol for clients that have moved forward, the session-based handshake for everyone else. You adopt the new protocol without forking your deployment or gating clients by version, which supersedes FastMCP's earlier "latest protocol only" stance.
-The same negotiation runs from the client, and its default flipped. A plain `Client(url)` now probes for the modern protocol and adopts it when the server offers it, falling back to the handshake otherwise โ where every earlier FastMCP version pinned the handshake outright. That flip is what brings the modern capabilities within reach of ordinary client code: a task-enabled tool hands back a handle to poll, and multi-round-trip elicitation resolves across successive requests, neither requiring the caller to opt in. Set `mode="legacy"` to pin the handshake when you need the session-based back-channel or the classic `initialize` result. Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` read the same regardless of which era you negotiated โ code that inspects the connection no longer branches on how it got there. See [Protocol negotiation](/clients/client#protocol-negotiation).
+Statelessness pays off in how you run the server. A sessionless request carries everything needed to answer it, so any replica behind an ordinary load balancer can serve any request and session affinity stops being a deployment requirement.
-The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577), and FastMCP 4's server API reflects that. `ctx.elicit` moves to a request-shaped pattern that works on modern connections: the tool returns a description of the input it needs, and the client answers with a fresh call. `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` are gone from the API, because each of them pushed a request down a live connection and a method that only works against old clients is a trap.
+The client default flipped to match. `Client(url)` probes for the modern protocol and adopts it when the server offers it, where every earlier FastMCP version pinned the handshake outright.
-Both capabilities survive in the same request-shaped form. Asking for roots that way is the natural replacement, since one round trip buys the whole answer. Generation usually belongs in the server instead, because a loop of asking rounds spends the round-trip budget over and over โ [call an LLM from your server](/servers/sampling). Logging is untouched: `ctx.info` and its siblings are notifications, and notifications ride the response stream on every era. Everything else about writing a server is unchanged.
+```python
+from fastmcp import Client
-## State without a session
+# Probes for the modern protocol, falls back to the handshake
+client = Client("https://example.com/mcp")
-A stateless protocol raises an obvious question: if every request is a fresh connection, where does a tool keep a shopping cart, a conversation, or a running total? FastMCP 4 follows the MCP working group's own decision to reject protocol-level sessions in favor of *explicit state handles* (SEP-2567) โ the server hands out an identifier, and the client passes it back.
+# Pins the handshake, when you need the session back-channel
+legacy = Client("https://example.com/mcp", mode="legacy")
+```
-Two shapes cover the cases. `UserSession` is injected like `Context` and keyed to the authenticated user, so a tool reads and writes one bucket of state with nothing to pass around. `SessionId` is an explicit handle a tool mints and the caller supplies as an argument, for when one user holds many independent states. Both store their data server-side in the storage backend, keyed to the authenticated user โ so a handle is inert in anyone else's hands. See [Session State](/servers/sessions).
+That default is what puts the modern capabilities within reach of ordinary client code: a task-enabled tool hands back a handle to poll, and multi-round-trip elicitation resolves across successive requests, with the caller opting in to neither. Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` read the same whichever era you negotiated, so code that inspects a connection never branches on how it was established. See [Protocol negotiation](/clients/client#protocol-negotiation).
+
+Intermediaries benefit too. On a modern connection, FastMCP's client attaches the method, the target name, and any opted-in argument values as HTTP headers, so a gateway or load balancer can route a request without parsing its JSON-RPC body. See [Gateway Routing Headers](/deployment/http#gateway-routing-headers).
+
+## Server-to-client requests
+
+A sessionless connection gives the server no channel to push a request down to a connected client mid-execution. Three `Context` methods depended on that channel, and this is the one part of FastMCP 4 likely to break an existing server.
+
+`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are removed. Touching one raises `AttributeError` on every era, so the break surfaces when you upgrade rather than in production against whichever client happens to negotiate the modern protocol.
+
+For generation, call an LLM directly from your tool: your server holds the API key, creates a provider client, and awaits a completion inline. That works against every client, including the many that never implemented sampling at all, and a tool that chains several generations pays no round trip for any of them. See [Sampling](/servers/sampling).
+
+When borrowing the *caller's* model is the actual point, or when a tool genuinely needs the client's roots, the tool asks by returning a description of what it needs. The round completes normally, the client answers, and it re-issues the call with the answer attached. `ctx.elicit()` is untouched and still works on handshake connections; on modern connections that same return-and-resume shape covers elicitation as well. See [the guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol).
+
+Logging and progress are unaffected. Both are notifications, and notifications ride the response stream on every era.
+
+## Session state
+
+If every request arrives on a fresh connection, a tool that wants to remember something between calls has nowhere to keep it. Weighing protocol-level sessions against statelessness, the MCP working group [chose statelessness](https://github.com/modelcontextprotocol/transports-wg/blob/main/docs/sessions-vs-sessionless-decision.md) and moved session semantics up to the application: the server hands out an identifier, and the client passes it back.
+
+FastMCP implements that pattern and adds the isolation a bare handle lacks. State is stored server-side and keyed to the authenticated user, so a handle is inert in anyone else's hands.
+
+Most tools want a single bucket per user. Declare a `UserSession` parameter and FastMCP injects it the way it injects `Context`: it never appears in the tool's input schema, and the caller passes nothing, because the user's identity selects the right bucket.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.sessions import UserSession
+
+mcp = FastMCP("assistant")
+
+
+@mcp.tool
+async def remember(fact: str, session: UserSession) -> str:
+ facts = await session.get("facts", default=[])
+ facts.append(fact)
+ await session.set("facts", facts)
+ return f"Remembered {len(facts)} facts."
+```
+
+Because the bucket is chosen from the caller's identity, `UserSession` requires [authentication](/servers/auth/authentication). An unauthenticated request has no user to key on, so the tool raises rather than guessing at a bucket.
+
+When one user needs several independent buckets, such as separate carts or parallel conversations, `SessionId` makes the handle an explicit string argument that the agent obtains from `create_session` and supplies on each call. See [Session State](/servers/sessions).
## Background tasks
-Long-running work runs as a background task: the server accepts the call, returns a handle, and the client polls for the result while the work proceeds. Tasks left the core MCP spec during the SDK v2 rebuild and returned as the `io.modelcontextprotocol/tasks` extension (SEP-2663), which FastMCP implements end to end in the optional `fastmcp-tasks` package. The durable execution engine that made FastMCP 3's tasks reliable โ [Docket](https://github.com/chrisguidry/docket) โ carries straight over, and `@mcp.tool(task=True)` remains the authoring surface, so the wire protocol modernizing underneath costs you no code change. See [Background Tasks](/servers/tasks).
+Long-running work runs as a background task: the server accepts the call and returns a handle immediately, and the client polls for the result while the work proceeds. Tasks left the core MCP spec during the SDK rewrite and returned as the `io.modelcontextprotocol/tasks` extension, which FastMCP implements end to end in the optional `fastmcp-tasks` package.
+
+`@mcp.tool(task=True)` remains the authoring surface and [Docket](https://github.com/chrisguidry/docket) still provides the durable execution engine, so the wire protocol modernizing underneath costs you no code change. What's new is the registration: tasks arrive as an extension you add to the server.
+
+```python
+import asyncio
+from fastmcp import FastMCP
+from fastmcp_tasks import TasksExtension
+
+mcp = FastMCP("MyServer")
+mcp.add_extension(TasksExtension())
+
+
+@mcp.tool(task=True)
+async def slow_computation(duration: int) -> str:
+ """A long-running operation."""
+ await asyncio.sleep(duration)
+ return f"Completed in {duration} seconds"
+```
+
+A FastMCP client handles the handle-and-poll cycle transparently, so `client.call_tool(...)` looks the same whether or not the call ran in the background. See [Background Tasks](/servers/tasks).
## Server extensions
-Background tasks are the first capability built on a more general one: FastMCP 4 makes MCP extensions โ capability-negotiated protocol features named by a reverse-DNS string (SEP-2133) โ a first-class surface. `FastMCP.add_extension()` lets an extension advertise a capability, add request methods, intercept `tools/call`, and run a lifespan hook, all with full access to the component registry, `Context`, and auth. The same extensions flow through the client with `Client(extensions=...)`. A cross-cutting protocol feature stops being surgery on core and becomes a supported plugin.
+Background tasks are the first capability built on a more general one. An MCP extension is a protocol feature named by a reverse-DNS string and negotiated as a capability, and FastMCP 4 makes extensions a first-class surface rather than something only the framework can add.
+
+`FastMCP.add_extension()` lets an extension advertise a capability, add request methods, intercept `tools/call`, and run a lifespan hook, all with full access to the component registry, `Context`, and auth. The same extensions flow through the client with `Client(extensions=...)`. A cross-cutting protocol feature becomes a supported plugin instead of surgery on core, and `TasksExtension` is the worked example of everything the interface allows. See [Server Extensions](/servers/extensions).
## Argument completion
-When a client offers autocomplete for a prompt argument or a resource-template parameter, it asks the server which values fit โ narrowing the list as the user types. FastMCP 4 lets a server answer. A single `@mcp.completion` handler receives the reference being completed, the argument and its partial value, and the arguments the user has already supplied, and returns the candidates the client surfaces as suggestions. Because the handler sees the earlier arguments, completions can depend on them โ a `repo` parameter suggesting only repositories under the `owner` already chosen.
+When a client offers autocomplete for a prompt argument or a resource-template parameter, it asks the server which values fit, narrowing the list as the user types. FastMCP 4 lets a server answer. A single `@mcp.completion` handler receives the reference being completed, the argument and its partial value, and the arguments the user has already supplied, and returns the candidates the client surfaces as suggestions.
+
+Because the handler sees the earlier arguments, completions can depend on them: a `repo` parameter can suggest only the repositories under the `owner` already chosen.
```python
from fastmcp import FastMCP
@@ -67,11 +130,11 @@ def complete(ref, argument, context):
return None
```
-Registering a handler advertises the completions capability during negotiation, so a client only sends requests to a server that answers them โ the same on both protocol eras. See [Argument Completion](/servers/completions).
+Registering a handler advertises the completions capability during negotiation, so a client only sends requests to a server that answers them, identically on both protocol eras. See [Argument Completion](/servers/completions).
## Enterprise identity
-FastMCP 4 ships a complete server-side implementation of identity assertion (SEP-990): enterprise "on-behalf-of" access, where a corporate identity provider issues a signed assertion, the user's agent presents it, and the server mints a short-lived token โ no browser login and no per-user consent screen. Behind one parameter on the existing auth providers, FastMCP performs the full signature verification, binding checks, replay rejection, and scoped token issuance.
+FastMCP 4 ships a complete server-side implementation of identity assertion, the enterprise "on-behalf-of" flow: a corporate identity provider issues a signed assertion, the user's agent presents it, and the server mints a short-lived token, with no browser login and no per-user consent screen. Behind one parameter on the existing auth providers, FastMCP performs the signature verification, binding checks, replay rejection, and scoped token issuance.
```python
from fastmcp import FastMCP
@@ -86,7 +149,7 @@ mcp = FastMCP("Internal API", auth=auth)
The asserted subject flows into the normal auth context, so tools read it through `get_access_token()` like any other identity. See [Identity Assertion](/servers/auth/oauth-proxy#identity-assertion-sep-990).
-Authorizing a caller by role is a related, provider-agnostic need. Scopes are standardized, so `require_scopes` behaves the same everywhere, but roles and groups are not part of OIDC and every provider files them under a different claim. `require_roles` handles the comparison and takes an `extract` callable naming where to look, so Keycloak's `realm_access.roles`, Cognito's `cognito:groups`, and Auth0's per-tenant namespaced claims all work without FastMCP guessing.
+Authorizing a caller by role is a related, provider-agnostic need. Scopes are standardized, so `require_scopes` behaves the same everywhere, but roles and groups are not part of OIDC and every provider files them under a different claim. `require_roles` handles the comparison and takes an `extract` callable naming where to look, so Keycloak's `realm_access.roles`, Cognito's `cognito:groups`, and Auth0's namespaced claims all work without FastMCP guessing.
```python
from fastmcp import FastMCP
@@ -94,15 +157,16 @@ from fastmcp.server.auth import require_roles
mcp = FastMCP("Internal API")
+
@mcp.tool(auth=require_roles("admin", extract=lambda c: c["realm_access"]["roles"]))
def rotate_credentials() -> str:
"""Only callable by a caller holding the 'admin' role."""
return "Rotated"
```
-This illustrates the check in isolation โ enforcing it for real needs an HTTP-transport server with a token-validating `auth` provider configured (a `JWTVerifier`, a `RemoteAuthProvider`, or a provider built on one, such as `KeycloakAuthProvider`, all expose claims directly), since STDIO has no OAuth concept and skips every check. See [Authorization](/servers/authorization#require_roles) for the full picture.
+That example shows the check in isolation. Enforcing it for real needs an HTTP-transport server with a token-validating `auth` provider configured, since STDIO has no OAuth concept and skips every check. A `JWTVerifier`, a `RemoteAuthProvider`, or any provider built on one such as `KeycloakAuthProvider` all expose claims directly. See [Authorization](/servers/authorization#require_roles).
-The client side of enterprise auth arrived too. Not every FastMCP client has a user behind it โ a backend service, a scheduled job, one MCP server calling another โ and `ClientCredentialsOAuthProvider` authenticates one of those to a protected server with the OAuth 2.0 client-credentials grant: no browser, no redirect, no consent screen.
+The client side arrived too. Plenty of FastMCP clients have no user behind them, such as a backend service, a scheduled job, or one MCP server calling another. `ClientCredentialsOAuthProvider` authenticates one of those to a protected server with the OAuth 2.0 client-credentials grant: no browser, no redirect, no consent screen.
```python
import asyncio
@@ -127,9 +191,9 @@ asyncio.run(main())
See [Machine-to-Machine Authentication](/clients/auth/client-credentials).
-## Faster and safer
+## Response caching
-Two more capabilities arrive by default. Response caching (SEP-2549) lets a server stamp freshness hints on its results that a caching [client](/clients/client#response-caching) reuses without a round trip, and a distributed `KeyValueResponseCacheStore` backs that cache with Redis or any key-value store, so a fleet of clients or proxy replicas shares fills.
+A server can stamp freshness hints on its results, and a caching client reuses a result within that window instead of making the round trip. Set the defaults on the server and every response carries them.
```python
from fastmcp import FastMCP
@@ -137,10 +201,10 @@ from fastmcp import FastMCP
mcp = FastMCP("Weather", cache_ttl=300, cache_scope="public")
```
-Security tightened in the same release: every templated resource screens its parameters for path traversal, absolute paths, and null bytes before the handler runs โ [path security](/servers/resources#path-security) on by default, covering mounted and proxied templates too.
+Backing the client's cache with the distributed `KeyValueResponseCacheStore` puts it in Redis or any key-value store, so a fleet of clients or proxy replicas shares fills rather than each paying for its own. See [Response caching](/clients/client#response-caching).
-The OAuth flow got more precise as well. Dynamic Client Registration now honors a client's declared `application_type` (SEP-837): the permissive loopback and app-scheme callbacks MCP clients rely on stay the default for `"native"`, while a client that registers as `"web"` is held to stricter browser-app redirect rules. And when `AuthMiddleware` denies a call specifically for a missing scope, it raises `InsufficientScopeError` naming exactly which scopes would fix it (SEP-2350), so a caller re-authorizes precisely instead of retrying blind. See [Application Type](/servers/auth/oauth-proxy#application-type-web-vs-native) and [Signaling Scope Shortfalls](/servers/authorization#signaling-scope-shortfalls).
+## Security defaults
-A gateway or load balancer in front of your server can now route a request without parsing its JSON-RPC body: on a modern connection, FastMCP's client attaches the method, target name, and opted-in argument values as HTTP headers (SEP-2243), so an intermediary dispatches on headers alone. See [Gateway Routing Headers](/deployment/http#gateway-routing-headers).
+Templated resources now screen their parameters for path traversal, absolute paths, and null bytes before the handler runs. This is on by default and covers mounted and proxied templates too, so a template that interpolates a parameter into a filesystem path no longer has to validate it by hand. See [path security](/servers/resources#path-security).
-When you're ready to move a server to v4, [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) walks through every change and what it looks like in practice.
+The OAuth flow got more precise in two places. Dynamic Client Registration honors a client's declared `application_type`: the permissive loopback and app-scheme callbacks that MCP clients rely on stay the default for `"native"`, while a client registering as `"web"` is held to stricter browser-app redirect rules. And when `AuthMiddleware` denies a call specifically for a missing scope, it raises `InsufficientScopeError` naming which scopes would fix it, so a caller re-authorizes precisely instead of retrying blind. See [Application Type](/servers/auth/oauth-proxy#application-type-web-vs-native) and [Signaling Scope Shortfalls](/servers/authorization#signaling-scope-shortfalls).
diff --git a/docs/more/faq.mdx b/docs/more/faq.mdx
index 29b0f7e89..1ac3c2c7d 100644
--- a/docs/more/faq.mdx
+++ b/docs/more/faq.mdx
@@ -58,7 +58,7 @@ Take the paths you need as ordinary tool arguments. The agent already knows whic
Because logging is a *notification* and sampling was a *request*. A notification is fire-and-forget: your server emits it down the response stream the caller already opened, and nothing has to be held open on the server's behalf. A request needs an answer to come back the other way, which requires a live connection the server can reach into.
-The modern protocol kept every server notification โ `notifications/message`, `notifications/progress`, and the list-changed family โ and removed the server-to-client request direction entirely. So `ctx.info()`, `ctx.debug()`, and `ctx.report_progress()` reach the client mid-call on every era, while sampling and roots have no era-agnostic form and were dropped. [Sampling](/servers/sampling#requests-and-notifications) works through the distinction in full.
+The modern protocol kept every server notification โ `notifications/message`, `notifications/progress`, and the list-changed family โ and removed the server-to-client request direction entirely. So `ctx.info()`, `ctx.debug()`, and `ctx.report_progress()` reach the client mid-call on every era, while sampling and roots have no era-agnostic form and were dropped. [Sampling](/servers/sampling#the-removed-methods) works through the distinction in full.
You may see an `MCPDeprecationWarning` from the SDK about the logging capability being deprecated as of `2026-07-28`. It refers to the capability declaration, not to the notification, and delivery is unaffected.
diff --git a/docs/servers/extensions.mdx b/docs/servers/extensions.mdx
new file mode 100644
index 000000000..de81d2251
--- /dev/null
+++ b/docs/servers/extensions.mdx
@@ -0,0 +1,166 @@
+---
+title: Server Extensions
+sidebarTitle: Extensions
+description: Add negotiated protocol features to a server without forking the framework.
+icon: plug
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+An MCP extension is a protocol feature that lives outside the core spec, named by a reverse-DNS identifier and negotiated as a capability. A server advertises the extensions it implements, and a client advertises the ones it understands. That negotiation is per request: a client repeats its extension capabilities in every request's `_meta`, so a handler can always tell whether the caller opted in to this particular call.
+
+Honoring that opt-in is the extension's job, not the framework's. FastMCP advertises your capability and routes your methods, but it does not filter callers for you, so an extension that changes behavior must check before it acts. The [tool-call interceptor](#intercepting-tool-calls) below shows the check.
+
+FastMCP 4 makes extensions a first-class surface. `FastMCP.add_extension()` takes an object that can advertise a capability, serve new request methods, wrap every `tools/call`, and own resources for the life of the server. [Background tasks](/servers/tasks) are built this way, on the same public interface available to you, so a cross-cutting protocol feature becomes a plugin rather than a change to FastMCP itself.
+
+## Writing an extension
+
+Subclass `ServerExtension` and set an `identifier`. The identifier must carry a reverse-DNS prefix in `vendor-prefix/name` form, which FastMCP validates when the class is defined, so a malformed one fails immediately rather than at connection time. Everything else is optional: each contribution method has a working default, and a useful extension often overrides just one.
+
+Registering the extension binds it to the server and advertises its capability. The capability is advertised only while the extension is registered, and registering two extensions with the same identifier is an error.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.extensions import ServerExtension
+
+
+class CallCounterExtension(ServerExtension):
+ identifier = "com.example/call-counter"
+
+ def __init__(self) -> None:
+ self.count = 0
+
+
+mcp = FastMCP("Demo")
+mcp.add_extension(CallCounterExtension())
+```
+
+Register extensions before the server starts. Adding one after the lifespan is running raises, because the extension's own lifespan could no longer run and it would end up silently half-active.
+
+An extension reaches the rest of the server through `self.server`, which is the `FastMCP` instance it was registered on. That is how handlers and interceptors get at the component registry, the request [`Context`](/servers/context), and the authenticated caller.
+
+## Advertising settings
+
+Some extensions need to tell the client how they are configured: a size limit, a supported mode, a flag. Override `settings()` to return a JSON-serializable dict, and it appears on the wire under `capabilities.extensions[identifier]`. The default is an empty dict, which advertises the extension with no settings attached.
+
+```python
+from typing import Any
+
+from fastmcp import FastMCP
+from fastmcp.server.extensions import ServerExtension
+
+
+class UploadExtension(ServerExtension):
+ identifier = "com.example/uploads"
+
+ def settings(self) -> dict[str, Any]:
+ return {"maxBytes": 10_000_000, "resumable": True}
+
+
+mcp = FastMCP("Demo")
+mcp.add_extension(UploadExtension())
+```
+
+A client reads these alongside the capability itself, so it can adapt before making a single call.
+
+## Adding request methods
+
+An extension can serve request methods the core spec does not define. Return a `MethodBinding` from `methods()` naming the wire method, the Pydantic model its params validate against, and the handler to run.
+
+Extension methods are strictly additive. Binding a spec-defined method like `tools/call` raises at construction, because doing so would silently shadow the server's own handler. To change how a core method behaves, use [middleware](/servers/middleware) or the tool-call interceptor below.
+
+The params model should subclass `RequestParams` so `_meta` parses uniformly, and the handler receives the request context and the validated params.
+
+```python
+from typing import Any
+
+from mcp.types import RequestParams
+from fastmcp.server.extensions import MethodBinding, ServerExtension
+
+
+class GetCallCountParams(RequestParams):
+ pass
+
+
+class CallCounterExtension(ServerExtension):
+ identifier = "com.example/call-counter"
+
+ def __init__(self) -> None:
+ self.count = 0
+
+ def methods(self) -> list[MethodBinding]:
+ return [
+ MethodBinding(
+ method="callCounter/get",
+ params_type=GetCallCountParams,
+ handler=self.get_count,
+ )
+ ]
+
+ async def get_count(self, ctx, params: GetCallCountParams) -> dict[str, Any]:
+ return {"count": self.count}
+```
+
+Setting `protocol_versions` on a binding restricts the method to specific wire versions, and a request at any other version is rejected as `METHOD_NOT_FOUND`. Leaving it unset, the default, serves the method on every version.
+
+## Intercepting tool calls
+
+Override `intercept_tool_call()` to wrap every `tools/call` the server handles. The interceptor runs after the FastMCP middleware chain and immediately before the tool body, making it the last gate before execution. Await `call_next()` to let the call proceed, or return a result without awaiting it to short-circuit.
+
+Every registered interceptor runs on every tool call, including calls from clients that never advertised your extension. FastMCP does not gate this for you, so an interceptor that changes what the caller gets back must first confirm the caller opted in. `context.client_extension_settings(identifier)` returns the settings the client declared for this request, or `None` when it declared nothing.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.extensions import ServerExtension
+
+
+class CallCounterExtension(ServerExtension):
+ identifier = "com.example/call-counter"
+
+ def __init__(self) -> None:
+ self.count = 0
+
+ async def intercept_tool_call(self, params, context, call_next):
+ if context.client_extension_settings(self.identifier) is None:
+ return await call_next()
+ self.count += 1
+ return await call_next()
+
+
+mcp = FastMCP("Demo")
+mcp.add_extension(CallCounterExtension())
+```
+
+Counting is harmless either way, so this example passes unaware callers straight through. The check becomes essential the moment an interceptor short-circuits: returning an extension-specific result to a client that never negotiated the extension hands it a shape it has no way to understand. Request methods have the same requirement, and `self.client_settings(ctx)` is the equivalent inside a handler.
+
+`params` holds the validated `tools/call` params, and `context` is the FastMCP `Context`, so the tool being invoked is reachable as `context.fastmcp.get_tool(params.name)` along with auth scope and the server itself. When several extensions intercept, they nest with the first-registered outermost.
+
+Reach for middleware when you want to observe or modify requests generally; reach for an interceptor when the behavior belongs to a negotiated capability and should exist only while that extension is registered.
+
+## Owning resources
+
+An extension that owns something with a lifecycle, such as a connection pool or a background worker, overrides `lifespan()` to return an async context manager. FastMCP enters it with the server's own [lifespan](/servers/lifespan) and exits it on shutdown, so setup and teardown stay with the extension that needs them rather than leaking into the application's startup code.
+
+The lifespan is entered once per runtime tree, at the root. This matters when you compose servers: extensions are served by the server they are registered on, and a mounted child's extensions do not propagate upward. The root server owns the wire, so only root-registered extensions advertise capabilities and answer methods. Register extensions on the server you actually run.
+
+## Client extensions
+
+The client half of an extension is what makes negotiation two-sided. Pass `ClientExtension` instances to `Client(extensions=...)` and each contributes its capability advertisement, its result claims, and its notification bindings to the underlying session. A claimed `call_tool` result is then resolved transparently through the extension that owns it.
+
+When a client needs only to say it understands an extension, without implementing behavior for it, `advertise()` produces an advertise-only entry.
+
+```python
+from fastmcp import Client
+from mcp.client import advertise
+
+client = Client(
+ "https://example.com/mcp",
+ extensions=[advertise("com.example/uploads", {"maxBytes": 10_000_000})],
+)
+```
+
+Advertise only what you genuinely support: the advertisement asserts wire compatibility, and claiming an extension you have not implemented invites the server to use a feature you cannot answer. For anything behavioral, construct the real extension instead.
+
+Claimed result shapes are a modern-protocol feature and stay inert on a legacy connection, so an extension-aware client is still safe to point at an older server.
diff --git a/docs/servers/providers/overview.mdx b/docs/servers/providers/overview.mdx
index c19073f78..2f23f76a5 100644
--- a/docs/servers/providers/overview.mdx
+++ b/docs/servers/providers/overview.mdx
@@ -70,12 +70,6 @@ When a client requests a component by name or URI, FastMCP queries providers and
- [Proxy a remote server](/servers/providers/proxy) through yours
- [Control visibility state](/servers/visibility) of components
- [Build dynamic sources](/servers/providers/custom) like database-backed tools
+- [Transform components](/servers/transforms/transforms) to namespace, rename, or modify them
-## Next Steps
-
-- [Local](/servers/providers/local) - How decorators work
-- [Mounting](/servers/composition) - Compose servers together
-- [Proxying](/servers/providers/proxy) - Connect to remote servers
-- [Transforms](/servers/transforms/transforms) - Namespace, rename, and modify components
-- [Visibility](/servers/visibility) - Control which components clients can access
-- [Custom](/servers/providers/custom) - Build your own providers
+The decorators you already use are themselves a provider: [`LocalProvider`](/servers/providers/local) is what backs `@mcp.tool` and its siblings.
diff --git a/docs/servers/storage-backends.mdx b/docs/servers/storage-backends.mdx
index 30bdf7b93..32e6530eb 100644
--- a/docs/servers/storage-backends.mdx
+++ b/docs/servers/storage-backends.mdx
@@ -199,7 +199,7 @@ Both parameters are required for production. **Wrap your storage in `FernetEncry
### Response Caching Middleware
-The [Response Caching Middleware](/servers/middleware#caching-middleware) caches tool calls, resource reads, and prompt requests. Storage configuration is passed via the `cache_storage` parameter:
+The [Response Caching Middleware](/servers/middleware#caching) caches tool calls, resource reads, and prompt requests. Storage configuration is passed via the `cache_storage` parameter:
```python
from pathlib import Path
@@ -289,6 +289,6 @@ This allows clients to reconnect without re-authenticating after restarts.
## More Resources
- [py-key-value-aio GitHub](https://github.com/strawgate/py-key-value) - Full library documentation
-- [Response Caching Middleware](/servers/middleware#caching-middleware) - Using storage for caching
+- [Response Caching Middleware](/servers/middleware#caching) - Using storage for caching
- [OAuth Token Security](/deployment/http#oauth-token-security) - Production OAuth configuration
- [HTTP Deployment](/deployment/http) - Complete deployment guide
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index f7fe31ec0..e87ebedd4 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -431,7 +431,7 @@ def get_user_details(user_id: str = Depends(get_user_id)) -> str:
return f"Details for {user_id}"
```
-See [Custom Dependencies](/servers/context#custom-dependencies) for more details on dependency injection.
+See [Custom Dependencies](/servers/dependency-injection#custom-dependencies) for more details on dependency injection.
## Return Values
diff --git a/docs/servers/transforms/code-mode.mdx b/docs/servers/transforms/code-mode.mdx
index 5b04fcab5..0e7e4f50d 100644
--- a/docs/servers/transforms/code-mode.mdx
+++ b/docs/servers/transforms/code-mode.mdx
@@ -140,7 +140,7 @@ You can cap result count with `default_limit`. The LLM can also override the lim
Search(default_limit=5) # return at most 5 results per search
```
-If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` parameter so the LLM can narrow results to specific categories before searching.
+If your tools use [tags](/servers/visibility#tags), Search also accepts a `tags` parameter so the LLM can narrow results to specific categories before searching.
### GetSchemas
@@ -148,7 +148,7 @@ If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` para
### GetTags
-`GetTags` lets the LLM browse tools by category using [tag](/servers/tools#tags) metadata. At brief detail, the LLM sees tag names with counts. At full detail, it sees tools listed under each tag:
+`GetTags` lets the LLM browse tools by category using [tag](/servers/visibility#tags) metadata. At brief detail, the LLM sees tag names with counts. At full detail, it sees tools listed under each tag:
```
- math (3 tools)
@@ -187,7 +187,7 @@ from fastmcp.experimental.transforms.code_mode import CodeMode
mcp = FastMCP("Server", transforms=[CodeMode()])
```
-If your tools use [tags](/servers/tools#tags), add `GetTags` so the LLM can browse by category before searching โ giving it four stages of progressive disclosure:
+If your tools use [tags](/servers/visibility#tags), add `GetTags` so the LLM can browse by category before searching โ giving it four stages of progressive disclosure:
```python
from fastmcp import FastMCP
From 0792ac812c3240a8256d44fbbc01caa97e4cb8cc Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Wed, 29 Jul 2026 09:51:13 -0400
Subject: [PATCH 05/32] Improve the v4 docs (#4707)
---
docs/apps/providers/file-upload.mdx | 25 ++-
docs/changelog.mdx | 2 +-
docs/cli/overview.mdx | 4 +-
docs/clients/client.mdx | 8 +-
docs/clients/logging.mdx | 11 +-
docs/clients/resources.mdx | 13 +-
docs/deployment/server-configuration.mdx | 15 +-
docs/docs.json | 2 +-
docs/getting-started/installation.mdx | 4 +-
docs/getting-started/quickstart.mdx | 6 +-
docs/getting-started/whats-new.mdx | 200 ++++++++++--------
docs/integrations/anthropic.mdx | 22 +-
docs/integrations/github.mdx | 4 +-
docs/integrations/mcp-json-configuration.mdx | 4 +-
docs/integrations/permit.mdx | 6 +-
docs/servers/auth/oauth-proxy.mdx | 7 +-
docs/servers/auth/oidc-proxy.mdx | 2 +-
docs/servers/auth/remote-oauth.mdx | 10 +-
docs/servers/middleware.mdx | 4 +-
docs/servers/prompts.mdx | 4 +-
docs/servers/resources.mdx | 6 +-
docs/servers/tools.mdx | 8 +-
docs/tutorials/mcp.mdx | 10 +-
docs/updates.mdx | 6 +-
.../fastmcp/server/auth/oauth_proxy/proxy.py | 2 +-
25 files changed, 220 insertions(+), 165 deletions(-)
diff --git a/docs/apps/providers/file-upload.mdx b/docs/apps/providers/file-upload.mdx
index b9709d946..f10ef0da7 100644
--- a/docs/apps/providers/file-upload.mdx
+++ b/docs/apps/providers/file-upload.mdx
@@ -59,16 +59,24 @@ This works with **stdio**, **SSE**, and **stateful HTTP** transports, where sess
In **stateless HTTP** mode, each request creates a new session object with a new ID. Files stored during one request (e.g. the UI upload) will be invisible to the next request (e.g. the LLM calling `list_files`). You **must** override `_get_scope_key` to use a stable identifier like a user ID from your auth token.
-For stateless deployments, override `_get_scope_key` to return a stable identifier. For example, to scope files by authenticated user:
+For stateless deployments, override `_get_scope_key` to return a stable identifier. To scope files by authenticated user, read the caller from `get_access_token()`.
+
+Reject the request when there is no subject to key on. `get_access_token()` returns `None` on an unauthenticated request, and `subject` is optional even on a valid token, since not every verifier populates it. Returning a fallback in either case would put every such caller in one shared bucket, so they would see each other's uploads.
```python
from fastmcp.apps.file_upload import FileUpload
+from fastmcp.server.dependencies import get_access_token
class UserScopedUpload(FileUpload):
def _get_scope_key(self, ctx):
- return ctx.access_token["sub"]
+ token = get_access_token()
+ if token is None or not token.subject:
+ raise ValueError("File scoping requires an authenticated user with a subject")
+ return token.subject
```
+If your provider carries the user identity in a different claim, read it from `token.claims` and validate it the same way.
+
For process-wide shared storage (all users see all files):
```python
@@ -85,10 +93,17 @@ The default implementation stores files in memory for the lifetime of the server
import base64
from fastmcp.apps.file_upload import FileUpload
+from fastmcp.server.dependencies import get_access_token
class S3Upload(FileUpload):
+ def _get_scope_key(self, ctx):
+ token = get_access_token()
+ if token is None or not token.subject:
+ raise ValueError("File scoping requires an authenticated user with a subject")
+ return token.subject
+
def on_store(self, files, ctx):
- user_id = ctx.access_token["sub"]
+ user_id = self._get_scope_key(ctx)
for f in files:
s3.put_object(
Bucket="uploads",
@@ -98,7 +113,7 @@ class S3Upload(FileUpload):
return self.on_list(ctx)
def on_list(self, ctx):
- user_id = ctx.access_token["sub"]
+ user_id = self._get_scope_key(ctx)
objects = s3.list_objects(Bucket="uploads", Prefix=f"{user_id}/")
return [
{
@@ -112,7 +127,7 @@ class S3Upload(FileUpload):
]
def on_read(self, name, ctx):
- user_id = ctx.access_token["sub"]
+ user_id = self._get_scope_key(ctx)
obj = s3.get_object(Bucket="uploads", Key=f"{user_id}/{name}")
content = obj["Body"].read()
return {
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index 09a52bbd8..de17a3f8e 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -9,7 +9,7 @@ tag: NEW
**[v4.0.0b1: Fourgone Conclusion](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1)**
-FastMCP 4 rebuilds the framework on the MCP Python SDK v2, and this beta is the first release to run on the SDK's stable 2.0. The SDK v2 rewrote the protocol layer end to end โ protocol types moved into a standalone `mcp_types` package, every model field renamed from camelCase to snake_case in Python, and the server's request-handling model replaced โ and FastMCP absorbs nearly all of it, so most FastMCP 3 servers run untouched. On that foundation v4 serves the sessionless `2026-07-28` protocol and the older handshake from one server, adds stateless session state and background tasks, makes protocol extensions a first-class surface, and removes server-initiated sampling and roots from the server API.
+FastMCP 4 makes stateful MCP applications work on the sessionless `2026-07-28` protocol while one deployment continues serving handshake-era clients. Tools can ask follow-up questions across requests, preserve authenticated user state, and move long-running work into background tasks without sticky sessions. Protocol extensions and enterprise identity become first-class surfaces, and most FastMCP 3 servers upgrade unchanged even though MCP Python SDK v2 rewrote the engine underneath them. Server-initiated sampling and roots are removed from the server API; the [upgrade guide](/getting-started/upgrading/from-fastmcp-3) covers their replacements.
### New Features ๐
* Migrate to MCP Python SDK v2 by [@jlowin](https://github.com/jlowin) in [#4437](https://github.com/PrefectHQ/fastmcp/pull/4437)
diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx
index c002c0c1e..9085daaa8 100644
--- a/docs/cli/overview.mdx
+++ b/docs/cli/overview.mdx
@@ -89,10 +89,10 @@ To skip authentication entirely โ useful for local development servers โ pas
fastmcp call http://localhost:8000/mcp my_tool --auth none
```
-You can also pass a bearer token directly:
+You can also pass a bearer token directly. Give the token value on its own; FastMCP adds the `Bearer` prefix when it builds the `Authorization` header.
```bash
-fastmcp list http://localhost:8000/mcp --auth "Bearer sk-..."
+fastmcp list http://localhost:8000/mcp --auth "sk-..."
```
## Transport Override
diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx
index 092738e7c..fb910777f 100644
--- a/docs/clients/client.mdx
+++ b/docs/clients/client.mdx
@@ -144,12 +144,12 @@ async with Client(mcp) as client:
print(f"Capabilities: {client.server_capabilities.tools}")
```
-For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually:
+For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually. `initialize()` is a handshake-era operation, so pin the connection with `mode="legacy"`: the modern protocol has no `initialize` round trip, and calling it on a modern connection raises.
```python
from fastmcp import Client
-client = Client("my_mcp_server.py", auto_initialize=False)
+client = Client("my_mcp_server.py", auto_initialize=False, mode="legacy")
async with client:
# Connection established, but not initialized yet
@@ -219,7 +219,7 @@ The SSE transport is legacy-only โ it cannot carry the sessionless modern era
-The client can cache the results of `list_tools`, `list_resources`, `list_prompts`, and `read_resource` so that repeated calls avoid a network round-trip. Caching is opt-in and honors the server's own cache hints, so it only takes effect against modern-era servers that advertise them โ a cache is inert on a legacy connection.
+The client can cache the results of `list_tools`, `list_resources`, and `list_prompts` so that repeated calls avoid a network round-trip. Caching is opt-in and honors the server's own cache hints, so it only takes effect against modern-era servers that advertise them โ a cache is inert on a legacy connection.
Enable the default in-memory cache by passing `cache=True`. It respects the `ttlMs` and `cacheScope` hints the server attaches to each response.
@@ -243,7 +243,7 @@ config = CacheConfig(target_id="weather-api", default_ttl_ms=60_000)
client = Client("https://example.com/mcp", mode="auto", cache=config)
```
-The high-level `list_tools`, `list_resources`, `list_prompts`, and `read_resource` methods always use the cache when one is configured. To override the behavior for a single call, use the lower-level `*_mcp` variants, which accept a `cache_mode` argument: `"use"` (the default) serves and stores, `"refresh"` stores a fresh result without serving a cached one, and `"bypass"` skips the cache entirely.
+The high-level `list_tools`, `list_resources`, and `list_prompts` methods always use the cache when one is configured. To override the behavior for a single call, use the lower-level `list_tools_mcp`, `list_resources_mcp`, `list_resource_templates_mcp`, and `list_prompts_mcp` variants, which accept a `cache_mode` argument: `"use"` (the default) serves and stores, `"refresh"` stores a fresh result without serving a cached one, and `"bypass"` skips the cache entirely.
```python
async with client:
diff --git a/docs/clients/logging.mdx b/docs/clients/logging.mdx
index 0fb9f735b..407b1ebd5 100644
--- a/docs/clients/logging.mdx
+++ b/docs/clients/logging.mdx
@@ -28,7 +28,16 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
-LOGGING_LEVEL_MAP = logging.getLevelNamesMapping()
+LOGGING_LEVEL_MAP = {
+ "DEBUG": logging.DEBUG,
+ "INFO": logging.INFO,
+ "NOTICE": logging.INFO,
+ "WARNING": logging.WARNING,
+ "ERROR": logging.ERROR,
+ "CRITICAL": logging.CRITICAL,
+ "ALERT": logging.CRITICAL,
+ "EMERGENCY": logging.CRITICAL,
+}
async def log_handler(message: LogMessage):
"""Forward MCP server logs to Python's logging system."""
diff --git a/docs/clients/resources.mdx b/docs/clients/resources.mdx
index 68aaaf18b..041ad0978 100644
--- a/docs/clients/resources.mdx
+++ b/docs/clients/resources.mdx
@@ -58,18 +58,25 @@ async with client:
Binary resources include images, PDFs, and other non-text data:
+Binary resources arrive as `BlobResourceContents`, whose `blob` field is a base64 **string**, so decode it before writing bytes to disk:
+
```python
+import base64
+
+from mcp_types import BlobResourceContents
+
async with client:
content = await client.read_resource("resource://images/logo.png")
for item in content:
- if hasattr(item, 'blob'):
- print(f"Binary content: {len(item.blob)} bytes")
+ if isinstance(item, BlobResourceContents):
+ data = base64.b64decode(item.blob)
+ print(f"Binary content: {len(data)} bytes")
print(f"MIME type: {item.mime_type}")
# Save to file
with open("downloaded_logo.png", "wb") as f:
- f.write(item.blob)
+ f.write(data)
```
## Multi-Server Clients
diff --git a/docs/deployment/server-configuration.mdx b/docs/deployment/server-configuration.mdx
index 4eb966231..c67d5ef1f 100644
--- a/docs/deployment/server-configuration.mdx
+++ b/docs/deployment/server-configuration.mdx
@@ -39,30 +39,33 @@ The `fastmcp.json` configuration answers three fundamental questions about your
This conceptual model helps you understand the purpose of each configuration section and organize your settings effectively. The configuration file maps directly to these three concerns:
+`source` is the *where*, `environment` the *what*, and `deployment` the *how*:
+
```json
{
"$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json",
"source": {
- // WHERE: Location of your server code
- "type": "filesystem", // Optional, defaults to "filesystem"
+ "type": "filesystem",
"path": "server.py",
"entrypoint": "mcp"
},
"environment": {
- // WHAT: Environment setup and dependencies
- "type": "uv", // Optional, defaults to "uv"
+ "type": "uv",
"python": ">=3.10",
"dependencies": ["pandas", "numpy"]
},
"deployment": {
- // HOW: Runtime configuration
"transport": "stdio",
"log_level": "INFO"
}
}
```
-Only the `source` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed.
+Only the `source` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed. Both `type` fields shown above are optional too, defaulting to `"filesystem"` and `"uv"` respectively.
+
+
+`fastmcp.json` is parsed as strict JSON, so it accepts no comments or trailing commas.
+
### JSON Schema Support
diff --git a/docs/docs.json b/docs/docs.json
index 22bc3c4b9..a3490a88a 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -16,7 +16,7 @@
"dark": "#475569",
"light": "#1e3a5f"
},
- "content": "FastMCP 4 is in beta โ check out [what's new](/getting-started/whats-new)!"
+ "content": "FastMCP 4 is in beta โ build stateful applications on sessionless MCP. [See what's new](/getting-started/whats-new)."
},
"colors": {
"dark": "#f72585",
diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx
index c3c1bdacd..8c3167fb6 100644
--- a/docs/getting-started/installation.mdx
+++ b/docs/getting-started/installation.mdx
@@ -115,8 +115,8 @@ FastMCP follows semantic versioning with pragmatic adaptations for the rapidly e
For production use, always pin to exact versions:
```
-fastmcp==4.0.0 # Good
-fastmcp>=4.0.0 # Bad - may install breaking changes
+fastmcp==4.0.0b1 # Good - an exact version
+fastmcp>=4.0.0 # Bad - may install breaking changes
```
See the full [versioning and release policy](/development/releases#versioning-policy) for details on our public API, deprecation practices, and breaking change philosophy.
diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx
index ae157d906..79c4599a3 100644
--- a/docs/getting-started/quickstart.mdx
+++ b/docs/getting-started/quickstart.mdx
@@ -142,9 +142,11 @@ def greet(name: str) -> PrefabApp:
You can preview app tools locally with `fastmcp dev apps my_server.py` โ no MCP host required. See the [Apps overview](/apps/overview) for the full guide, including state management, forms, charts, and server-connected interactivity.
-## Deploy to Prefect Horizon
+## Deploy Your Server
-[Prefect Horizon](https://horizon.prefect.io?utm_source=gofastmcp&utm_medium=docs) is the enterprise MCP platform built by the FastMCP team at [Prefect](https://www.prefect.io). It provides managed hosting, authentication, access control, and observability for MCP servers.
+FastMCP HTTP servers run anywhere you can host a Python application. The [HTTP deployment guide](/deployment/http) covers the transport settings and security boundaries for self-managed infrastructure.
+
+For a managed deployment, [Prefect Horizon](https://horizon.prefect.io?utm_source=gofastmcp&utm_medium=docs) is the enterprise MCP platform built by the FastMCP team at [Prefect](https://www.prefect.io). It provides hosting, authentication, access control, and observability for MCP servers.
Horizon is **free for personal projects** and offers enterprise governance for teams.
diff --git a/docs/getting-started/whats-new.mdx b/docs/getting-started/whats-new.mdx
index 230bb5844..cebc3e682 100644
--- a/docs/getting-started/whats-new.mdx
+++ b/docs/getting-started/whats-new.mdx
@@ -1,65 +1,112 @@
---
title: "What's New in FastMCP 4"
sidebarTitle: "What's New"
-description: A sessionless MCP protocol, the state layer that replaces sessions, and enterprise identity.
+description: FastMCP 4 makes stateful MCP applications work on the sessionless protocol while one server serves every protocol era.
icon: sparkles
---
-FastMCP 4 runs on version 2 of the MCP Python SDK, which rewrote the protocol layer to support MCP's new sessionless protocol, `2026-07-28`. That protocol drives most of this release. It changes how servers deploy, how clients connect, where state lives between calls, and how a running tool asks the user a question.
+FastMCP 4 makes stateful MCP applications work on MCP's sessionless protocol. Tools can ask follow-up questions across requests, preserve authenticated user state, and move long-running work into background tasks without sticky sessions or a continuously connected client.
-Most FastMCP 3 servers run on 4 unchanged. Two things need attention: `ctx.sample()` and `ctx.list_roots()` are gone, and code that builds MCP protocol models by hand now uses snake_case field names where the SDK used camelCase. [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) covers every break in detail.
+The protocol changed completely underneath those APIs. Your application usually does not: one FastMCP server negotiates both protocol eras per connection, and most FastMCP 3 servers upgrade unchanged.
+
+That is the theme of version 4: stateless transport without stateless application code. The release also makes protocol extensions a first-class surface, adds enterprise identity for agents acting on behalf of users, and strengthens production defaults across caching, routing, and security.
FastMCP 4 is in **beta**. Pin an exact version and expect sharp edges. See [Install the v4 prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
-## Every protocol era
+## Protocol compatibility
-A FastMCP 4 server answers clients on both sides of the protocol transition from a single deployment. The SDK negotiates per connection: the sessionless protocol for clients that have moved forward, the session-based handshake for everyone else. You adopt the new protocol without forking your deployment or gating clients by version, which supersedes FastMCP's earlier "latest protocol only" stance.
+A protocol migration usually forces a choice between breaking clients that have not moved yet and holding the server back with them. FastMCP 4 serves both eras from one deployment, negotiating the best mutual version for each connection. Modern clients get the sessionless protocol while handshake-era clients continue working unchanged.
-Statelessness pays off in how you run the server. A sessionless request carries everything needed to answer it, so any replica behind an ordinary load balancer can serve any request and session affinity stops being a deployment requirement.
+Statelessness changes how that deployment scales. Each modern request carries everything needed to answer it, so any replica behind an ordinary load balancer can serve any request and session affinity stops being a requirement.
-The client default flipped to match. `Client(url)` probes for the modern protocol and adopts it when the server offers it, where every earlier FastMCP version pinned the handshake outright.
+The client default follows the same rule. `Client(url)` probes for the modern protocol and falls back to the handshake when necessary. Pin `mode="legacy"` only when your application specifically needs the session back-channel.
```python
from fastmcp import Client
-# Probes for the modern protocol, falls back to the handshake
+# Negotiate the best mutual protocol
client = Client("https://example.com/mcp")
-# Pins the handshake, when you need the session back-channel
+# Require the handshake-era protocol
legacy = Client("https://example.com/mcp", mode="legacy")
```
-That default is what puts the modern capabilities within reach of ordinary client code: a task-enabled tool hands back a handle to poll, and multi-round-trip elicitation resolves across successive requests, with the caller opting in to neither. Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` read the same whichever era you negotiated, so code that inspects a connection never branches on how it was established. See [Protocol negotiation](/clients/client#protocol-negotiation).
+Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` expose the same interface whichever era was negotiated. Application code that inspects a server does not need a protocol-version branch. See [Protocol negotiation](/clients/client#protocol-negotiation).
-Intermediaries benefit too. On a modern connection, FastMCP's client attaches the method, the target name, and any opted-in argument values as HTTP headers, so a gateway or load balancer can route a request without parsing its JSON-RPC body. See [Gateway Routing Headers](/deployment/http#gateway-routing-headers).
+On modern connections, FastMCP also attaches the method, target name, and opted-in argument values as HTTP headers. Gateways and load balancers can route requests without parsing JSON-RPC bodies. See [Gateway routing headers](/deployment/http#gateway-routing-headers).
-## Server-to-client requests
+## Stateful applications
-A sessionless connection gives the server no channel to push a request down to a connected client mid-execution. Three `Context` methods depended on that channel, and this is the one part of FastMCP 4 likely to break an existing server.
+The modern protocol removes transport-level sessions, but applications still need conversations, user state, and long-running work. FastMCP moves those concerns into explicit application primitives that survive fresh connections. Shared stores and request-state keys extend them across replicas and worker restarts.
-`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are removed. Touching one raises `AttributeError` on every era, so the break surfaces when you upgrade rather than in production against whichever client happens to negotiate the modern protocol.
+### Interactive tools
-For generation, call an LLM directly from your tool: your server holds the API key, creates a provider client, and awaits a completion inline. That works against every client, including the many that never implemented sampling at all, and a tool that chains several generations pays no round trip for any of them. See [Sampling](/servers/sampling).
+Many useful tools need more than one exchange. A booking tool asks for a destination, then a date, then confirmation. A destructive operation asks the user to approve it before continuing.
-When borrowing the *caller's* model is the actual point, or when a tool genuinely needs the client's roots, the tool asks by returning a description of what it needs. The round completes normally, the client answers, and it re-issues the call with the answer attached. `ctx.elicit()` is untouched and still works on handshake connections; on modern connections that same return-and-resume shape covers elicitation as well. See [the guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol).
+On the modern protocol, the tool returns a description of the input it needs. That result completes the request normally. The client fulfils the request and calls the tool again with the answer attached; the tool runs from the top, reads `ctx.input_responses`, and either asks another question or returns its final result.
-Logging and progress are unaffected. Both are notifications, and notifications ride the response stream on every era.
+Each request completes while the user responds. Single-process servers use an automatic process-local key to protect the state carried between rounds; load-balanced deployments configure one shared key so any replica can validate and resume the next round:
-## Session state
+```python
+import os
-If every request arrives on a fresh connection, a tool that wants to remember something between calls has nowhere to keep it. Weighing protocol-level sessions against statelessness, the MCP working group [chose statelessness](https://github.com/modelcontextprotocol/transports-wg/blob/main/docs/sessions-vs-sessionless-decision.md) and moved session semantics up to the application: the server hands out an identifier, and the client passes it back.
+from fastmcp import Context, FastMCP
+from mcp.server.request_state import RequestStateSecurity
+from mcp.types import ElicitRequest, ElicitRequestFormParams, InputRequiredResult
-FastMCP implements that pattern and adds the isolation a bare handle lacks. State is stored server-side and keyed to the authenticated user, so a handle is inert in anyone else's hands.
+mcp = FastMCP(
+ "Booking",
+ request_state_security=RequestStateSecurity(
+ keys=[os.environ["REQUEST_STATE_KEY"].encode()]
+ ),
+)
-Most tools want a single bucket per user. Declare a `UserSession` parameter and FastMCP injects it the way it injects `Context`: it never appears in the tool's input schema, and the caller passes nothing, because the user's identity selects the right bucket.
+
+@mcp.tool
+async def book_flight(ctx: Context) -> str | InputRequiredResult:
+ answers = ctx.input_responses
+ if answers is None:
+ params = ElicitRequestFormParams(
+ message="Where would you like to fly?",
+ requested_schema={
+ "type": "object",
+ "properties": {"destination": {"type": "string"}},
+ "required": ["destination"],
+ },
+ )
+ return InputRequiredResult(
+ result_type="input_required",
+ input_requests={
+ "destination": ElicitRequest(
+ method="elicitation/create",
+ params=params,
+ )
+ },
+ )
+
+ response = answers["destination"]
+ if response.action != "accept" or response.content is None:
+ return "Booking cancelled."
+
+ destination = response.content["destination"]
+ return f"Booked a flight to {destination}."
+```
+
+Every replica must receive the same `REQUEST_STATE_KEY`, containing at least 32 bytes of secret key material. A FastMCP client drives the loop through its existing elicitation handler, so client code receives the terminal result without managing the intermediate rounds. See [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol).
+
+### Session state
+
+Application state follows the same explicit model. FastMCP stores state server-side and binds it to the authenticated user, so a session handle is inert in another user's hands.
+
+Most tools want one state bucket per user. Declare a `UserSession` parameter and FastMCP injects it like `Context`: it never appears in the tool schema, and the caller passes nothing because their authenticated identity selects the bucket.
```python
from fastmcp import FastMCP
from fastmcp.server.sessions import UserSession
-mcp = FastMCP("assistant")
+mcp = FastMCP("Assistant")
@mcp.tool
@@ -70,18 +117,19 @@ async def remember(fact: str, session: UserSession) -> str:
return f"Remembered {len(facts)} facts."
```
-Because the bucket is chosen from the caller's identity, `UserSession` requires [authentication](/servers/auth/authentication). An unauthenticated request has no user to key on, so the tool raises rather than guessing at a bucket.
+`UserSession` requires [authentication](/servers/auth/authentication), since an unauthenticated request has no user to key on. When one user needs several independent buckets, such as separate carts or conversations, `SessionId` exposes the handle as an explicit string argument.
-When one user needs several independent buckets, such as separate carts or parallel conversations, `SessionId` makes the handle an explicit string argument that the agent obtains from `create_session` and supplies on each call. See [Session State](/servers/sessions).
+The default in-memory state store is process-local. To preserve state across restarts or share it among replicas, pass a shared persistent `session_state_store`. See [Session state](/servers/sessions).
-## Background tasks
+### Background work
-Long-running work runs as a background task: the server accepts the call and returns a handle immediately, and the client polls for the result while the work proceeds. Tasks left the core MCP spec during the SDK rewrite and returned as the `io.modelcontextprotocol/tasks` extension, which FastMCP implements end to end in the optional `fastmcp-tasks` package.
+Long-running tools create a different kind of state problem: holding a request open for several minutes invites timeouts and leaves the user unable to tell whether work is progressing. Background tasks accept the call and return a handle immediately, then let the client poll while work proceeds asynchronously.
-`@mcp.tool(task=True)` remains the authoring surface and [Docket](https://github.com/chrisguidry/docket) still provides the durable execution engine, so the wire protocol modernizing underneath costs you no code change. What's new is the registration: tasks arrive as an extension you add to the server.
+FastMCP implements the `io.modelcontextprotocol/tasks` extension in the optional `fastmcp-tasks` package. The authoring API remains `@mcp.tool(task=True)`, backed by [Docket](https://github.com/chrisguidry/docket):
```python
import asyncio
+
from fastmcp import FastMCP
from fastmcp_tasks import TasksExtension
@@ -91,24 +139,28 @@ mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def slow_computation(duration: int) -> str:
- """A long-running operation."""
+ """Run a long computation."""
await asyncio.sleep(duration)
return f"Completed in {duration} seconds"
```
-A FastMCP client handles the handle-and-poll cycle transparently, so `client.call_tool(...)` looks the same whether or not the call ran in the background. See [Background Tasks](/servers/tasks).
+`fastmcp.Client` handles the task handle and polling cycle, so `client.call_tool(...)` returns the same way whether the tool ran inline or in the background. See [Background tasks](/servers/tasks).
-## Server extensions
+`TasksExtension()` uses an in-memory, single-process backend by default. Configure a Redis or Valkey backend for durable work that survives restarts and runs across separate workers.
-Background tasks are the first capability built on a more general one. An MCP extension is a protocol feature named by a reverse-DNS string and negotiated as a capability, and FastMCP 4 makes extensions a first-class surface rather than something only the framework can add.
+## Extensible protocol
-`FastMCP.add_extension()` lets an extension advertise a capability, add request methods, intercept `tools/call`, and run a lifespan hook, all with full access to the component registry, `Context`, and auth. The same extensions flow through the client with `Client(extensions=...)`. A cross-cutting protocol feature becomes a supported plugin instead of surgery on core, and `TasksExtension` is the worked example of everything the interface allows. See [Server Extensions](/servers/extensions).
+Background tasks are built on a general extension surface. An MCP extension advertises a capability under a reverse-DNS identifier and can add behavior negotiated between a server and client.
-## Argument completion
+### Server extensions
-When a client offers autocomplete for a prompt argument or a resource-template parameter, it asks the server which values fit, narrowing the list as the user types. FastMCP 4 lets a server answer. A single `@mcp.completion` handler receives the reference being completed, the argument and its partial value, and the arguments the user has already supplied, and returns the candidates the client surfaces as suggestions.
+`FastMCP.add_extension()` lets an extension advertise capabilities, add request methods, intercept `tools/call`, and own lifespan behavior with access to the component registry, `Context`, and authentication. Client extensions use the matching `Client(extensions=...)` interface.
-Because the handler sees the earlier arguments, completions can depend on them: a `repo` parameter can suggest only the repositories under the `owner` already chosen.
+Cross-cutting protocol behavior can therefore live in a supported plugin instead of requiring changes to FastMCP core. `TasksExtension` is a complete example of the interface. See [Server extensions](/servers/extensions).
+
+### Argument completion
+
+FastMCP 4 also lets servers answer MCP argument-completion requests. A completion handler sees the prompt or resource-template argument, its partial value, and values already supplied, so suggestions can depend on earlier choices.
```python
from fastmcp import FastMCP
@@ -126,74 +178,40 @@ def write_poem(theme: str) -> str:
def complete(ref, argument, context):
if isinstance(ref, PromptReference) and argument.name == "theme":
options = ["nature", "love", "adventure"]
- return [o for o in options if o.startswith(argument.value)]
+ return [option for option in options if option.startswith(argument.value)]
return None
```
-Registering a handler advertises the completions capability during negotiation, so a client only sends requests to a server that answers them, identically on both protocol eras. See [Argument Completion](/servers/completions).
+Registering the handler advertises the completion capability during negotiation, so clients only send requests to servers that support them. See [Argument completion](/servers/completions).
## Enterprise identity
-FastMCP 4 ships a complete server-side implementation of identity assertion, the enterprise "on-behalf-of" flow: a corporate identity provider issues a signed assertion, the user's agent presents it, and the server mints a short-lived token, with no browser login and no per-user consent screen. Behind one parameter on the existing auth providers, FastMCP performs the signature verification, binding checks, replay rejection, and scoped token issuance.
+Interactive OAuth authorization assumes a person can complete a browser flow. Internal agents often act for employees without a person waiting at a keyboard, while the server still needs the employee's identity for authorization and audit.
+
+Identity assertion carries that identity through the agent. A corporate identity provider signs an assertion, the agent presents it, and the server exchanges it for a short-lived token without an interactive login or consent screen. FastMCP performs signature verification, binding checks, replay rejection, and scoped token issuance through the authentication providers you already use.
```python
from fastmcp import FastMCP
from fastmcp.server.auth import IdentityAssertion, OAuthProxy
auth = OAuthProxy(
- # existing upstream configuration unchanged
- identity_assertion=IdentityAssertion(trusted_issuers=["https://login.acme-corp.com"]),
+ # Existing upstream configuration
+ identity_assertion=IdentityAssertion(
+ trusted_issuers=["https://login.acme-corp.com"]
+ ),
)
mcp = FastMCP("Internal API", auth=auth)
```
-The asserted subject flows into the normal auth context, so tools read it through `get_access_token()` like any other identity. See [Identity Assertion](/servers/auth/oauth-proxy#identity-assertion-sep-990).
+The asserted subject enters the normal authentication context, so tools read it through `get_access_token()` like any other identity. See [Identity assertion](/servers/auth/oauth-proxy#identity-assertion-sep-990).
-Authorizing a caller by role is a related, provider-agnostic need. Scopes are standardized, so `require_scopes` behaves the same everywhere, but roles and groups are not part of OIDC and every provider files them under a different claim. `require_roles` handles the comparison and takes an `extract` callable naming where to look, so Keycloak's `realm_access.roles`, Cognito's `cognito:groups`, and Auth0's namespaced claims all work without FastMCP guessing.
+Authorization gained a provider-neutral role check as well. `require_roles` accepts an extraction function for providers that store roles and groups under different claims, while [scope step-up challenges](/servers/authorization#signaling-scope-shortfalls) tell a client exactly which scopes to request.
-```python
-from fastmcp import FastMCP
-from fastmcp.server.auth import require_roles
+For clients with no user behind them, such as backend services and scheduled jobs, `ClientCredentialsOAuthProvider` implements the OAuth 2.0 client-credentials grant with no browser or redirect. See [Machine-to-machine authentication](/clients/auth/client-credentials).
-mcp = FastMCP("Internal API")
+## Production defaults
-
-@mcp.tool(auth=require_roles("admin", extract=lambda c: c["realm_access"]["roles"]))
-def rotate_credentials() -> str:
- """Only callable by a caller holding the 'admin' role."""
- return "Rotated"
-```
-
-That example shows the check in isolation. Enforcing it for real needs an HTTP-transport server with a token-validating `auth` provider configured, since STDIO has no OAuth concept and skips every check. A `JWTVerifier`, a `RemoteAuthProvider`, or any provider built on one such as `KeycloakAuthProvider` all expose claims directly. See [Authorization](/servers/authorization#require_roles).
-
-The client side arrived too. Plenty of FastMCP clients have no user behind them, such as a backend service, a scheduled job, or one MCP server calling another. `ClientCredentialsOAuthProvider` authenticates one of those to a protected server with the OAuth 2.0 client-credentials grant: no browser, no redirect, no consent screen.
-
-```python
-import asyncio
-
-from fastmcp import Client
-from fastmcp.client.auth import ClientCredentialsOAuthProvider
-
-auth = ClientCredentialsOAuthProvider(
- client_id="my-client-id",
- client_secret="my-client-secret",
- scopes=["read", "write"],
-)
-
-
-async def main():
- async with Client("https://example.com/mcp", auth=auth) as client:
- await client.list_tools()
-
-
-asyncio.run(main())
-```
-
-See [Machine-to-Machine Authentication](/clients/auth/client-credentials).
-
-## Response caching
-
-A server can stamp freshness hints on its results, and a caching client reuses a result within that window instead of making the round trip. Set the defaults on the server and every response carries them.
+A server can now attach freshness hints to its results, and a caching client can reuse those results without another round trip. Set a default time-to-live and scope on the server:
```python
from fastmcp import FastMCP
@@ -201,10 +219,18 @@ from fastmcp import FastMCP
mcp = FastMCP("Weather", cache_ttl=300, cache_scope="public")
```
-Backing the client's cache with the distributed `KeyValueResponseCacheStore` puts it in Redis or any key-value store, so a fleet of clients or proxy replicas shares fills rather than each paying for its own. See [Response caching](/clients/client#response-caching).
+`KeyValueResponseCacheStore` can place the client cache in Redis or another key-value store so a fleet of clients or proxies shares fills. See [Response caching](/clients/client#response-caching).
-## Security defaults
+Resource templates now reject path traversal, absolute paths, and null bytes in their parameters before the handler runs. The protection is enabled by default and applies to mounted and proxied templates. See [Path security](/servers/resources#path-security).
-Templated resources now screen their parameters for path traversal, absolute paths, and null bytes before the handler runs. This is on by default and covers mounted and proxied templates too, so a template that interpolates a parameter into a filesystem path no longer has to validate it by hand. See [path security](/servers/resources#path-security).
+OAuth defaults also distinguish native clients from web applications during Dynamic Client Registration, and missing scopes now produce an `InsufficientScopeError` that names the scopes required to continue. See [Application type](/servers/auth/oauth-proxy#application-type-web-vs-native) and [scope shortfalls](/servers/authorization#signaling-scope-shortfalls).
-The OAuth flow got more precise in two places. Dynamic Client Registration honors a client's declared `application_type`: the permissive loopback and app-scheme callbacks that MCP clients rely on stay the default for `"native"`, while a client registering as `"web"` is held to stricter browser-app redirect rules. And when `AuthMiddleware` denies a call specifically for a missing scope, it raises `InsufficientScopeError` naming which scopes would fix it, so a caller re-authorizes precisely instead of retrying blind. See [Application Type](/servers/auth/oauth-proxy#application-type-web-vs-native) and [Signaling Scope Shortfalls](/servers/authorization#signaling-scope-shortfalls).
+## Upgrade note
+
+The sessionless protocol has no live connection for a server to call back into during execution. FastMCP 4 therefore removes `ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` from every protocol era so incompatible code fails immediately during an upgrade.
+
+For generation, call an LLM directly from the server when your application owns the model. When borrowing the caller's model is the point, return an `InputRequiredResult` carrying a sampling request and read the answer on the next round. Roots use the same return-and-resume pattern. See [Sampling](/servers/sampling) and [the guard pattern](/servers/elicitation#sampling-and-roots).
+
+`ctx.elicit()` remains available on handshake-era connections; modern connections use the multi-round pattern described above. Code that constructs MCP protocol models directly must also use snake_case Python field names with SDK v2.
+
+[Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) covers these changes and every other compatibility break.
diff --git a/docs/integrations/anthropic.mdx b/docs/integrations/anthropic.mdx
index 08b9b2c9c..6fb8841e1 100644
--- a/docs/integrations/anthropic.mdx
+++ b/docs/integrations/anthropic.mdx
@@ -69,9 +69,11 @@ You'll also need to authenticate with Anthropic. You can do this by setting the
export ANTHROPIC_API_KEY="your-api-key"
```
-Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment. **At this time you must also include the `extra_headers` parameter with the `anthropic-beta` header.**
+Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment.
-```python {5, 13-22}
+The connector is in beta, so the call goes through `client.beta.messages` with the `mcp-client-2025-11-20` flag. Each entry in `mcp_servers` also needs a matching `mcp_toolset` entry in `tools` that references it by name; declaring the server without the toolset is rejected as a validation error.
+
+```python {5, 14-23}
import anthropic
from rich import print
@@ -81,8 +83,9 @@ url = 'https://your-server-url.com'
client = anthropic.Anthropic()
response = client.beta.messages.create(
- model="claude-sonnet-4-20250514",
+ model="claude-sonnet-5",
max_tokens=1000,
+ betas=["mcp-client-2025-11-20"],
messages=[{"role": "user", "content": "Roll a few dice!"}],
mcp_servers=[
{
@@ -91,9 +94,7 @@ response = client.beta.messages.create(
"name": "dice-server",
}
],
- extra_headers={
- "anthropic-beta": "mcp-client-2025-04-04"
- }
+ tools=[{"type": "mcp_toolset", "mcp_server_name": "dice-server"}],
)
print(response.content)
@@ -193,7 +194,7 @@ Error code: 400 - {
To authenticate the client, you can pass the token using the `authorization_token` parameter in your MCP server configuration:
-```python {8, 21}
+```python {8, 22}
import anthropic
from rich import print
@@ -206,8 +207,9 @@ access_token = 'your-access-token'
client = anthropic.Anthropic()
response = client.beta.messages.create(
- model="claude-sonnet-4-20250514",
+ model="claude-sonnet-5",
max_tokens=1000,
+ betas=["mcp-client-2025-11-20"],
messages=[{"role": "user", "content": "Roll a few dice!"}],
mcp_servers=[
{
@@ -217,9 +219,7 @@ response = client.beta.messages.create(
"authorization_token": access_token
}
],
- extra_headers={
- "anthropic-beta": "mcp-client-2025-04-04"
- }
+ tools=[{"type": "mcp_toolset", "mcp_server_name": "dice-server"}],
)
print(response.content)
diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx
index d493eb1ef..d1a2a3608 100644
--- a/docs/integrations/github.mdx
+++ b/docs/integrations/github.mdx
@@ -69,7 +69,7 @@ from fastmcp.server.auth.providers.github import GitHubProvider
# The GitHubProvider handles GitHub's token format and validation
auth_provider = GitHubProvider(
client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID
- client_secret="github_pat_...", # Your GitHub OAuth App Client Secret
+ client_secret="your-github-client-secret", # Your GitHub OAuth App Client Secret
base_url="http://localhost:8000", # Must match your OAuth App configuration
# redirect_path="/auth/callback" # Default value, customize if needed
)
@@ -151,7 +151,7 @@ from cryptography.fernet import Fernet
# Production setup with encrypted persistent token storage
auth_provider = GitHubProvider(
client_id="Ov23liAbcDefGhiJkLmN",
- client_secret="github_pat_...",
+ client_secret="your-github-client-secret",
base_url="https://your-production-domain.com",
# Production token management
diff --git a/docs/integrations/mcp-json-configuration.mdx b/docs/integrations/mcp-json-configuration.mdx
index fec8ffc01..b516c9954 100644
--- a/docs/integrations/mcp-json-configuration.mdx
+++ b/docs/integrations/mcp-json-configuration.mdx
@@ -70,7 +70,7 @@ An object containing environment variables to set when launching the server. All
This format is widely adopted across the MCP ecosystem:
-- **Claude Desktop**: Uses `~/.claude/claude_desktop_config.json`
+- **Claude Desktop**: Uses `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows
- **Cursor**: Uses `~/.cursor/mcp.json`
- **VS Code**: Uses workspace `.vscode/mcp.json`
- **Other clients**: Many MCP-compatible applications follow this standard
@@ -457,7 +457,7 @@ The generated configuration works with any MCP-compatible application:
**Prefer [`fastmcp install claude-desktop`](/integrations/claude-desktop)** for automatic installation. Use MCP JSON for advanced configuration needs.
-Copy the `mcpServers` object into `~/.claude/claude_desktop_config.json`
+Copy the `mcpServers` object into Claude Desktop's config file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows)
### Cursor
diff --git a/docs/integrations/permit.mdx b/docs/integrations/permit.mdx
index ddda7cd2c..66b8c896e 100644
--- a/docs/integrations/permit.mdx
+++ b/docs/integrations/permit.mdx
@@ -300,10 +300,12 @@ For advanced configuration options and custom middleware extensions, see [Advanc
See the [example server](https://github.com/permitio/permit-fastmcp/blob/main/permit_fastmcp/example_server/example.py) for a full implementation with JWT-based authentication. For additional examples and usage patterns, see [Example Server](https://github.com/permitio/permit-fastmcp/blob/main/permit_fastmcp/example_server/):
```python
+import os
+import datetime
+
+import jwt
from fastmcp import FastMCP, Context
from permit_fastmcp.middleware.middleware import PermitMcpMiddleware
-import jwt
-import datetime
# Configure JWT identity extraction
os.environ["PERMIT_MCP_IDENTITY_MODE"] = "jwt"
diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx
index 5cc0f633a..dd4751350 100644
--- a/docs/servers/auth/oauth-proxy.mdx
+++ b/docs/servers/auth/oauth-proxy.mdx
@@ -283,10 +283,11 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
- Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF.
+ Secret used to sign FastMCP JWT tokens issued to clients. How the key is derived depends on what you pass:
- **Default behavior (`None`):**
- Derives a 32-byte key using PBKDF2 from the upstream client secret.
+ - **`bytes`** are used as-is, with no stretching, so supply at least 32 bytes of high-entropy key material. With the default file-backed client storage, the bytes must also decode as UTF-8; use `secrets.token_urlsafe(32).encode()` instead of raw `secrets.token_bytes()`, or configure `client_storage` explicitly.
+ - **A string** is stretched into a 32-byte key with PBKDF2 (1,000,000 iterations), since a supplied string may be low-entropy. Strings shorter than 12 characters also log a warning.
+ - **`None`** (the default) derives a 32-byte key from the upstream client secret using HKDF.
**For production:**
Provide an explicit secret (e.g., from environment variable) to use a fixed key instead of the key derived from the upstream client secret. This allows you to manage keys securely in cloud environments, allows keys to work across multiple instances, and allows you to rotate keys without losing client registrations.
diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx
index 763963858..006efc8d6 100644
--- a/docs/servers/auth/oidc-proxy.mdx
+++ b/docs/servers/auth/oidc-proxy.mdx
@@ -155,7 +155,7 @@ Set this if your provider requires a specific authentication method and the defa
- Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF.
+ Secret used to sign FastMCP JWT tokens issued to clients. **`bytes`** are used as-is, with no stretching, so supply at least 32 bytes of high-entropy key material. With the default file-backed client storage, the bytes must also decode as UTF-8; use `secrets.token_urlsafe(32).encode()` instead of raw `secrets.token_bytes()`, or configure `client_storage` explicitly. **A string** is stretched into a 32-byte key with PBKDF2 (1,000,000 iterations), since a supplied string may be low-entropy.
**Default behavior (`None`):**
The key is deterministically derived from `client_secret` using HKDF, on every platform. Because the derivation is deterministic, the same key is produced across restarts as long as `client_secret` doesn't change, so tokens remain valid without any extra configuration. This convenience makes it **only** suitable for development and local testing.
diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx
index 957cad053..e2fd14b98 100644
--- a/docs/servers/auth/remote-oauth.mdx
+++ b/docs/servers/auth/remote-oauth.mdx
@@ -116,8 +116,6 @@ auth = RemoteAuthProvider(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")],
base_url="https://api.yourcompany.com", # Your server base URL
- # Optional: restrict allowed client redirect URIs
- allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"]
)
mcp = FastMCP(name="Company API", auth=auth)
@@ -216,13 +214,7 @@ WorkOS's support for Dynamic Client Registration makes it particularly well-suit
## Client Redirect URI Security
-`RemoteAuthProvider` also supports the `allowed_client_redirect_uris` parameter for controlling which redirect URIs are accepted from MCP clients during DCR:
-
-- `None` (default): Broad DCR-compatible redirect support, while rejecting unsafe browser schemes such as `javascript:`, `data:`, `file:`, and `vbscript:`
-- Custom list: Specify allowed patterns with wildcard support
-- Empty list `[]`: No redirect URIs allowed
-
-This provides defense-in-depth even though DCR providers typically validate redirect URIs themselves.
+Redirect URIs are validated by the DCR provider itself, since it owns the registration flow. To constrain them from the FastMCP side, use [`OAuthProxy`](/servers/auth/oauth-proxy), whose `allowed_client_redirect_uris` parameter accepts a list of allowed patterns with wildcard support.
## Implementation Considerations
diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx
index af7d17e42..dd841f00d 100644
--- a/docs/servers/middleware.mdx
+++ b/docs/servers/middleware.mdx
@@ -378,7 +378,7 @@ mcp.add_middleware(LoggingMiddleware(
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `include_payloads` | `bool` | `False` | Log request/response content |
-| `max_payload_length` | `int` | `500` | Truncate payloads beyond this length |
+| `max_payload_length` | `int` | `1000` | Truncate payloads beyond this length |
| `logger` | `Logger` | module logger | Custom logger instance |
### Timing
@@ -533,7 +533,7 @@ mcp.add_middleware(ErrorHandlingMiddleware(
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `include_traceback` | `bool` | `False` | Include stack traces in logs |
-| `transform_errors` | `bool` | `False` | Convert exceptions to MCP errors |
+| `transform_errors` | `bool` | `True` | Convert exceptions to MCP errors |
| `error_callback` | `Callable` | `None` | Custom callback on errors |
For automatic retries:
diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx
index ae7172522..1986a50bf 100644
--- a/docs/servers/prompts.mdx
+++ b/docs/servers/prompts.mdx
@@ -448,14 +448,14 @@ A prompt can ask the client for information before it renders. On an MCP 2026-07
-You can configure how the FastMCP server handles attempts to register multiple prompts with the same name. Use the `on_duplicate_prompts` setting during `FastMCP` initialization.
+You can configure how the FastMCP server handles attempts to register the same prompt twice. Identity is the component's type, name, and version together, so a prompt may share a name with a tool, and two versions of one prompt coexist. The `on_duplicate` setting covers every component type, so it applies to prompts alongside tools and resources.
```python
from fastmcp import FastMCP
mcp = FastMCP(
name="PromptServer",
- on_duplicate_prompts="error" # Raise an error if a prompt name is duplicated
+ on_duplicate="error" # Raise an error on an exact duplicate
)
@mcp.prompt
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index e0daf6e76..13a5d986a 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -78,7 +78,7 @@ mcp = FastMCP(name="DataServer")
)
def get_application_status() -> str:
"""Internal function description (ignored if description is provided above)."""
- return json.dumps({"status": "ok", "uptime": 12345, "version": mcp.settings.version})
+ return json.dumps({"status": "ok", "uptime": 12345, "version": "2.1"})
```
@@ -793,14 +793,14 @@ A resource or resource template can ask the client for information before it pro
-You can configure how the FastMCP server handles attempts to register multiple resources or templates with the same URI. Use the `on_duplicate_resources` setting during `FastMCP` initialization.
+You can configure how the FastMCP server handles attempts to register the same resource or template twice. Identity is the component's type, URI, and version together, so two versions of one resource coexist and only an exact repeat collides. The `on_duplicate` setting covers every component type, so it applies to resources and templates alongside tools and prompts.
```python
from fastmcp import FastMCP
mcp = FastMCP(
name="ResourceServer",
- on_duplicate_resources="error" # Raise error on duplicates
+ on_duplicate="error" # Raise an error on an exact duplicate
)
@mcp.resource("data://config")
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index e87ebedd4..4a9c4918d 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -1081,22 +1081,22 @@ For full documentation on the Context object and all its capabilities, see the [
-You can control how the FastMCP server behaves if you try to register multiple tools with the same name. This is configured using the `on_duplicate_tools` argument when creating the `FastMCP` instance.
+You can control how the FastMCP server behaves if you register the same component twice. Identity is the component's type, name, and version together, so a tool and a prompt may share a name, and two versions of one tool coexist. Only an exact repeat of all three counts as a duplicate. The `on_duplicate` argument sets that policy once for every component type.
```python
from fastmcp import FastMCP
mcp = FastMCP(
name="StrictServer",
- # Configure behavior for duplicate tool names
- on_duplicate_tools="error"
+ # Configure behavior for exact component duplicates
+ on_duplicate="error"
)
@mcp.tool
def my_tool(): return "Version 1"
# This will now raise a ValueError because 'my_tool' already exists
-# and on_duplicate_tools is set to "error".
+# and on_duplicate is set to "error".
# @mcp.tool
# def my_tool(): return "Version 2"
```
diff --git a/docs/tutorials/mcp.mdx b/docs/tutorials/mcp.mdx
index fd3995fff..34b1c86c2 100644
--- a/docs/tutorials/mcp.mdx
+++ b/docs/tutorials/mcp.mdx
@@ -21,7 +21,7 @@ The answer lies in **standardization**. The AI ecosystem is fragmented. Every mo
1. **Interoperability:** Build one MCP server, and it can be used by any MCP-compliant client (Claude, Gemini, OpenAI, custom agents, etc.) without custom integration code. This is the protocol's most important promise.
2. **Discoverability:** Clients can dynamically ask a server what it's capable of at runtime. They receive a structured, machine-readable "menu" of tools and resources.
-3. **Security & Safety:** MCP provides a clear, sandboxed boundary. An LLM can't execute arbitrary code on your server; it can only *request* to run the specific, typed, and validated functions you explicitly expose.
+3. **Explicit boundaries:** MCP gives hosts and servers a typed inventory of the capabilities they expose. That creates a clear place to apply authorization, user confirmation, input validation, and sandboxing; the protocol defines the interface, while your application supplies those security policies.
4. **Composability:** You can build small, specialized MCP servers and combine them to create powerful, complex applications.
## Core MCP Components
@@ -111,10 +111,6 @@ def summarize_text(text_to_summarize: str) -> str:
## Advanced Capabilities
-Beyond the core components, MCP also supports more advanced interaction patterns, such as a server requesting that the *client's* LLM generate a completion (known as **sampling**), or a server sending asynchronous **notifications** to a client. These features enable more complex, bidirectional workflows and are fully supported by FastMCP.
+Beyond tools, resources, and prompts, MCP supports richer interaction patterns such as notifications, progress updates, user elicitation, and argument completion. Extensions add capabilities such as durable background tasks.
-## Next Steps
-
-Now that you understand the core concepts of the Model Context Protocol, you're ready to start building. The best place to begin is our step-by-step tutorial.
-
-[**Tutorial: How to Create an MCP Server in Python โ**](/tutorials/create-mcp-server)
+FastMCP exposes these patterns through typed Python APIs. For example, [elicitation](/servers/elicitation) lets tools request missing information or confirmation, while [background tasks](/servers/tasks) let long-running work continue after the original request returns.
diff --git a/docs/updates.mdx b/docs/updates.mdx
index bb66069e9..e930cb36c 100644
--- a/docs/updates.mdx
+++ b/docs/updates.mdx
@@ -11,11 +11,13 @@ title="FastMCP v4.0.0b1: Fourgone Conclusion"
href="https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1"
cta="Read the release notes"
>
-FastMCP 4 rebuilds the framework on the MCP Python SDK v2, and this beta is the first release to run on the SDK's stable 2.0. The engine underneath changed completely, but FastMCP absorbs nearly all of it โ most FastMCP 3 servers run untouched.
+FastMCP 4 makes stateful MCP applications work on the sessionless protocol while one deployment continues serving handshake-era clients. The engine underneath changed completely, but FastMCP absorbs nearly all of it โ most FastMCP 3 servers upgrade untouched.
๐ **Every protocol era** โ one server answers both the sessionless `2026-07-28` protocol and the older session-based handshake, negotiated per connection.
-๐พ **State without a session** โ `UserSession` and `SessionId` give tools durable state on a protocol that deliberately has none, keyed per user when the request is authenticated.
+๐ฌ **Interactive tools** โ tools ask follow-up questions across complete request-response rounds, with shared request-state keys for load balancing and worker restarts.
+
+๐พ **State without a session** โ `UserSession` and `SessionId` give tools explicit server-side state on a protocol that deliberately has none, keyed per user when the request is authenticated.
โณ **Background tasks** โ the `io.modelcontextprotocol/tasks` extension in the new `fastmcp-tasks` package, on the same Docket engine FastMCP 3 used.
diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
index 422ed3b49..2ca7fa311 100644
--- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
+++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
@@ -386,7 +386,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
If None, an encrypted file store will be created in the data directory.
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes).
If bytes are provided, they will be used as-is.
- If a string is provided, it will be derived into a 32-byte key using PBKDF2 (1.2M iterations).
+ If a string is provided, it will be derived into a 32-byte key using PBKDF2 (1,000,000 iterations).
If not provided, it will be derived from the upstream client secret using HKDF.
require_authorization_consent: Consent screen behavior (default True).
- True: always show the consent screen before redirecting to the
From a22f778dbf8303fda4315fbb512373d959454961 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Wed, 29 Jul 2026 09:51:22 -0400
Subject: [PATCH 06/32] Rewrite the FastMCP docs welcome page (#4709)
---
README.md | 15 ++--
docs/deployment/prefect-horizon.mdx | 2 +-
docs/docs.json | 2 +-
docs/getting-started/welcome.mdx | 104 ++++++++++++-------------
docs/v3/deployment/prefect-horizon.mdx | 2 +-
5 files changed, 60 insertions(+), 65 deletions(-)
diff --git a/README.md b/README.md
index 5d312c0b3..920996af9 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@
---
-The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production:
+The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP is a full MCP application framework for servers, clients, and interactive apps. A server starts with ordinary Python:
```python
from fastmcp import FastMCP
@@ -82,11 +82,11 @@ FastMCP has three pillars:
Ready to build? Start with the [installation guide](https://gofastmcp.com/getting-started/installation) or jump straight to the [quickstart](https://gofastmcp.com/getting-started/quickstart).
-## Run FastMCP in production with Horizon
+## Scale MCP with Horizon
-FastMCP is the standard way to build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=github&utm_medium=readme&utm_campaign=readme_horizon&utm_content=readme_body)** is the enterprise MCP gateway for running them safely.
+FastMCP handles the MCP application layer. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=github&utm_medium=readme&utm_campaign=readme_horizon&utm_content=readme_body)** is the enterprise MCP gateway for scaling servers and tools across teams, with centralized governance over how they are deployed, discovered, secured, and used.
-Built by the FastMCP team, Horizon packages the best practices we've learned shipping the world's most popular MCP framework.
+FastMCP and Horizon are built by the same team at [Prefect](https://www.prefect.io/).
Deploy FastMCP servers from GitHub with branch previews and instant rollback. Create a private registry of every MCP your company uses. Secure access with SSO and tool-level RBAC. Get audit logs, observability, and governance across your MCP stack. Remix approved tools into purpose-built endpoints for teams and agents.
@@ -94,10 +94,10 @@ Start with FastMCP. [Scale with Horizon โ](https://www.prefect.io/horizon?utm_
## Installation
-We recommend installing FastMCP with [uv](https://docs.astral.sh/uv/):
+We recommend adding FastMCP to your project with [uv](https://docs.astral.sh/uv/):
```bash
-uv pip install fastmcp
+uv add fastmcp
```
For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation).
@@ -108,9 +108,6 @@ For full installation instructions, including verification and upgrading, see th
- [Upgrading from MCP SDK v1](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2)
- [Upgrading from the low-level SDK v1](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2)
-> [!NOTE]
-> If `import fastmcp` fails right after a `pip` upgrade from FastMCP 3.2 or earlier, run `pip install --force-reinstall fastmcp`. See [Troubleshooting](https://gofastmcp.com/getting-started/installation#troubleshooting) for why this happens (`uv` is unaffected).
-
## ๐ Documentation
FastMCP's complete documentation is available at **[gofastmcp.com](https://gofastmcp.com)**, including detailed guides, API references, and advanced patterns.
diff --git a/docs/deployment/prefect-horizon.mdx b/docs/deployment/prefect-horizon.mdx
index b22644181..68f157c52 100644
--- a/docs/deployment/prefect-horizon.mdx
+++ b/docs/deployment/prefect-horizon.mdx
@@ -5,7 +5,7 @@ description: The MCP platform from the FastMCP team
icon: cloud
---
-[Prefect Horizon](https://www.prefect.io/horizon) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities.
+[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_horizon&utm_content=guide_intro) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities.
Horizon includes a **free personal tier for FastMCP users**, making it the fastest way to get a secure, production-ready server URL with built-in OAuth authentication.
diff --git a/docs/docs.json b/docs/docs.json
index a3490a88a..c52daada0 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -67,7 +67,7 @@
"label": ""
},
{
- "href": "https://prefect.io/horizon",
+ "href": "https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_horizon&utm_content=header",
"icon": "cloud",
"label": "Prefect Horizon"
}
diff --git a/docs/getting-started/welcome.mdx b/docs/getting-started/welcome.mdx
index 1e3fe2a9d..5c13e6bdf 100644
--- a/docs/getting-started/welcome.mdx
+++ b/docs/getting-started/welcome.mdx
@@ -1,26 +1,11 @@
---
-title: "Welcome to FastMCP"
+title: "FastMCP: The Framework for MCP"
sidebarTitle: "Welcome!"
-description: The fast, Pythonic way to build MCP servers, clients, and applications.
+description: FastMCP is the standard framework for building Model Context Protocol (MCP) servers, clients, and interactive applications.
icon: hand-wave
mode: center
---
-{/*
-
-
-
- */}
+**FastMCP is a full framework for building [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) applications.** It gives you one coherent API for servers, clients, and interactive apps. Use it to expose Python functions as MCP tools, connect to local or remote MCP servers, and return interactive interfaces directly from your tools. FastMCP manages schema generation, validation, transport, authentication, and protocol compatibility around your application code.
-**FastMCP is the standard framework for building MCP applications.** The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production โ build servers that expose capabilities, connect clients to any MCP service, and give your tools interactive UIs:
+A FastMCP server starts with ordinary Python:
```python {1}
from fastmcp import FastMCP
mcp = FastMCP("Demo ๐")
+
@mcp.tool
def add(a: int, b: int) -> int:
- """Add two numbers"""
+ """Add two numbers."""
return a + b
+
if __name__ == "__main__":
mcp.run()
```
+## Move fast and make things
-## Move Fast and Make Things
+An effective MCP application needs more than a function registry. Models need accurate schemas, callers need validated results, clients need compatible transports, and production servers need authentication and predictable lifecycle management.
-The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) lets you give agents access to your tools and data. But building an effective MCP application is harder than it looks.
+FastMCP treats those as framework responsibilities. Declare a Python function and FastMCP derives its schema, validates its inputs and outputs, and exposes it through MCP. Connect a client to a URL and FastMCP handles protocol negotiation, authentication, and connection lifecycle. Your application remains ordinary Python while FastMCP keeps the MCP boundary correct.
-FastMCP handles all of it. Declare a tool with a Python function, and the schema, validation, and documentation are generated automatically. Connect to a server with a URL, and transport negotiation, authentication, and protocol lifecycle are managed for you. You focus on your logic, and the MCP part just works: **with FastMCP, best practices are built in.**
+**That's why FastMCP is the standard framework for working with MCP.** FastMCP created the high-level Python API incorporated into the official MCP Python SDK in 2024. The actively maintained standalone project is now downloaded more than a million times a day, and some version of FastMCP powers 70% of MCP servers across all languages.
-**That's why FastMCP is the standard framework for working with MCP.** FastMCP 1.0 was incorporated into the official MCP Python SDK in 2024. Today, the actively maintained standalone project is downloaded a million times a day, and some version of FastMCP powers 70% of MCP servers across all languages.
+## Servers, clients, and apps
-FastMCP has three pillars:
+FastMCP covers the full MCP application lifecycle through three complementary pillars:
- Expose tools, resources, and prompts to LLMs.
+ Expose Python functions, data, and instructions as MCP tools, resources, and prompts.
- Give your tools interactive UIs rendered directly in the conversation.
+ Give MCP tools interactive user interfaces rendered directly in the conversation.
- Connect to any MCP server โ local or remote, programmatic or CLI.
+ Connect to any MCP server through Python, the command line, or another MCP application.
-**[Servers](/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](/clients/client)** connect to any server with full protocol support. And **[Apps](/apps/overview)** give your tools interactive UIs rendered directly in the conversation.
+**[Servers](/servers/server)** turn your application logic into MCP capabilities with generated schemas and validation. **[Clients](/clients/client)** connect to local or remote MCP servers with full protocol support. **[Apps](/apps/overview)** let tools return forms, tables, charts, and other interactive interfaces alongside ordinary MCP results.
-**Building in TypeScript?** [FastMCP for TypeScript](https://github.com/PrefectHQ/fastmcp-ts) is the official counterpart, built and maintained by the same team. The three pillars work the same way there, so what you learn here carries over.
+The three pillars share one model: FastMCP owns the protocol machinery while your code defines what the application does.
-Ready to build? Start with the [installation guide](/getting-started/installation) or jump straight to the [quickstart](/getting-started/quickstart).
+**Building in TypeScript?** [FastMCP for TypeScript](https://github.com/PrefectHQ/fastmcp-ts) is the official counterpart, built and maintained by the same team. Its servers, clients, and apps follow the same concepts, so what you learn here carries over.
+
+
+
+ Add FastMCP to your project with `uv add fastmcp`, verify the package, and find the right upgrade guide.
+
+
+ Create a tool, run its server, call it from a client, and add an interactive UI.
+
+
FastMCP is made with ๐ by [Prefect](https://www.prefect.io/).
-## Run FastMCP in production with Horizon
+
+**This documentation reflects FastMCP's `main` branch**, so it may describe features that have not reached a stable release. Version badges identify when features were introduced.
+
-FastMCP is the standard way to build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_body)** is the enterprise MCP gateway for running them safely.
+## Scale MCP with Horizon
-Built by the FastMCP team, Horizon packages the best practices we've learned shipping the world's most popular MCP framework.
+FastMCP handles the MCP application layer. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_body)** is the enterprise MCP gateway for scaling servers and tools across teams, with centralized governance over how they are deployed, discovered, secured, and used.
-Deploy FastMCP servers from GitHub with branch previews and instant rollback. Create a private registry of every MCP your company uses. Secure access with SSO and tool-level RBAC. Get audit logs, observability, and governance across your MCP stack. Remix approved tools into purpose-built endpoints for teams and agents.
+Horizon applies the operational patterns developed while maintaining FastMCP: deploy servers from GitHub with branch previews and instant rollback, organize them in a private registry, protect access with SSO and tool-level RBAC, and observe activity through audit logs and telemetry.
+
+Horizon can also combine approved tools into purpose-built MCP endpoints for different teams and agents, while keeping access policy and governance centralized.
Start with FastMCP. [Scale with Horizon โ](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_cta)
-
-**This documentation reflects FastMCP's `main` branch**, meaning it always reflects the latest development version. Features are generally marked with version badges (e.g. `New in version: 3.0.0`) to indicate when they were introduced. Note that this may include features that are not yet released.
-
+## LLM-friendly docs
-## LLM-Friendly Docs
+FastMCP documentation is designed for developers and coding agents. Every page is available as Markdown, the complete documentation is published in `llms.txt` formats, and the documentation itself is exposed through an MCP server.
-The FastMCP documentation is available in multiple LLM-friendly formats:
+### MCP server
-### MCP Server
-
-The FastMCP docs are accessible via MCP! The server URL is `https://gofastmcp.com/mcp`.
-
-In fact, you can use FastMCP to search the FastMCP docs:
+Point any MCP-compatible agent at `https://gofastmcp.com/mcp` to let it search the documentation as it works. You can also connect with FastMCP's Python client directly:
```python
import asyncio
+
from fastmcp import Client
-async def main():
+
+async def main() -> None:
async with Client("https://gofastmcp.com/mcp") as client:
result = await client.call_tool(
name="search_fast_mcp",
- arguments={"query": "deploy a FastMCP server"}
+ arguments={"query": "deploy a FastMCP server"},
)
- print(result)
+ print(result)
+
asyncio.run(main())
```
-### Text Formats
+### Markdown formats
-The docs are also available in [llms.txt format](https://llmstxt.org/):
-- [llms.txt](https://gofastmcp.com/llms.txt) - A sitemap listing all documentation pages
-- [llms-full.txt](https://gofastmcp.com/llms-full.txt) - The entire documentation in one file (may exceed context windows)
+The documentation is also available in [`llms.txt`](https://llmstxt.org/) formats:
-Any page can be accessed as markdown by appending `.md` to the URL. For example, this page becomes `https://gofastmcp.com/getting-started/welcome.md`.
+- [`llms.txt`](https://gofastmcp.com/llms.txt) lists every documentation page.
+- [`llms-full.txt`](https://gofastmcp.com/llms-full.txt) contains the complete documentation in one file and may exceed some context windows.
-You can also copy any page as markdown by pressing "Cmd+C" (or "Ctrl+C" on Windows) on your keyboard.
+Append `.md` to any documentation URL to retrieve that page as Markdown. For example, this page is available at `https://gofastmcp.com/getting-started/welcome.md`. You can also copy the current page as Markdown by pressing `Cmd+C` or `Ctrl+C`.
diff --git a/docs/v3/deployment/prefect-horizon.mdx b/docs/v3/deployment/prefect-horizon.mdx
index b22644181..6a26fa19e 100644
--- a/docs/v3/deployment/prefect-horizon.mdx
+++ b/docs/v3/deployment/prefect-horizon.mdx
@@ -5,7 +5,7 @@ description: The MCP platform from the FastMCP team
icon: cloud
---
-[Prefect Horizon](https://www.prefect.io/horizon) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities.
+[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_horizon&utm_content=v3_guide_intro) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities.
Horizon includes a **free personal tier for FastMCP users**, making it the fastest way to get a secure, production-ready server URL with built-in OAuth authentication.
From 07f6eafd99e888f9a0ac61fc30f904f30a5d3f65 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Wed, 29 Jul 2026 09:51:36 -0400
Subject: [PATCH 07/32] Add comprehensive Codex code review rules (#4710)
---
CLAUDE.md | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index e6357e06b..5a4ef8bed 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -184,6 +184,20 @@ Because the docs land *before* the tag exists, derive the entry from the maintai
- **Style:** Prose over code comments for important information
- **Docstrings:** FastMCP docstrings are automatically compiled into MDX documents. Use markdown (single backticks, fenced code blocks), not RST (no double backticks). Bare `{}` in examples will be interpreted as JSX โ wrap in backticks instead.
+## Code Review Rules
+
+### Framework regressions and root causes
+
+- Review changes carefully for regressions in supported framework behavior, including interactions beyond the immediate diff. Trace relevant callers, shared abstractions, protocol and public API contracts, and all affected MCP component types. Determine whether a change fixes the causal code path or merely compensates for the symptom; side channels and special cases that leave the root cause intact should be treated as suspect.
+
+### Comprehensive first pass
+
+- Review the entire pull request diff against the merge base, not only the latest commits. Inspect every changed file and the relevant surrounding code, collect all independent, substantiated consequential findings before submitting the review, and report the complete set in one review whenever possible. Do not stop after finding the first few issues or defer other already-visible findings to later review cycles.
+
+### Prior discussion and proportionality
+
+- When prior review threads and author or maintainer replies are available, read them before commenting. Evaluate responses on their merits and do not repeat a resolved or convincingly rebutted finding without new evidence. Avoid fixating on speculative edge cases: report an edge case only when it is reachable under supported usage or a credible threat model and has meaningful impact; otherwise omit it or clearly treat it as non-blocking.
+
## Critical Patterns
- Never use bare `except` - be specific with exception types
From 44c0907dda78618f882b08de26e31c4428bd74b8 Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Wed, 29 Jul 2026 10:22:17 -0500
Subject: [PATCH 08/32] Give parallel Windows CI more timeout headroom (#4680)
pytest-timeout falls back to its thread method on Windows, which os._exit()s
the process instead of failing the test. A single slow test therefore kills an
xdist worker and fails whichever unrelated test it was running.
Co-authored-by: Claude Opus 5 (1M context)
---
.github/actions/run-pytest/action.yml | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/.github/actions/run-pytest/action.yml b/.github/actions/run-pytest/action.yml
index e7ee2b8ec..c82e9c0bd 100644
--- a/.github/actions/run-pytest/action.yml
+++ b/.github/actions/run-pytest/action.yml
@@ -46,6 +46,16 @@ runs:
PARALLEL_FLAGS="--numprocesses auto --maxprocesses $MAX_PROCS --dist worksteal"
fi
+ # pytest-timeout has no signal-based method on Windows, so it falls back
+ # to the thread method, which dumps stacks and os._exit()s the process.
+ # Under a contended runner that turns a single slow test into a dead
+ # xdist worker, failing whichever unrelated test that worker happened to
+ # be running. Give parallel Windows runs more headroom so ordinary
+ # scheduling jitter does not take a worker down.
+ if [ "$RUNNER_OS" == "Windows" ] && [ "$MAX_PROCS" != "0" ]; then
+ TIMEOUT=$((TIMEOUT * 4))
+ fi
+
uv run --no-sync pytest \
--inline-snapshot=disable \
--timeout=$TIMEOUT \
From 0f18a258d4d88d2a112a2e2d647edb2c63246cd4 Mon Sep 17 00:00:00 2001
From: Nicholas Brown
Date: Thu, 30 Jul 2026 09:32:58 -0400
Subject: [PATCH 09/32] add language dropdown (#4716)
---
docs/css/language-dropdown.css | 57 +++++++++++++++++++++++++
docs/language-dropdown.js | 77 ++++++++++++++++++++++++++++++++++
2 files changed, 134 insertions(+)
create mode 100644 docs/css/language-dropdown.css
create mode 100644 docs/language-dropdown.js
diff --git a/docs/css/language-dropdown.css b/docs/css/language-dropdown.css
new file mode 100644
index 000000000..0eb810545
--- /dev/null
+++ b/docs/css/language-dropdown.css
@@ -0,0 +1,57 @@
+/* Language dropdown: injected by language-dropdown.js into the sidebar
+ footer, to the right of Mintlify's theme selector. Mirrors the almond
+ theme pill's exact metrics (lg:h-7 desktop / 2.375rem mobile, rounded-full,
+ border-gray-200/70, dark:border-white/[0.07]) so the two controls read as
+ one family. */
+#language-switch {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+}
+
+#language-switch select {
+ appearance: none;
+ -webkit-appearance: none;
+ background-color: transparent;
+ border: 1px solid rgb(229 231 235 / 0.7);
+ border-radius: 9999px;
+ color: rgb(107 114 128);
+ cursor: pointer;
+ font-size: 0.75rem;
+ line-height: 1rem;
+ height: 2.375rem;
+ padding: 0 1.375rem 0 0.75rem;
+ /* Chevron, drawn in the same gray as the label text. */
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.5rem center;
+ background-size: 0.7rem;
+ transition: border-color 0.2s;
+}
+
+@media (min-width: 1024px) {
+ #language-switch select {
+ height: 1.75rem;
+ }
+}
+
+#language-switch select:hover {
+ color: rgb(75 85 99);
+ border-color: rgb(229 231 235);
+}
+
+#language-switch select:focus-visible {
+ outline: 2px solid rgb(45 0 247 / 0.4);
+ outline-offset: 1px;
+}
+
+.dark #language-switch select {
+ border-color: rgb(255 255 255 / 0.07);
+ color: rgb(156 163 175);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+}
+
+.dark #language-switch select:hover {
+ color: rgb(209 213 219);
+ border-color: rgb(255 255 255 / 0.1);
+}
diff --git a/docs/language-dropdown.js b/docs/language-dropdown.js
new file mode 100644
index 000000000..4eb5131f6
--- /dev/null
+++ b/docs/language-dropdown.js
@@ -0,0 +1,77 @@
+// Language dropdown: a small Python/TypeScript switcher injected into the
+// sidebar footer, next to Mintlify's theme selector. Selecting the other
+// language navigates to that project's docs site; selecting the current
+// language is a no-op. Styling lives in css/language-dropdown.css.
+(function () {
+ if (typeof window === "undefined") return;
+
+ var CURRENT_LANGUAGE = "python";
+
+ // TODO: fastmcp-ts has no public docs site URL discoverable in either repo
+ // yet. Until it exists, point at the repo README (the same cross-link the
+ // welcome page uses), then replace with the real docs URL.
+ var TYPESCRIPT_DOCS_URL = "https://github.com/PrefectHQ/fastmcp-ts";
+ var PYTHON_DOCS_URL = "https://gofastmcp.com";
+
+ var URLS = { python: PYTHON_DOCS_URL, typescript: TYPESCRIPT_DOCS_URL };
+
+ function findThemeSelector() {
+ // Mintlify's sidebar-footer DOM is not a stable public API, so probe a
+ // few markers (almond theme first) and give up quietly if none match.
+ return (
+ document.querySelector("[data-theme-preference-switch]") ||
+ document.querySelector('[role="group"][aria-label="Theme preference"]')
+ );
+ }
+
+ function buildDropdown() {
+ var label = document.createElement("label");
+ label.id = "language-switch";
+
+ var select = document.createElement("select");
+ select.setAttribute("aria-label", "Switch documentation language");
+
+ [
+ ["python", "Python"],
+ ["typescript", "TypeScript"],
+ ].forEach(function (entry) {
+ var option = document.createElement("option");
+ option.value = entry[0];
+ option.textContent = entry[1];
+ if (entry[0] === CURRENT_LANGUAGE) option.selected = true;
+ select.appendChild(option);
+ });
+
+ select.addEventListener("change", function () {
+ if (select.value === CURRENT_LANGUAGE) return;
+ window.location.href = URLS[select.value];
+ });
+
+ label.appendChild(select);
+ return label;
+ }
+
+ function addDropdown() {
+ if (document.getElementById("language-switch")) return;
+ var theme = findThemeSelector();
+ if (!theme || !theme.parentElement) return;
+ // Insert after the theme pill; margin-left:auto floats it right.
+ theme.parentElement.insertBefore(buildDropdown(), theme.nextSibling);
+ }
+
+ function run() {
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", addDropdown);
+ } else {
+ addDropdown();
+ }
+ }
+
+ run();
+
+ // Mintlify re-renders the sidebar on client-side navigation; re-inject when
+ // the dropdown disappears.
+ new MutationObserver(function () {
+ if (!document.getElementById("language-switch")) addDropdown();
+ }).observe(document.body, { subtree: true, childList: true });
+})();
From bcef61d8064a145a2f32416ec969a351f70bcbe8 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Thu, 30 Jul 2026 10:18:03 -0400
Subject: [PATCH 10/32] Route published docs through pull requests (#4713)
---
.github/workflows/publish-fastmcp.yml | 38 +++++++++++++++++++++++----
CLAUDE.md | 4 ++-
docs/development/releases.mdx | 2 +-
3 files changed, 37 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/publish-fastmcp.yml b/.github/workflows/publish-fastmcp.yml
index dc32179cb..8b2ce33b2 100644
--- a/.github/workflows/publish-fastmcp.yml
+++ b/.github/workflows/publish-fastmcp.yml
@@ -178,19 +178,27 @@ jobs:
run: uv publish -v dist/fastmcp-*.tar.gz dist/fastmcp-*.whl
update-published-docs:
- name: Update published-docs branch
+ name: Open published-docs PR
runs-on: ubuntu-latest
needs: pypi-publish
if: github.event_name == 'workflow_run' && github.event.workflow_run.event == 'release' && needs['pypi-publish'].outputs.is_prerelease != 'true'
- timeout-minutes: 2
+ timeout-minutes: 5
permissions:
- contents: write
+ contents: read
steps:
+ - name: Generate Marvin App token
+ id: marvin-token
+ uses: actions/create-github-app-token@v3
+ with:
+ app-id: ${{ secrets.MARVIN_APP_ID }}
+ private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
+
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
+ token: ${{ steps.marvin-token.outputs.token }}
- name: Check release line
id: release_line
@@ -205,6 +213,26 @@ jobs:
echo "Release commit is not on ${DEFAULT_BRANCH}; skipping published-docs update."
fi
- - name: Point published-docs at published release
+ - name: Prepare published docs tree
if: steps.release_line.outputs.update_published_docs == 'true'
- run: git push --force origin "HEAD:published-docs"
+ env:
+ RELEASE_SHA: ${{ github.event.workflow_run.head_sha }}
+ run: |
+ git fetch origin published-docs
+ git switch --force-create published-docs-sync origin/published-docs
+ git read-tree --reset -u "$RELEASE_SHA"
+ test "$(git write-tree)" = "$(git rev-parse "${RELEASE_SHA}^{tree}")"
+
+ - name: Open published docs PR
+ if: steps.release_line.outputs.update_published_docs == 'true'
+ uses: peter-evans/create-pull-request@v8
+ with:
+ token: ${{ steps.marvin-token.outputs.token }}
+ base: published-docs
+ branch: marvin/publish-docs-v${{ needs.pypi-publish.outputs.version }}
+ commit-message: "Publish FastMCP v${{ needs.pypi-publish.outputs.version }} docs"
+ title: "Publish FastMCP v${{ needs.pypi-publish.outputs.version }} docs"
+ body: "Updates `published-docs` to the exact release tree. Merging publishes the documentation to production."
+ delete-branch: true
+ author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
+ committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
diff --git a/CLAUDE.md b/CLAUDE.md
index 5a4ef8bed..1a2cbddb0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -117,7 +117,9 @@ Set `target_commitish` to the same branch that will receive the release tag. For
**Patch releases** (3.1.1, 3.0.2) get 1-2 sentences explaining what broke and what the fix does. Keep it minimal โ the auto-generated changelog has the details.
-**Merge the docs changelog PR *before* cutting the release, not after.** The post-publish `update-published-docs` job force-pushes the `published-docs` branch (which gofastmcp.com serves) to the released commit for stable releases on the default branch, so the changelog entry only reaches the live site if it's already in the commit being tagged. Land the docs PR on the release target branch first, then cut the release from that branch. If you tag first and merge docs after, this release's changelog won't appear on the live site until the next default-branch stable release force-pushes `published-docs` forward. Maintenance releases from `release/3.x` or `release/2.x` publish packages and GitHub notes without repointing `published-docs`; add their changelog entries on the maintenance branch, slotted into the matching major-version section. Two hand-maintained files mirror the GitHub release and must get a new entry for every version, newest at the top (these are `.mdx` and are not covered by the prek Prettier hook, which only runs on `yaml`/`json5` โ match the existing entries' style by hand):
+**Publish docs through a PR.** The `published-docs` branch serves gofastmcp.com, and repository rules reject direct pushes and force-pushes to it. Stable releases from `main` automatically open a publication PR after PyPI succeeds. For prereleases and later docs follow-ups, create the same PR manually: start a temporary branch from the current `published-docs`, make a single commit whose tree exactly matches the desired commit on `main`, and use `published-docs` as the PR base. Merging publishes to production. Never push directly to `published-docs`.
+
+**Merge the docs changelog PR *before* cutting the release, not after.** The post-publish `update-published-docs` job opens a PR that syncs `published-docs` to the released commit for stable releases on the default branch, so the changelog entry only reaches the live site if it's already in the commit being tagged. Land the docs PR on the release target branch first, then cut the release from that branch. If you tag first and merge docs after, this release's publication PR will not include the changelog; publish `main` manually through the PR flow above or wait for the next default-branch stable release. Maintenance releases from `release/3.x` or `release/2.x` publish packages and GitHub notes without repointing `published-docs`; add their changelog entries on the maintenance branch, slotted into the matching major-version section. Two hand-maintained files mirror the GitHub release and must get a new entry for every version, newest at the top (these are `.mdx` and are not covered by the prek Prettier hook, which only runs on `yaml`/`json5` โ match the existing entries' style by hand):
- `docs/changelog.mdx` is the full mirror. Add an `` block with: a bold linked title (`**[v: ]()**`), a condensed 1-paragraph intro (one sentence for patches), the full categorized PR list reformatted from the `--generate-notes` output (`* by [@user](https://github.com/user) in [#NNNN]()`), a `## New Contributors` list (plain `@user`, linked PR), and a `**Full Changelog**: [vA...vB]()` line.
- `docs/updates.mdx` is the skimmable card feed. Add an `` wrapping a `` that links to the GitHub release, with a 1-2 sentence summary and (for point releases) a handful of emoji-bulleted highlights.
diff --git a/docs/development/releases.mdx b/docs/development/releases.mdx
index ecba9a25f..f537703e2 100644
--- a/docs/development/releases.mdx
+++ b/docs/development/releases.mdx
@@ -65,7 +65,7 @@ Our release process is intentionally simple:
2. Generate release notes automatically, and curate or add additional editorial information as needed
3. GitHub releases automatically trigger PyPI deployments
-Current-major releases target `main`. Maintenance releases target their release branch, such as `release/3.x` for 3.x patches and `release/2.x` for 2.x patches. Stable releases from `main` update the `published-docs` branch after PyPI publishing succeeds; maintenance releases publish packages and GitHub release notes without repointing the live docs branch.
+Current-major releases target `main`. Maintenance releases target their release branch, such as `release/3.x` for 3.x patches and `release/2.x` for 2.x patches. Stable releases from `main` open a PR that syncs the release commit to `published-docs` after PyPI publishing succeeds; merging that PR publishes the live docs. Prereleases skip the automatic PR and use the same PR-based sync when their docs are ready to publish. Maintenance releases publish packages and GitHub release notes without repointing the live docs branch.
This automation lets maintainers focus on code quality rather than release mechanics.
From bc07264529fe108b43ae81d116a15d3e2808d23b Mon Sep 17 00:00:00 2001
From: Jake Kaplan <40362401+jakekaplan@users.noreply.github.com>
Date: Thu, 30 Jul 2026 16:23:33 -0400
Subject: [PATCH 11/32] Fix self-referential connection error causes (#4720)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
๐ค Generated with OpenAI Codex
---
fastmcp_slim/fastmcp/client/client.py | 5 ++-
tests/client/auth/test_oauth_client.py | 4 +-
tests/client/client/test_session.py | 55 ++++++++++++++++++++++++--
3 files changed, 59 insertions(+), 5 deletions(-)
diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py
index c9b9aa1eb..55aff6408 100644
--- a/fastmcp_slim/fastmcp/client/client.py
+++ b/fastmcp_slim/fastmcp/client/client.py
@@ -1022,7 +1022,10 @@ class Client(
raise RuntimeError(
"Session task completed without exception but connection failed"
)
- raise _connection_failure(exception) from exception
+ failure = _connection_failure(exception)
+ if failure is exception:
+ raise exception
+ raise failure from exception
self._session_state.nesting_counter += 1
diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py
index 1ded252f8..22ca63af0 100644
--- a/tests/client/auth/test_oauth_client.py
+++ b/tests/client/auth/test_oauth_client.py
@@ -96,10 +96,12 @@ async def test_unauthorized(client_unauthorized: Client):
SDK v2 surfaces the server's 401 as an MCPError ("Server returned an error
response") rather than re-raising the raw httpx2.HTTPStatusError.
"""
- with pytest.raises(MCPError, match="error response"):
+ with pytest.raises(MCPError, match="error response") as exc_info:
async with client_unauthorized:
pass
+ assert exc_info.value.__cause__ is not exc_info.value
+
async def test_ping(streamable_http_server: str):
"""Test that we can ping the server.
diff --git a/tests/client/client/test_session.py b/tests/client/client/test_session.py
index 0579aa6d5..116bff69c 100644
--- a/tests/client/client/test_session.py
+++ b/tests/client/client/test_session.py
@@ -1,18 +1,33 @@
"""Client session and task error propagation tests."""
import asyncio
+from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
+from typing import Any
+import httpx2
import pytest
-from mcp import ClientSession
-from mcp_types import TextContent
+from mcp import ClientSession, MCPError
+from mcp_types import INTERNAL_ERROR, TextContent
from fastmcp import FastMCP
from fastmcp.client import Client
-from fastmcp.client.transports import PythonStdioTransport
+from fastmcp.client.transports import ClientTransport, PythonStdioTransport
from fastmcp.client.transports.base import TransportOptions
+class _FailingTransport(ClientTransport):
+ def __init__(self, exception: Exception) -> None:
+ self._exception = exception
+
+ @asynccontextmanager
+ async def connect_session(
+ self, **session_kwargs: Any
+ ) -> AsyncIterator[ClientSession]:
+ raise self._exception
+ yield
+
+
class TestSessionTaskErrorPropagation:
"""Tests for ensuring session task errors propagate to client calls.
@@ -143,6 +158,40 @@ class TestSessionTaskErrorPropagation:
client._session_state.session_task = original_task
+class TestConnectionFailurePropagation:
+ @pytest.mark.parametrize(
+ "failure",
+ [
+ MCPError(code=INTERNAL_ERROR, message="upstream failed"),
+ httpx2.HTTPStatusError(
+ "upstream unavailable",
+ request=httpx2.Request("GET", "https://example.com"),
+ response=httpx2.Response(503),
+ ),
+ ],
+ ids=["mcp-error", "http-status-error"],
+ )
+ async def test_preserves_passthrough_exception(self, failure: Exception):
+ client = Client(transport=_FailingTransport(failure))
+
+ with pytest.raises(type(failure)) as exc_info:
+ async with client:
+ pass
+
+ assert exc_info.value is failure
+ assert exc_info.value.__cause__ is not failure
+
+ async def test_wraps_other_failures_with_cause(self):
+ failure = OSError("connection refused")
+ client = Client(transport=_FailingTransport(failure))
+
+ with pytest.raises(RuntimeError, match="Client failed to connect") as exc_info:
+ async with client:
+ pass
+
+ assert exc_info.value.__cause__ is failure
+
+
class TestCustomSessionClass:
"""Transports build the session class the client asks for."""
From 40c3e122e8e5cce4d080c3b87805ea7bae92c96c Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Sun, 2 Aug 2026 08:33:32 -0500
Subject: [PATCH 12/32] Write downloaded skill text as UTF-8 (#4715)
---
fastmcp_slim/fastmcp/utilities/skills.py | 2 +-
tests/utilities/test_skills.py | 47 ++++++++++++++++++++++++
2 files changed, 48 insertions(+), 1 deletion(-)
diff --git a/fastmcp_slim/fastmcp/utilities/skills.py b/fastmcp_slim/fastmcp/utilities/skills.py
index 2c93b1f7f..73b859548 100644
--- a/fastmcp_slim/fastmcp/utilities/skills.py
+++ b/fastmcp_slim/fastmcp/utilities/skills.py
@@ -205,7 +205,7 @@ async def download_skill(
# Write content
if isinstance(content, mcp_types.TextResourceContents):
- file_path.write_text(content.text)
+ file_path.write_text(content.text, encoding="utf-8")
elif isinstance(content, mcp_types.BlobResourceContents):
file_path.write_bytes(base64.b64decode(content.blob))
else:
diff --git a/tests/utilities/test_skills.py b/tests/utilities/test_skills.py
index 62268ac91..90c6f5726 100644
--- a/tests/utilities/test_skills.py
+++ b/tests/utilities/test_skills.py
@@ -269,6 +269,53 @@ class TestDownloadSkill:
downloaded = (result / "SKILL.md").read_text()
assert downloaded == original
+ async def test_writes_text_resources_as_utf8(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ content = "Use the tool โ then stop."
+ manifest = {
+ "skill": "unicode",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": len(content.encode("utf-8")),
+ "hash": "sha256:unicode",
+ }
+ ],
+ }
+ client = FakeResourceReader(
+ {
+ "skill://unicode/_manifest": [
+ text_resource("skill://unicode/_manifest", json.dumps(manifest))
+ ],
+ "skill://unicode/SKILL.md": [
+ text_resource("skill://unicode/SKILL.md", content)
+ ],
+ }
+ )
+ original_write_text = Path.write_text
+
+ def locale_sensitive_write_text(
+ path: Path,
+ data: str,
+ encoding: str | None = None,
+ errors: str | None = None,
+ newline: str | None = None,
+ ) -> int:
+ return original_write_text(
+ path,
+ data,
+ encoding=encoding or "ascii",
+ errors=errors,
+ newline=newline,
+ )
+
+ monkeypatch.setattr(Path, "write_text", locale_sensitive_write_text)
+
+ result = await download_skill(cast(Client, client), "unicode", tmp_path)
+
+ assert (result / "SKILL.md").read_text(encoding="utf-8") == content
+
async def test_raises_if_exists_without_overwrite(
self, skills_server: FastMCP, tmp_path: Path
):
From 9034a2eb4bdcbccbad5851d6d98ceae445d3f5c4 Mon Sep 17 00:00:00 2001
From: YAO_001
Date: Sun, 2 Aug 2026 21:41:36 +0800
Subject: [PATCH 13/32] Fix CodeMode tool error propagation (#4704)
Co-authored-by: nate nowack
---
fastmcp_slim/pyproject.toml | 2 +-
.../experimental/transforms/test_code_mode.py | 57 ++++++++
uv.lock | 126 +++++++++---------
3 files changed, 121 insertions(+), 64 deletions(-)
diff --git a/fastmcp_slim/pyproject.toml b/fastmcp_slim/pyproject.toml
index ba408404d..b009efd42 100644
--- a/fastmcp_slim/pyproject.toml
+++ b/fastmcp_slim/pyproject.toml
@@ -74,7 +74,7 @@ client = [
"authlib>=1.6.11",
"py-key-value-aio[filetree,keyring,memory]>=0.4.4,<0.5.0",
]
-code-mode = ["pydantic-monty==0.0.17"]
+code-mode = ["pydantic-monty==0.0.18"]
gemini = ["google-genai>=1.18.0", "jsonref>=1.1.0"]
mcp = [
"exceptiongroup>=1.2.2",
diff --git a/tests/experimental/transforms/test_code_mode.py b/tests/experimental/transforms/test_code_mode.py
index 557f33c8b..cea34de50 100644
--- a/tests/experimental/transforms/test_code_mode.py
+++ b/tests/experimental/transforms/test_code_mode.py
@@ -791,6 +791,63 @@ async def test_code_mode_monty_execute_chaining() -> None:
assert _unwrap_result(result) == {"result": 13}
+@requires_monty
+@pytest.mark.parametrize(
+ ("failing_call", "expected_message"),
+ [
+ ("await call_tool('no_such_tool', {})", "Unknown tool: no_such_tool"),
+ ("await call_tool('boom', {})", "deliberate tool failure"),
+ ],
+ ids=["unknown-tool", "tool-error"],
+)
+async def test_code_mode_monty_call_tool_errors_are_catchable(
+ failing_call: str, expected_message: str
+) -> None:
+ """Sandbox code can catch call_tool errors and preserve prior work."""
+ mcp = FastMCP("CodeMode Monty Catch Errors")
+
+ @mcp.tool
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ @mcp.tool
+ def boom() -> None:
+ raise ToolError("deliberate tool failure")
+
+ mcp.add_transform(CodeMode(sandbox_provider=MontySandboxProvider()))
+
+ code = (
+ "total = (await call_tool('add', {'x': 2, 'y': 3}))['result']\n"
+ "caught = None\n"
+ "try:\n"
+ f" {failing_call}\n"
+ "except Exception as exc:\n"
+ " caught = str(exc)\n"
+ "return {'caught': caught, 'total': total}"
+ )
+ result = await _run_tool(mcp, "execute", {"code": code})
+
+ assert _unwrap_result(result) == {
+ "caught": expected_message,
+ "total": 5,
+ }
+
+
+@requires_monty
+async def test_code_mode_monty_uncaught_call_tool_error_surfaces() -> None:
+ """Uncaught backend errors still propagate out of the sandbox."""
+ mcp = FastMCP("CodeMode Monty Uncaught Error")
+
+ @mcp.tool
+ def boom() -> None:
+ raise ToolError("deliberate tool failure")
+
+ mcp.add_transform(CodeMode(sandbox_provider=MontySandboxProvider()))
+
+ with pytest.raises(ToolError, match="deliberate tool failure"):
+ await _run_tool(mcp, "execute", {"code": "return await call_tool('boom', {})"})
+
+
@requires_monty
async def test_code_mode_monty_bare_call_returns_empty() -> None:
"""Pins the reported #4263 symptom as a usage error, not a sandbox bug.
diff --git a/uv.lock b/uv.lock
index a169584d7..727bf1183 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1065,7 +1065,7 @@ requires-dist = [
{ name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], marker = "extra == 'client'", specifier = ">=0.4.4,<0.5.0" },
{ name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], marker = "extra == 'server'", specifier = ">=0.4.4,<0.5.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.0" },
- { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = "==0.0.17" },
+ { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = "==0.0.18" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "pyjwt", marker = "extra == 'azure'", specifier = ">=2.12.0" },
{ name = "pyperclip", marker = "extra == 'server'", specifier = ">=1.9.0" },
@@ -2280,73 +2280,73 @@ wheels = [
[[package]]
name = "pydantic-monty"
-version = "0.0.17"
+version = "0.0.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f4/f8/431ba0b79d02922811392c4e3d283d6508f7052ceaa1936cc34878703ecc/pydantic_monty-0.0.17.tar.gz", hash = "sha256:9c4904a8fbc63282793f3afd2d180124494c7fc371783f365e5691c9586360af", size = 1007724, upload-time = "2026-04-22T20:13:48.915Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/14/5b/bb6a8bfdf13eb9808c966bdac064a40ce9ac881ec6d64dba3e055888f22b/pydantic_monty-0.0.18.tar.gz", hash = "sha256:c43794c7c4664fa1403d4841459d0e23f01b4f552283db638f5b40ced4dac6a1", size = 1197105, upload-time = "2026-05-29T08:31:41.077Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5e/19/8105bc0b3acb42f6cb48a29669a5e21316bc05e3e9b6fab64cf94b483712/pydantic_monty-0.0.17-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3c3b6c026d8a0437eeb4d6b2d908be75e2715e0555b9a13f076b7e9ba9bbae19", size = 7344730, upload-time = "2026-04-22T20:13:24.408Z" },
- { url = "https://files.pythonhosted.org/packages/6d/a2/7281cdb37481c4252292b63bebf737c87d0fd463f3174499608607de0907/pydantic_monty-0.0.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c80b4d34437abd209c042f81f8ecea81a097022fb9b01431ab859b877edfbc4d", size = 7334937, upload-time = "2026-04-22T20:15:06.923Z" },
- { url = "https://files.pythonhosted.org/packages/a5/68/0bf7c0c627a56d8653b42888a3c1fc33cd33d2532ec456d9358275d7c792/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:beecc1f7e5b10db40d7b2b24a68166a36514289a2402bfef370a7984e90a2ab8", size = 7864543, upload-time = "2026-04-22T20:14:46.273Z" },
- { url = "https://files.pythonhosted.org/packages/09/9b/5a6f006541fd3bdc64b6dfbbaeabfb2244c89a22d7077a1fc92ec497c03e/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64ea7babdcc9fba93089fa52589b6d0549f755e37500f6cf4aeaeb8e56328a3e", size = 7138764, upload-time = "2026-04-22T20:15:30.516Z" },
- { url = "https://files.pythonhosted.org/packages/01/cc/59cca979bd427d166df8c827fba9e794c4a5c08943e225a22adf9854a78f/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a7fe77a191205becb622eaecb075e8bcbbbe4dac20a916d9c58ce6d59a22a8da", size = 7444006, upload-time = "2026-04-22T20:15:23.386Z" },
- { url = "https://files.pythonhosted.org/packages/1f/c5/d027170fb33fcbc038febb76dfd2d9047f5194a250ea608e3ed8e5ec28d4/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2cdbefc180cc83c8b8415aaf95b9099bb2cb15261f40ebe2c92f13e7d52439a4", size = 7967564, upload-time = "2026-04-22T20:14:57.315Z" },
- { url = "https://files.pythonhosted.org/packages/3e/01/ac0d4bc1ff00acfac14b7cb2ee322d08778c206cd57f43da8206a2f6ce78/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:575ce5aa31db18bbbf6275f00e9b0c005ca393bfb73a2f306a577ad490ec2d98", size = 8199021, upload-time = "2026-04-22T20:15:14.488Z" },
- { url = "https://files.pythonhosted.org/packages/51/85/8d0c6e5f127da9ebc0fcda6e411592d12b7606347d67aecd4363df5eed6b/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e252ec54fc4728406045f7be36ca45dbea8e6856df9c6154b1b9821b8952dfa2", size = 7769814, upload-time = "2026-04-22T20:14:55.197Z" },
- { url = "https://files.pythonhosted.org/packages/ac/cc/cb4d1b14b039eab00b33a7274f15f81739c3f272e2dfbeb8fb13c6b0c85d/pydantic_monty-0.0.17-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fba71e5cb49f15a1446ecee142c8cc11f4bd6df4fcb4926465c83181474b2fd4", size = 7317432, upload-time = "2026-04-22T20:14:19.993Z" },
- { url = "https://files.pythonhosted.org/packages/c8/16/737c7a023abbcb21848eb4d58f7167d9f4f8cdc46858ce8ed835cc2c137c/pydantic_monty-0.0.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69136647abd56f804987834e37573adcc5c3b3d05013b8b3a2939f44b3bd5199", size = 7767816, upload-time = "2026-04-22T20:13:40.002Z" },
- { url = "https://files.pythonhosted.org/packages/dc/9f/5302b784f882ae8a8396f29f8c5ab4c16524c173a3d777b94af33858fdf2/pydantic_monty-0.0.17-cp310-cp310-win32.whl", hash = "sha256:d5b3beb6169b59adea10fdefb1e54bfa9a66165404891dfb6fcf16f7749cda3b", size = 7230648, upload-time = "2026-04-22T20:14:27.03Z" },
- { url = "https://files.pythonhosted.org/packages/1c/27/8c219f619dad466ec25db365acf88e2a50450dd862e0daff0eb281b6176b/pydantic_monty-0.0.17-cp310-cp310-win_amd64.whl", hash = "sha256:50ed9561b6dd1a1863d4cac81e4eaca64cb10ab541aaab92fcb5996739bb8e7f", size = 8075073, upload-time = "2026-04-22T20:14:17.073Z" },
- { url = "https://files.pythonhosted.org/packages/e7/42/ca8e42d9f3318f5c454cf8b168d814ec97c6f2afc38756d4b1b806184f6d/pydantic_monty-0.0.17-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:af890d691f6055491a4e643dd5bf09e07bd7a20ad70038531aada6415ab8794a", size = 7344138, upload-time = "2026-04-22T20:13:29.155Z" },
- { url = "https://files.pythonhosted.org/packages/56/c8/cfaf0a56087301d4e88f72cf54ea45a7eebc09c021c85b8864447f1e3755/pydantic_monty-0.0.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f38a69858dfdd2c9474156616d05e25a288e2080aee24152fa40c19ad425f0e", size = 7334903, upload-time = "2026-04-22T20:14:31.489Z" },
- { url = "https://files.pythonhosted.org/packages/51/77/a751a6f73f854aa85fed94cfa5ecab21d7bf218c9fa03c96f9edf470cc4e/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bb88264e291cee56770a775f57125538c4713c6d362e89ee63bff506f650a0df", size = 7864258, upload-time = "2026-04-22T20:13:15.594Z" },
- { url = "https://files.pythonhosted.org/packages/0a/fe/2eb51eb37e9f712cada64fa8d7df4b63b1f5fc635290147ab158ff0e1ef1/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54c317611454aba8be7ca96aeeea9429f4702a5c4ba89812bea82bed0d8e34fd", size = 7138153, upload-time = "2026-04-22T20:14:22.255Z" },
- { url = "https://files.pythonhosted.org/packages/bb/15/835b10cdec3b96b089eef9899df6850b7f84a10225c491698b0ecf8e532a/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9563b5b4933f0f08c0e66ec66aaa4f43f2388bcc04b984e58aab2146dacd3829", size = 7443572, upload-time = "2026-04-22T20:13:17.951Z" },
- { url = "https://files.pythonhosted.org/packages/16/92/aca140923fad8a2821a135cfeaa2fbb3321063bbadaa760424a016bb1ac6/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35f267a501bc1910178a1515fdd3dd927273fbb44e44b8718cb3b33aee79f41b", size = 7967178, upload-time = "2026-04-22T20:14:06.032Z" },
- { url = "https://files.pythonhosted.org/packages/d7/ca/7c4ff1e3fe2e82a4745decfca67b54a7a61cd306875e32d8e41c5192c69e/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35b000c52755f25f322ea7c4d079f09aa60635ffe24a6463899e423066a41bf3", size = 8198241, upload-time = "2026-04-22T20:15:21.2Z" },
- { url = "https://files.pythonhosted.org/packages/30/0b/702db7b753b96ebc6713e7cbdfaecdb471df3e3cb0f0f6e828620a743b78/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61b517776ad13aa4580b1dd89188b18296ceeaf88256423563bbc99e804fd83f", size = 7768859, upload-time = "2026-04-22T20:13:20.044Z" },
- { url = "https://files.pythonhosted.org/packages/29/9c/8d16e0cc0c36d1444f25d57da68dd22216bf0961c457a482429cec32141b/pydantic_monty-0.0.17-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5da5362ef25665a23a3b13024497719f65cafa61d696cac76429f84701bee2e2", size = 7316674, upload-time = "2026-04-22T20:14:52.579Z" },
- { url = "https://files.pythonhosted.org/packages/6f/4d/d47ae703d402e45475333c4bf11b117c8068305f00c1363dbaea13d0fd09/pydantic_monty-0.0.17-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7e655b6ddd552c02b751f1d57fc291fbd5654ff8b166a8bd634857879160d0b7", size = 7767515, upload-time = "2026-04-22T20:15:16.539Z" },
- { url = "https://files.pythonhosted.org/packages/99/9b/e17fb50d0df5cf9908f8fffa25c5909ed0eb92ca102ded06f7a6d6133e78/pydantic_monty-0.0.17-cp311-cp311-win32.whl", hash = "sha256:ea8b3ae8c42d572cefad841d3bda63cc458d9de2361cb9172914250e6dbe2c75", size = 7230347, upload-time = "2026-04-22T20:14:08.083Z" },
- { url = "https://files.pythonhosted.org/packages/5e/82/d3119f59652d04bcf69d671ddbd38464d5775fbc738a258d3c8f7800e29d/pydantic_monty-0.0.17-cp311-cp311-win_amd64.whl", hash = "sha256:3293c2f7524bfc7c3d8c794f1c1dc1eb4cf9c65a5e222061e2218ced85f3f6df", size = 8074183, upload-time = "2026-04-22T20:14:50.42Z" },
- { url = "https://files.pythonhosted.org/packages/d1/31/95827babdb35149f076c5d191b6b1e7a7c58f4bc72432f905e02e4e3231e/pydantic_monty-0.0.17-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:27c2254fa7a7b05e969f79578889230d293c62e0b1ee28371ec4f3c54b14426a", size = 7342248, upload-time = "2026-04-22T20:15:18.775Z" },
- { url = "https://files.pythonhosted.org/packages/cb/67/ca9cfc07cd445d22def53e9db86912f9ae3e11ef772ce41c2ff41a47eac5/pydantic_monty-0.0.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:445cc471ce6f5a88ef06741b7ebc7002a2253d182f55a2f47094d4adedaaf497", size = 7311255, upload-time = "2026-04-22T20:15:27.913Z" },
- { url = "https://files.pythonhosted.org/packages/df/96/abc9c4972d91a9673435b84e12b99d038e42d1f99648fc9e5f242e09d00e/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:39121038405911f59da7bf61164251f59bad3fb1b0cd28f43c42c3949eee2c8a", size = 7868109, upload-time = "2026-04-22T20:15:04.779Z" },
- { url = "https://files.pythonhosted.org/packages/75/82/9e4d55529bb99d882b9277a721762537a8bc1345ab1d052bb614a88bd15b/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dafc8ffe57c257002f623afdb7d0e41f73de850179ebd90b42611e4f2b6f9884", size = 7139709, upload-time = "2026-04-22T20:15:25.386Z" },
- { url = "https://files.pythonhosted.org/packages/96/97/f1af6acefb7bb38d73934d6853998bbd327de7418b811372519080d9fd84/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ea00838ef8f37dcd8085defcbdfe89fdd05297a6533f1d3f4cad857d13cedc7b", size = 7450444, upload-time = "2026-04-22T20:13:37.974Z" },
- { url = "https://files.pythonhosted.org/packages/1d/91/af92ef409e1c065345cf1451bbcf19e00f70a250b8372ec65143ca9a9238/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683d18089acf14d0de293245b9e37c7f0ec64e6d266f6773144211931aa3ec97", size = 7967525, upload-time = "2026-04-22T20:13:42.674Z" },
- { url = "https://files.pythonhosted.org/packages/7b/1f/23ecd6e268ef24ce6b0fe4a1e76a314990d2e923ac5791e29d657418243d/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1829993dd50cf497cbed66ea9f6c8ff7d157d22592a05c7399f92fb8a549e3c", size = 8199124, upload-time = "2026-04-22T20:15:00.02Z" },
- { url = "https://files.pythonhosted.org/packages/42/2a/36b694ea0c7e202250a81a57faf00f218738da6c5d070c752f2d81cd34ce/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a7869e3f41a54cc588096c52a8a4de25ecd81e75c867ea4164b14ea1ae1a57f", size = 7739623, upload-time = "2026-04-22T20:14:37.57Z" },
- { url = "https://files.pythonhosted.org/packages/98/e5/090357d7bc0f0751d1afbb71330695fa26554699c88ba56ecaad91657088/pydantic_monty-0.0.17-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9c17663e2c6f07aec5bc54cd7e39a9e20f250a97a9081e1b2b932eb00d0afc5", size = 7317755, upload-time = "2026-04-22T20:14:48.367Z" },
- { url = "https://files.pythonhosted.org/packages/15/63/67200070cf33325ecfda81d4aee3bf312250ce80bd73058103e04e0f3587/pydantic_monty-0.0.17-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5d2cf98afe2fb124f6ade91d9663d54277478cad417164f83df1854a41ef450c", size = 7769158, upload-time = "2026-04-22T20:14:39.611Z" },
- { url = "https://files.pythonhosted.org/packages/58/ce/9ecfbc2f45406cfb247fafdea4f4a8412db3e559a22c4385eb15266ba2c1/pydantic_monty-0.0.17-cp312-cp312-win32.whl", hash = "sha256:b2185cc4effbbd6793eed4e0f0bcb6a3dbfbb3289ea4d47888708813f0a3dd47", size = 7227917, upload-time = "2026-04-22T20:14:15.122Z" },
- { url = "https://files.pythonhosted.org/packages/d3/80/9be3bef8273817ccc17da25c3ce4ff5d5d45e5629c17eacc90cdef073821/pydantic_monty-0.0.17-cp312-cp312-win_amd64.whl", hash = "sha256:7833daed757ec9b09b627cc3577a4a76b114c5148f779531d7cfdb1095bcf0a9", size = 8043469, upload-time = "2026-04-22T20:13:22.102Z" },
- { url = "https://files.pythonhosted.org/packages/b5/44/0e106b8b27eb93b66e4f3d279486464e05ba5ee31088848e58b5f506f879/pydantic_monty-0.0.17-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0cdac8c3c16477596bc96ee1cec4f2fbaccd089e2daa1e7b9f227cc89f97cb1", size = 7341507, upload-time = "2026-04-22T20:14:41.717Z" },
- { url = "https://files.pythonhosted.org/packages/e5/88/a0315fa08e62e2d1ef00c03d8202d7bef3f1f71543bebfb916fea265c0a2/pydantic_monty-0.0.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37290d6a1c35aba5cfb8b490bb31c0d822e8ddca8f3ca9ea068e30930d80dd1e", size = 7311916, upload-time = "2026-04-22T20:13:34.022Z" },
- { url = "https://files.pythonhosted.org/packages/e7/e5/e4da6acb408594cbfbfb8dd3c0491b9b2ee54e9183e7ebc5f584baa07af9/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:49f252b2fb918686d3e8f76cb30245e782d1560a7fa68dbc0f6940d83c12bd41", size = 7867465, upload-time = "2026-04-22T20:13:31.466Z" },
- { url = "https://files.pythonhosted.org/packages/58/d4/64c2f8eb708a743b0944ea8f71dfd51bc655285b4be28d55577dafbb29fa/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2254d25c34463d67069f5f1567157bde9311654cd5371f8933b8ea9815bfa26a", size = 7139262, upload-time = "2026-04-22T20:14:10.715Z" },
- { url = "https://files.pythonhosted.org/packages/0c/67/5d766f9cd304e871a5dfe5f0a85eaa533538ef07e9b2858fdf9f37f83694/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0688a1fa5dc045ac7b7e996d7269b94f74f116d7b1e352c7b5bb5ad53d4fe03", size = 7450119, upload-time = "2026-04-22T20:13:44.515Z" },
- { url = "https://files.pythonhosted.org/packages/33/98/fa16779021d93edb19807e87cdba56bbec6adfad21f10b41a212572ce513/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b35121ac555ed201405c69d531e4cb916da6984b8cd2e15c8a319117349faf6", size = 7967398, upload-time = "2026-04-22T20:13:55.576Z" },
- { url = "https://files.pythonhosted.org/packages/92/64/287a42720bc9e975ab5b52625aa9fc6bcef8298dd821022cc45c6ee1808d/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c97dc44af25d4392b474902fc40f78f09fe8f44ba791364670334fe12abc077", size = 8198835, upload-time = "2026-04-22T20:15:02.072Z" },
- { url = "https://files.pythonhosted.org/packages/97/37/03edb1fd582b79b2b462afc3fea5e1c8fea73afefc4870dc35fc3c7c492e/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ed2fb365ef9ca921de9a17786ecfa2efe06e65678e6ca57be51658a2a880f31", size = 7739241, upload-time = "2026-04-22T20:13:46.925Z" },
- { url = "https://files.pythonhosted.org/packages/11/0b/b765c9ca2ae27def1caa07345aba073ae1239fc2d9cca7a375f3dc2195f7/pydantic_monty-0.0.17-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5bf9f07b38dd12747e3c95b169a5afb3e2e9107622e01e548246c84d19a69c99", size = 7316719, upload-time = "2026-04-22T20:14:13.058Z" },
- { url = "https://files.pythonhosted.org/packages/e4/3b/64fe872cd575ab5262e1ba2959554ead198c939cfaa425f7f8e9b1ad2694/pydantic_monty-0.0.17-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e9bafd4b5acbc0a8e12ee8403a3ce37281c3b0fa5909d3f412bee76c69003c", size = 7769150, upload-time = "2026-04-22T20:13:57.784Z" },
- { url = "https://files.pythonhosted.org/packages/76/b4/b6a0bb41f39bac2e11e6a2fd42ca0886893fafa6344fb17c3f0a94e22e83/pydantic_monty-0.0.17-cp313-cp313-win32.whl", hash = "sha256:1c239ae3e610d3f39cd1609285209a4e2d046b465ac1bfed0d4374c615eed0fa", size = 7227705, upload-time = "2026-04-22T20:15:12.262Z" },
- { url = "https://files.pythonhosted.org/packages/86/e7/d8cd62f537f7ab17714ee19ea221a0e341dab407215376ab1c41d79794c9/pydantic_monty-0.0.17-cp313-cp313-win_amd64.whl", hash = "sha256:1886c3590b02f359ae991f1e76691064f167330eda4fbf22762127ce17d0eb48", size = 8043469, upload-time = "2026-04-22T20:14:24.86Z" },
- { url = "https://files.pythonhosted.org/packages/69/c0/8354baf835e1a04c4b9e11d253f82df7d625a9305e6a23a177fb895b1484/pydantic_monty-0.0.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:a166bd04d1996f0d144fbb5e1391cd1c0fbdacd4fa3b689dd48931388679fe98", size = 7341303, upload-time = "2026-04-22T20:14:35.653Z" },
- { url = "https://files.pythonhosted.org/packages/00/ac/d58221b5e17915421ca00bb08b805ac121b6accb194785d5422da4a2f5fc/pydantic_monty-0.0.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d82f319e3fd79707a7b81bbb68596509d14ac73502d2f0daf4ab5d281efdfbdc", size = 7318912, upload-time = "2026-04-22T20:13:59.966Z" },
- { url = "https://files.pythonhosted.org/packages/81/3f/8eeb8f652f6cd6e06a737aa9f00de2949e37a669b31756bc51b6182457b4/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f38b9875f7ff56fe69538b60b2ecbdcbe2b8b7780407ceb05fe0ee1414bf8d19", size = 7867027, upload-time = "2026-04-22T20:13:51.426Z" },
- { url = "https://files.pythonhosted.org/packages/55/ba/ec6620c27c8b4cada6ce53378c52c245e238e50a20b968cafdc1b8573c4e/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74a778bb5a4dcdbc85b3b9002f9a72a43fb6ffd88635bfdd502e8d3053008337", size = 7137542, upload-time = "2026-04-22T20:13:35.939Z" },
- { url = "https://files.pythonhosted.org/packages/41/78/5419785630511b54b15cfb094871bcb53ec9025ba8d91bd7ab5b22b6c98f/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e2ab074a65738e9c1b4be9a432e7ca1e9987a6706018dc7a5af6d4ce7cecdf3", size = 7450222, upload-time = "2026-04-22T20:13:53.378Z" },
- { url = "https://files.pythonhosted.org/packages/61/04/cec11fa96a47034da3c21af53e73f43d2270f5ce96cd710809859fe9c0b2/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00fd1cd28b4200c9ccd02629868486b366af1bfe1f0584d4c9e513b7a941a868", size = 7967405, upload-time = "2026-04-22T20:14:03.826Z" },
- { url = "https://files.pythonhosted.org/packages/81/e3/f2be0fb975100b6936ca36a8410098f10fab3b26729c0b0d1de2fac59ff3/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ea6684555bfd00cbe9d2df3e73caada3d82200c168c63cc475197b55b88401", size = 8199028, upload-time = "2026-04-22T20:13:26.802Z" },
- { url = "https://files.pythonhosted.org/packages/2c/43/358bdaa9c50d21fe4a25a71d43ce9af2d6796616fa47aca84f807433564e/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f804a03a3bbd0cf0ade1d4ce11b50ca6858e9c4440b27c746faf1d3c0a272954", size = 7749903, upload-time = "2026-04-22T20:15:09.626Z" },
- { url = "https://files.pythonhosted.org/packages/9a/da/8bcd0a78abf13edceca36aaf5c180fad963e4c2e042fd7247b9e96048306/pydantic_monty-0.0.17-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:b5476e6c08b86b0bea554b97ce9b142aac1177447f2d3c5751864b27991fd1b1", size = 7312704, upload-time = "2026-04-22T20:14:33.668Z" },
- { url = "https://files.pythonhosted.org/packages/88/49/5de8bb7f8b82c3ebb8f2485e0b7a40055b072193039d95dcb5d35fcba72c/pydantic_monty-0.0.17-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b5fcdcca45439844bee268686f37226dbf5803ec7a5945f5536c41419f151dac", size = 7768902, upload-time = "2026-04-22T20:14:29.389Z" },
- { url = "https://files.pythonhosted.org/packages/f0/7c/500a002f1a52f17c8b8a989875da3e1590c18ce95c89856aa967e2348a36/pydantic_monty-0.0.17-cp314-cp314-win32.whl", hash = "sha256:4dd3e6e80a415e7272f7a7583a4f8e045096653f6074e181117eb61fc8fe3b45", size = 7227049, upload-time = "2026-04-22T20:14:01.871Z" },
- { url = "https://files.pythonhosted.org/packages/5c/20/b8f552f2a863778f49ebb708f18aaf0bfb275480a48965786e06efacf1c4/pydantic_monty-0.0.17-cp314-cp314-win_amd64.whl", hash = "sha256:36a8090a628e8cf91df8f66c721a71050ac8f48473d4992b9afbd9585941a647", size = 8062999, upload-time = "2026-04-22T20:14:44.006Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/74/36d50926a7b53b85723960fad50b34b5fc8da79cc8f6091a1f1b44a02b79/pydantic_monty-0.0.18-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:857b62bfc6f06cd9853d4fc51011391e0431187fe9d08034ae24eafcb797c60a", size = 8464519, upload-time = "2026-05-29T08:30:49.301Z" },
+ { url = "https://files.pythonhosted.org/packages/28/7b/941e3c9c4816864a2c260df63d3be36c523022732154d2853e5376fcf1e1/pydantic_monty-0.0.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65918fac0835109de6f725069d0aa35b7454c26809634d5344d7b26686754381", size = 8719689, upload-time = "2026-05-29T08:30:27.115Z" },
+ { url = "https://files.pythonhosted.org/packages/31/20/84cfdf92732651e68aa52d846a22ae573294241b4aa75ae84c0b3d2782b0/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c5bee11eecbadf03b2e764feb11fdea12a6b176bf071bb2fa922a23a704a83b4", size = 9042115, upload-time = "2026-05-29T08:29:18.039Z" },
+ { url = "https://files.pythonhosted.org/packages/23/dc/e3dcdef2d0dc09751ed054c69c2363e05d94c985994197ce5748b22b8799/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de65d5a8c7ba74794d7f50dfa0b36014931abe2a5abe1a48778afc1bb7dd5d60", size = 8171553, upload-time = "2026-05-29T08:30:51.772Z" },
+ { url = "https://files.pythonhosted.org/packages/61/93/45d2b8867f74ddff0a45b96e78d1ff5bdd4bfcd68f6fd622009096b4324c/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8694f0897d611d6f81901eee31d5c73c6d677cd50efe95368b40e1dc1d034e8a", size = 8586169, upload-time = "2026-05-29T08:29:58.806Z" },
+ { url = "https://files.pythonhosted.org/packages/06/2c/e46629bf65a4017e905db9b87158253869d329cb884604be78e74c0e3d88/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dcd3286f6b74a959acd32cdb4c3f0a423f91ff6d7775a08315091766f74a76dc", size = 9181554, upload-time = "2026-05-29T08:31:03.712Z" },
+ { url = "https://files.pythonhosted.org/packages/20/f6/91af3acf83fe6b156134e90e7739ff167247d7a48aa53735b3b6a050a335/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa2cc2dda0c7a271c6b0792ce7e60dd0bc5114263b83dccb147c6d0c88d28614", size = 9286056, upload-time = "2026-05-29T08:30:17.643Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/71/1b008c633a4767e518e4aebfd79eb1c2c20259282853b6967373d70ca0f9/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e87e5953fe1ad15f9e67c5dc590ae240889da28bc84c344e255579d4f33281f5", size = 9266143, upload-time = "2026-05-29T08:30:01.364Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/c5/d2b44995729c884f682e499fea134f7b19883b3414c077431d80dc222802/pydantic_monty-0.0.18-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:83b82b7c235943081b31eb9a4c8a4af961640cfbc5b7d3a97dda1bf3efd83cff", size = 8350637, upload-time = "2026-05-29T08:30:20.061Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/7d/8326aca20b563cf656a2d7e52fca1ec98c9b2cde67eba06ecebffb5b73f7/pydantic_monty-0.0.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0526e5222cbb4cd0a253f49bfcf851dc87984b39d2a5e4eb041d8ce7d1b6987a", size = 8900794, upload-time = "2026-05-29T08:31:24.604Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/a6/f62f187a1327ae3bf101de44439b62508da7895ca63c38295115c12a1006/pydantic_monty-0.0.18-cp310-cp310-win32.whl", hash = "sha256:668a4502e9bd67c7bb5d2c4c9d153e9f798d4f5f452845b9a72aaa1f8ce86ab8", size = 8280979, upload-time = "2026-05-29T08:30:47.128Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/62/455b679f3b5c00caf362b2388d8a191889f2496f834500989be404175997/pydantic_monty-0.0.18-cp310-cp310-win_amd64.whl", hash = "sha256:12c2ac68f2a12ac68bcd51beb1bf6c2e5fd81061584fd5a826d3454fc9220e36", size = 9482422, upload-time = "2026-05-29T08:31:10.421Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/50/06720fb35b73993aa9964403eff1ab35b1d7bd0db1b1ee0633e19311e254/pydantic_monty-0.0.18-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5140382a6ea68778c76f04ccb91fdbfd1a77b8cae3a89534e23a0e5afaf21e75", size = 8464367, upload-time = "2026-05-29T08:29:46.855Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/8e/b3946ee663349fb35f9dceddf1aed394b8e5df1d8767b840844db9cee515/pydantic_monty-0.0.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421d1b7956e06a22dc13fe6a34bebf3a1bdde8cf78616eded018f7a9ca746295", size = 8718281, upload-time = "2026-05-29T08:31:06.121Z" },
+ { url = "https://files.pythonhosted.org/packages/36/3f/9fb2e8d0ed660d0e5b281316be0c1cb1a023b156c02a8dc8a2c3ec007af7/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6609de4408ad54387ecd0b3eedce796497ee72c6ec888074519afcd4f6959a81", size = 9041289, upload-time = "2026-05-29T08:29:33.062Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/5e/cb242ba7bd63985eee94f0dff8864002bb5ded2da7190e73786ddcd5b4e8/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2b37890d8a948606be184bd6e3fa4e445d26e3c7329c6a451a271bfc470f24", size = 8170676, upload-time = "2026-05-29T08:29:38.358Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/8d/d144775ea57b813e97aef9edaf5f867fb82960597af517125e1b513c983c/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:19b86e4fc4b73c2c925906bbc31488b9e8b99b54101f8ea7bbdccfd60ded38f0", size = 8585337, upload-time = "2026-05-29T08:31:27.206Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/81/6291a4871fbdb8dfa66d1b1f2406c11066757caa1c53092320e5d11ea49d/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4de83f38b3658152697c524ea87ce307a13701d807801c9be27870e837829a02", size = 9181594, upload-time = "2026-05-29T08:31:36.736Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/00/28879cee77e24f70c756c4603b4a21b013e601b7821bd07e06cf6718b75a/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3aaa4fb7af8fd84f42df5e1e43d7ff3eae7d81314580462c8ca716e7e6e361", size = 9285193, upload-time = "2026-05-29T08:30:58.727Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/07/52dece571ef47085d2f1053df4c1be8d5b42d8735da4797f5f79d650f81f/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d3dd72c195eca243b08c5d68b1f513124feaf22acb87b73cd8bfcdc3f6b4bb7", size = 9264997, upload-time = "2026-05-29T08:29:49.51Z" },
+ { url = "https://files.pythonhosted.org/packages/38/12/b010315be2927c5be43d3a4036cd6857d9981d5116efda5e40540f43a014/pydantic_monty-0.0.18-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b0409a5f314af54f704d73cd97fe2a0cc8ef2635952bf57c5879b9313281614", size = 8350432, upload-time = "2026-05-29T08:30:10.984Z" },
+ { url = "https://files.pythonhosted.org/packages/84/8b/9674a90269dc0f1a080e606cba642b256e138e7fcee1a3a0b55969946f83/pydantic_monty-0.0.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:790f169bb5700e3ab24a8d44fd7016d915c701e27bcd2feabe0de917306bbff0", size = 8900736, upload-time = "2026-05-29T08:29:42.508Z" },
+ { url = "https://files.pythonhosted.org/packages/28/6a/07351c22208814c466d9a26bab03642727e9f5570562cbd4fbde837e4644/pydantic_monty-0.0.18-cp311-cp311-win32.whl", hash = "sha256:3682f3bd67ef92ecd78a3f5f4efcd7659ba643aaca45412601a92691d6440250", size = 8280476, upload-time = "2026-05-29T08:31:17.814Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/de/937dcc0e828d324f037a5004f97c8ff245158fe735107023a10d8f672e32/pydantic_monty-0.0.18-cp311-cp311-win_amd64.whl", hash = "sha256:eecdf1175542ac2fd3f6a203c7744145be4e73e08755c6de1d35253dc6a872e7", size = 9480905, upload-time = "2026-05-29T08:30:15.287Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/d1/307df5ac3a694acc5922f00fc7ce96357ad4afaa41bbfeec0b8379bed6ec/pydantic_monty-0.0.18-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1030bd49b813e67aedf4f7bb3dd4cc9edaa203554b3b8fe11eeab6d61139229f", size = 8462571, upload-time = "2026-05-29T08:29:21.081Z" },
+ { url = "https://files.pythonhosted.org/packages/55/83/8ccf04b2f9642153702c6eb22d0a0abad57014fd85879ab1f6341b5a1946/pydantic_monty-0.0.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2988d3e511131680d9de60647bfe5c697b1e4e4cad474fecf1451c53314e8520", size = 8688756, upload-time = "2026-05-29T08:30:24.677Z" },
+ { url = "https://files.pythonhosted.org/packages/81/84/e3ce3294636b92a5eb238273026dd2825d97deac44f76e901990a4eeb306/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7d8d0f42162cb40da05f32d50d9d8d74411b3d4f1182117c8365f18457442c0d", size = 9046635, upload-time = "2026-05-29T08:30:06.38Z" },
+ { url = "https://files.pythonhosted.org/packages/de/b8/c7881620a812850772ae0924863d1399cbecb3e4c8c455a9c7a9c20b06f8/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c688dc7c7b28a2f389a61217bae1d50658e28f960abe33a254328cadc8a17a", size = 8171342, upload-time = "2026-05-29T08:29:44.773Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/ea/6d10ea1657e303295a75a3854f6dd6b378cbd501dcd1782844107b932acd/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f469293f5a776231b9617787a5dd6c58048c6568833ee6848338be6391f15449", size = 8591152, upload-time = "2026-05-29T08:31:29.944Z" },
+ { url = "https://files.pythonhosted.org/packages/93/fb/ab85c4676ccffd0f3b7f509a4c8b396b07c7860577def2f58a22b3fe8aef/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6e0cd4991947b8a47210985836f94c14139bc3ea06253d2f38d0d752b165517", size = 9183064, upload-time = "2026-05-29T08:31:12.776Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/12/11292178b487052f9e0a1ea7b3d17e1e3bfcba598fefce8cb9ed8712021e/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58b4b96863abbc0ffa5baf64779b3fbb6376cc763488b027ecaddb5052d5ff17", size = 9285440, upload-time = "2026-05-29T08:29:35.642Z" },
+ { url = "https://files.pythonhosted.org/packages/79/42/7afb8dde4414d84c042f2cc1b0870a7351cae2e4fbf3fef89b3aa683eca9/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1208bd5976c1254b2705559836511c86ea3cc51d9c6f688b1e3e22984715dbc", size = 9233438, upload-time = "2026-05-29T08:31:39.177Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/46/89124cf146725e354b44685b477da6b0b5dc07a8a3af2aec309e88c55405/pydantic_monty-0.0.18-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:977168253d8f6b49bb64f128d02c4b62ee8b435fd62876268377e1f2b00cc00f", size = 8351900, upload-time = "2026-05-29T08:31:08.348Z" },
+ { url = "https://files.pythonhosted.org/packages/00/c5/dda512f5a9c68242faea368844aacefb54c2a13f9b40bee5ab48ccdc78c5/pydantic_monty-0.0.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c21319a091dc1ff1fccb8647dae5bb543b3f528c556319ed15c7992dfa9b5e87", size = 8901559, upload-time = "2026-05-29T08:29:26.047Z" },
+ { url = "https://files.pythonhosted.org/packages/45/97/496655362d4bb6e74ff791cf40be6a502e794c296f0089912783325075a7/pydantic_monty-0.0.18-cp312-cp312-win32.whl", hash = "sha256:220fe77920af9033ae644887e747b68567df630b1a8afa39b0a830d84a3438b5", size = 8277428, upload-time = "2026-05-29T08:30:35.322Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/24/2913a50a9afbce681629408814ae94929589bed9aa347b176caf17842957/pydantic_monty-0.0.18-cp312-cp312-win_amd64.whl", hash = "sha256:f965a62993bd3fe7be94f99c86349d61d987b3d8cc07fb729d7d8af87c7d481d", size = 9453897, upload-time = "2026-05-29T08:31:15.481Z" },
+ { url = "https://files.pythonhosted.org/packages/70/86/5f1eb8b0743ba65821aa37285f131478672b2832baa08386e931c9e71969/pydantic_monty-0.0.18-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:765865634c2075ec816515db22acf0c71e42d25dcbf66638dc063d95c7d1a858", size = 8473615, upload-time = "2026-05-29T08:30:22.027Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/9c/7628423f955efb669d2cc1d3a8909bf8271b543ce27036e18229ad0e51e8/pydantic_monty-0.0.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d47976a18e3e3da0e86f8cf6068fc8a125930422dc10d3d3bf0b5410a9e9282e", size = 8689116, upload-time = "2026-05-29T08:29:30.906Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/dd/ec6cbbe997205063c679ef17220a48fcb0a4c319fc336ca81a3c7c248c6d/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c6bc7a776d9d97b899054263c0f0c7316523571da02b4c2a6d2ecd4793482e0", size = 9045884, upload-time = "2026-05-29T08:30:53.831Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/9c/51f8ffa4340bc1986eb9240b0756724f5fdf3c463d6d66c8cc8450e1446d/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb84fe51e3f6e00a0cc9628e0acf5904d982c8cc85d4db42b7532d071602f703", size = 8178458, upload-time = "2026-05-29T08:30:09.015Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ec/7eb84aeb86631571f9acffc91552217dc2b524b00db37b8d10517df467d1/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a2e86f3ba67b094d498bc8b071d5e3b8b034bb9f3006216a357c5f71d49d6132", size = 8591295, upload-time = "2026-05-29T08:29:23.357Z" },
+ { url = "https://files.pythonhosted.org/packages/da/69/d5210208fa116593bd81789e2e5abb6222d38087c9c1879e18f7e7620275/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb8a412aa0336d4e0334a4e05a54b391d91fa334020bd295a280285ba17ab5ca", size = 9184647, upload-time = "2026-05-29T08:29:51.852Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/74/4d95c8f65072964c4cb798dbe87d2e1c1349607ab5905874bfa8a0b94de1/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1056ce3acef60ab880314caf97775c5ea41b30f9306d0dd28cefeffd42dda366", size = 9291637, upload-time = "2026-05-29T08:31:19.966Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/40/5817780313a3e089ca6f860fbdc836d3aa33790eb72c2e8fe2edc877820e/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9593caa45b68fd07ac67bea9974effbe2a1c5453d8a106b913596b0dff6d8471", size = 9233863, upload-time = "2026-05-29T08:31:42.838Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/55/f77565c5797502c7ba995dc23a26759cb33023590f9f2926bc4e8ab87afe/pydantic_monty-0.0.18-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e15e18ed27a17ee607ad3bcbf82f25a9ec4d496ff493ff64cdabb83ca2174cec", size = 8358264, upload-time = "2026-05-29T08:31:32.531Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/1f/c700eb800868d1be4078a99cb00e23fb7e5d8760c8e83b729bba27b5bf92/pydantic_monty-0.0.18-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:96d75a418d96640ff0c7f354a78fc4470a2d0f40ba69e886c2f32756d594d9a0", size = 8906664, upload-time = "2026-05-29T08:30:04.055Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/52/1b4599d5a6dccc65d46956431cfdf5a46df4a18005a93ffec4aab56a47b8/pydantic_monty-0.0.18-cp313-cp313-win32.whl", hash = "sha256:5cd5ff08e6749b3a4a2192856861b36feee8575e2cf81bd6bd1d8b4b39ac630e", size = 8276949, upload-time = "2026-05-29T08:30:12.968Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/eb/54c9011e2ef5e1358512ea23bb4a862b4f8fdda2d2f951a58854ff55ee3e/pydantic_monty-0.0.18-cp313-cp313-win_amd64.whl", hash = "sha256:52ce98be1e5bf76974597234ec857b7a6ef99374a036860cd2e2e1bd75c18f1e", size = 9453909, upload-time = "2026-05-29T08:31:01.275Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/04/e6462c2d4097189fc4af62b84273d6ef0a69473cbdf1c7f158d8cec25c11/pydantic_monty-0.0.18-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8c38add825895ecfde75f3272126f07dd94f2db08440f165e76d7e321feec8da", size = 8473729, upload-time = "2026-05-29T08:30:32.648Z" },
+ { url = "https://files.pythonhosted.org/packages/65/ff/6aca0ddd5c074b2757dd992b38e57f5e6b21ec0631007ec2d1b4dcbd3bff/pydantic_monty-0.0.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:186eaa80945c4a5bb19beba54471d423bffcf5daf64f93029b2d5df1939e201f", size = 8702897, upload-time = "2026-05-29T08:29:56.669Z" },
+ { url = "https://files.pythonhosted.org/packages/29/37/d56705a23d7c5ff5112f5f82d56e70a9073a46d6ebfdbce09bcb31a52921/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c7a568fd6db2389743d0d28f355273c0d6d2009c5252c0971dcb1e5629e60d4f", size = 9046049, upload-time = "2026-05-29T08:30:56.314Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/00/82a6ddb1ca7bf2b1ef4b1751d960671324f3a0a498fc80d335f3f0962176/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:324443ea73eb70bfd57b34e554278f52501592c4580edc6b9708b0d3d6a42f44", size = 8178495, upload-time = "2026-05-29T08:31:34.62Z" },
+ { url = "https://files.pythonhosted.org/packages/71/9b/1a1aab97a113d718d6e6db9a7e5ad2fb9fd9cde8623d4885188983fa349b/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:936438027363474eecc5573eb635d1c5f3bf061ad02334d5b545a132fbb76e45", size = 8593356, upload-time = "2026-05-29T08:30:37.618Z" },
+ { url = "https://files.pythonhosted.org/packages/65/89/0f5212ccae4c29fa85c86dea553e45cf4729f94eba47187c747d44f7c890/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f700d2c7139f44ac29f51301c835a23e6c53402984cb23f3e3214e47df7ddaa3", size = 9184371, upload-time = "2026-05-29T08:29:28.574Z" },
+ { url = "https://files.pythonhosted.org/packages/26/1a/a2f3f0016a1326ef50d732f53bd8f447b3535fdb59a40287e77d0914935f/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10abc11ae00d712b866b2a64e6a0f34c6a7d5f99903228f4699eeb4ced50299a", size = 9292432, upload-time = "2026-05-29T08:30:30.051Z" },
+ { url = "https://files.pythonhosted.org/packages/78/55/8bc4f8924c8bfd366b2c524a19083f86bb457c61f56c47e4ae4bd607536e/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8183aa8e2420aa4c1924cc05b87c8143f953b7c173f240cb7cf20e0b3f865cdb", size = 9247001, upload-time = "2026-05-29T08:30:40.061Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/5e/f6ae7d18cfc058f4df49765420800dd5d7de3adbba6be864a8bc919847d2/pydantic_monty-0.0.18-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:03fccf00fd925b616e0b7ce59c354f3fa1e50eb2d511391e260e28793b5d3b0c", size = 8357641, upload-time = "2026-05-29T08:29:40.372Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/d3/166961ca42ad855b7a2dd50d494be26ff0222b21b68750081962fb4568f9/pydantic_monty-0.0.18-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:d9dc4185bad6ca7f38d2d71b9d8ed2d68e48e4c4f0ccc89cd0188c08469bda7d", size = 8905773, upload-time = "2026-05-29T08:30:43.535Z" },
+ { url = "https://files.pythonhosted.org/packages/df/88/d8bd8e82ca624ca6bebe9ebfb6cfc461214303158ba735751a6c2277043e/pydantic_monty-0.0.18-cp314-cp314-win32.whl", hash = "sha256:4840805ecfe5a38c07126f02181d907c687ca765a73aaabc2b128184390a2c52", size = 8277373, upload-time = "2026-05-29T08:31:22.247Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/0e/c395b22ddc32c746d7e2d271dd18bb585289576bb67483435e25643b7ecd/pydantic_monty-0.0.18-cp314-cp314-win_amd64.whl", hash = "sha256:83b6e2b73b0fa60c5641ecb6e8b588840023163ca6ab8b3e5da7ad088390ee7c", size = 9467375, upload-time = "2026-05-29T08:29:54.227Z" },
]
[[package]]
From 10b158baf33f5e7f8b4f616f115d54739fe0fa88 Mon Sep 17 00:00:00 2001
From: Martin Styk
Date: Sun, 2 Aug 2026 15:42:06 +0200
Subject: [PATCH 14/32] Forward enable_cimd to Auth0, AWS Cognito and OCI
providers (#4719)
Signed-off-by: Martin Styk
---
fastmcp_slim/fastmcp/server/auth/providers/auth0.py | 4 ++++
fastmcp_slim/fastmcp/server/auth/providers/aws.py | 4 ++++
fastmcp_slim/fastmcp/server/auth/providers/oci.py | 4 ++++
3 files changed, 12 insertions(+)
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/auth0.py b/fastmcp_slim/fastmcp/server/auth/providers/auth0.py
index e6a939b51..0a3120a2b 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/auth0.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/auth0.py
@@ -104,6 +104,7 @@ class Auth0Provider(OIDCProxy):
fallback_refresh_token_expiry_seconds: int | None = None,
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
+ enable_cimd: bool = True,
) -> None:
"""Initialize Auth0 OAuth provider.
@@ -148,6 +149,8 @@ class Auth0Provider(OIDCProxy):
refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details.
token_expiry_threshold_seconds: Number of seconds before actual expiry to
treat a token as expired, refreshing early to avoid races. Defaults to 0.
+ enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
+ client IDs (default True). Set to False to disable.
"""
# Parse scopes if provided as string
auth0_required_scopes = (
@@ -174,6 +177,7 @@ class Auth0Provider(OIDCProxy):
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds,
token_expiry_threshold_seconds=token_expiry_threshold_seconds,
+ enable_cimd=enable_cimd,
)
logger.debug(
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/aws.py b/fastmcp_slim/fastmcp/server/auth/providers/aws.py
index 7a3167ca6..01b1bcbd5 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/aws.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/aws.py
@@ -144,6 +144,7 @@ class AWSCognitoProvider(OIDCProxy):
fallback_refresh_token_expiry_seconds: int | None = None,
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
+ enable_cimd: bool = True,
):
"""Initialize AWS Cognito OAuth provider.
@@ -188,6 +189,8 @@ class AWSCognitoProvider(OIDCProxy):
refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details.
token_expiry_threshold_seconds: Number of seconds before actual expiry to
treat a token as expired, refreshing early to avoid races. Defaults to 0.
+ enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
+ client IDs (default True). Set to False to disable.
"""
# Parse scopes if provided as string
required_scopes_final = (
@@ -223,6 +226,7 @@ class AWSCognitoProvider(OIDCProxy):
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds,
token_expiry_threshold_seconds=token_expiry_threshold_seconds,
+ enable_cimd=enable_cimd,
)
logger.debug(
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/oci.py b/fastmcp_slim/fastmcp/server/auth/providers/oci.py
index ffd98ed09..613efd69b 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/oci.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/oci.py
@@ -141,6 +141,7 @@ class OCIProvider(OIDCProxy):
fallback_refresh_token_expiry_seconds: int | None = None,
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
+ enable_cimd: bool = True,
) -> None:
"""Initialize OCI OIDC provider.
@@ -174,6 +175,8 @@ class OCIProvider(OIDCProxy):
refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details.
token_expiry_threshold_seconds: Number of seconds before actual expiry to
treat a token as expired, refreshing early to avoid races. Defaults to 0.
+ enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
+ client IDs (default True). Set to False to disable.
"""
# Parse scopes if provided as string
oci_required_scopes = (
@@ -200,6 +203,7 @@ class OCIProvider(OIDCProxy):
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds,
token_expiry_threshold_seconds=token_expiry_threshold_seconds,
+ enable_cimd=enable_cimd,
)
logger.debug(
From c428a08feae9f0dd84db09c20cf1e745069d43d8 Mon Sep 17 00:00:00 2001
From: Shuying <46500487+ShuyingZhang@users.noreply.github.com>
Date: Sun, 2 Aug 2026 08:42:26 -0500
Subject: [PATCH 15/32] fix: preserve valid servers during CLI discovery
(#4714)
Co-authored-by: Shuying
---
fastmcp_slim/fastmcp/cli/discovery.py | 39 ++++++++++++++++-----------
tests/cli/test_discovery.py | 24 +++++++++++++++++
2 files changed, 48 insertions(+), 15 deletions(-)
diff --git a/fastmcp_slim/fastmcp/cli/discovery.py b/fastmcp_slim/fastmcp/cli/discovery.py
index ff3c616ff..fcc6a49d5 100644
--- a/fastmcp_slim/fastmcp/cli/discovery.py
+++ b/fastmcp_slim/fastmcp/cli/discovery.py
@@ -97,24 +97,33 @@ def _parse_mcp_servers(
if not servers_dict:
return []
- normalized = {
- name: _normalize_server_entry(entry)
- for name, entry in servers_dict.items()
- if isinstance(entry, dict)
- }
+ discovered: list[DiscoveredServer] = []
+ for name, entry in servers_dict.items():
+ if not isinstance(entry, dict):
+ continue
- try:
- config = MCPConfig.from_dict({"mcpServers": normalized})
- except Exception as exc:
- logger.warning("Could not parse MCP servers from %s: %s", config_path, exc)
- return []
+ normalized = _normalize_server_entry(entry)
+ try:
+ config = MCPConfig.from_dict({"mcpServers": {name: normalized}})
+ except Exception as exc:
+ logger.warning(
+ "Could not parse MCP server %r from %s: %s",
+ name,
+ config_path,
+ exc,
+ )
+ continue
- return [
- DiscoveredServer(
- name=name, source=source, config=server, config_path=config_path
+ discovered.append(
+ DiscoveredServer(
+ name=name,
+ source=source,
+ config=config.mcpServers[name],
+ config_path=config_path,
+ )
)
- for name, server in config.mcpServers.items()
- ]
+
+ return discovered
def _parse_mcp_config(path: Path, source: str) -> list[DiscoveredServer]:
diff --git a/tests/cli/test_discovery.py b/tests/cli/test_discovery.py
index 102bbf440..6318cc9f7 100644
--- a/tests/cli/test_discovery.py
+++ b/tests/cli/test_discovery.py
@@ -140,6 +140,30 @@ class TestParseMcpConfig:
servers = _parse_mcp_config(path, "test")
assert servers == []
+ def test_invalid_server_does_not_hide_valid_servers(
+ self, tmp_path: Path, caplog: pytest.LogCaptureFixture
+ ):
+ path = tmp_path / "config.json"
+ _write_config(
+ path,
+ {
+ "mcpServers": {
+ "working": {
+ "command": "python",
+ "args": ["server.py"],
+ },
+ "broken": {
+ "args": ["missing-command.py"],
+ },
+ }
+ },
+ )
+
+ servers = _parse_mcp_config(path, "test")
+
+ assert [server.name for server in servers] == ["working"]
+ assert "broken" in caplog.text
+
def test_remote_server(self, tmp_path: Path):
path = tmp_path / "config.json"
_write_config(path, _REMOTE_CONFIG)
From 34bdd480c91fef4482b184fcc637e574edcfaecd Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 2 Aug 2026 14:11:26 -0400
Subject: [PATCH 16/32] Improve HTTP server startup performance (#4729)
---
fastmcp_slim/fastmcp/server/__init__.py | 28 ++-
fastmcp_slim/fastmcp/server/context.py | 19 +-
fastmcp_slim/fastmcp/server/event_store.py | 55 +-----
fastmcp_slim/fastmcp/server/http.py | 2 +-
.../fastmcp/server/mixins/transport.py | 2 +-
.../local_provider/decorators/tools.py | 18 +-
fastmcp_slim/fastmcp/server/server.py | 30 +++-
.../server/session_scoped_event_store.py | 68 +++++++
fastmcp_slim/fastmcp/tools/base.py | 47 +++--
.../fastmcp/tools/function_parsing.py | 14 +-
.../fastmcp/utilities/docstring_parsing.py | 6 +-
fastmcp_slim/fastmcp/utilities/json_schema.py | 11 +-
fastmcp_slim/fastmcp/utilities/prefab.py | 74 ++++++++
scripts/benchmark_http_startup.py | 169 ++++++++++++++++++
tests/server/http/test_startup_imports.py | 73 ++++++++
15 files changed, 488 insertions(+), 128 deletions(-)
create mode 100644 fastmcp_slim/fastmcp/server/session_scoped_event_store.py
create mode 100644 fastmcp_slim/fastmcp/utilities/prefab.py
create mode 100644 scripts/benchmark_http_startup.py
create mode 100644 tests/server/http/test_startup_imports.py
diff --git a/fastmcp_slim/fastmcp/server/__init__.py b/fastmcp_slim/fastmcp/server/__init__.py
index d6edbc4f1..63c1f0351 100644
--- a/fastmcp_slim/fastmcp/server/__init__.py
+++ b/fastmcp_slim/fastmcp/server/__init__.py
@@ -1,17 +1,31 @@
import importlib
+from typing import TYPE_CHECKING
from fastmcp import _install_hints
-try:
- from .context import Context
- from .server import FastMCP, create_proxy
-except ImportError as exc:
- raise ImportError(_install_hints.SERVER_SUPPORT) from exc
+if TYPE_CHECKING:
+ from .context import Context as Context
+ from .server import FastMCP as FastMCP
+ from .server import create_proxy as create_proxy
def __getattr__(name: str) -> object:
- if name == "dependencies":
- return importlib.import_module("fastmcp.server.dependencies")
+ if name in {"context", "dependencies"}:
+ return importlib.import_module(f"fastmcp.server.{name}")
+ if name == "Context":
+ try:
+ from .context import Context
+ except ImportError as exc:
+ raise ImportError(_install_hints.SERVER_SUPPORT) from exc
+
+ return Context
+ if name in {"FastMCP", "create_proxy"}:
+ try:
+ from .server import FastMCP, create_proxy
+ except ImportError as exc:
+ raise ImportError(_install_hints.SERVER_SUPPORT) from exc
+
+ return FastMCP if name == "FastMCP" else create_proxy
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/fastmcp_slim/fastmcp/server/context.py b/fastmcp_slim/fastmcp/server/context.py
index bd089289a..3373e09da 100644
--- a/fastmcp_slim/fastmcp/server/context.py
+++ b/fastmcp_slim/fastmcp/server/context.py
@@ -10,7 +10,6 @@ from logging import Logger
from typing import Any, Literal, cast, overload
import mcp_types
-from key_value.aio.errors import SerializationError
from mcp import LoggingLevel, ServerSession
from mcp.server.context import ServerRequestContext
from mcp_types import (
@@ -1146,10 +1145,9 @@ class Context:
value=StateValue(value=value),
ttl=self._STATE_TTL_SECONDS,
)
- except (ValueError, SerializationError) as e:
+ except ValueError as e:
# Pydantic raises PydanticSerializationError (a ValueError) and the
- # key_value library raises SerializationError; both carry "serialize"
- # in the message. Other ValueErrors propagate unchanged.
+ # message carries "serialize". Other ValueErrors propagate unchanged.
if "serialize" in str(e).lower():
raise TypeError(
f"Value for state key {key!r} is not serializable. "
@@ -1158,6 +1156,19 @@ class Context:
f"request-scoped and will not persist across requests."
) from e
raise
+ except Exception as e:
+ # Import the optional storage implementation only on its error path,
+ # rather than adding the key_value package to every server startup.
+ from key_value.aio.errors import SerializationError
+
+ if not isinstance(e, SerializationError):
+ raise
+ raise TypeError(
+ f"Value for state key {key!r} is not serializable. "
+ f"Use set_state({key!r}, value, serializable=False) to store "
+ f"non-serializable values. Note: non-serializable state is "
+ f"request-scoped and will not persist across requests."
+ ) from e
async def get_state(self, key: str) -> Any:
"""Get a value from the state store.
diff --git a/fastmcp_slim/fastmcp/server/event_store.py b/fastmcp_slim/fastmcp/server/event_store.py
index 86897aac7..bdc504865 100644
--- a/fastmcp_slim/fastmcp/server/event_store.py
+++ b/fastmcp_slim/fastmcp/server/event_store.py
@@ -18,6 +18,9 @@ from mcp.server.streamable_http import EventStore as SDKEventStore
from mcp_types import JSONRPCMessage
from pydantic import TypeAdapter
+from fastmcp.server.session_scoped_event_store import (
+ SessionScopedEventStore as SessionScopedEventStore,
+)
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import FastMCPBaseModel
@@ -42,58 +45,6 @@ class StreamEventList(FastMCPBaseModel):
event_ids: list[str]
-class SessionScopedEventStore(SDKEventStore):
- """EventStore adapter that isolates stream IDs to one transport session."""
-
- def __init__(self, event_store: SDKEventStore, session_id: str):
- self._event_store = event_store
- self._stream_prefix = f"{len(session_id)}:{session_id}:"
-
- def _scope_stream_id(self, stream_id: StreamId) -> StreamId:
- return f"{self._stream_prefix}{stream_id}"
-
- def _unscope_stream_id(self, stream_id: StreamId) -> StreamId | None:
- if not stream_id.startswith(self._stream_prefix):
- return None
- return stream_id[len(self._stream_prefix) :]
-
- async def store_event(
- self, stream_id: StreamId, message: JSONRPCMessage | None
- ) -> EventId:
- return await self._event_store.store_event(
- self._scope_stream_id(stream_id), message
- )
-
- async def replay_events_after(
- self,
- last_event_id: EventId,
- send_callback: EventCallback,
- ) -> StreamId | None:
- replayed_events: list[EventMessage] = []
-
- async def buffer_event(event: EventMessage) -> None:
- replayed_events.append(event)
-
- scoped_stream_id = await self._event_store.replay_events_after(
- last_event_id, buffer_event
- )
- if scoped_stream_id is None:
- return None
-
- stream_id = self._unscope_stream_id(scoped_stream_id)
- if stream_id is None:
- logger.warning(
- "Event ID %s does not belong to this session-scoped event store",
- last_event_id,
- )
- return None
-
- for event in replayed_events:
- await send_callback(event)
-
- return stream_id
-
-
class EventStore(SDKEventStore):
"""EventStore implementation backed by AsyncKeyValue.
diff --git a/fastmcp_slim/fastmcp/server/http.py b/fastmcp_slim/fastmcp/server/http.py
index f196627b7..7bd8fff99 100644
--- a/fastmcp_slim/fastmcp/server/http.py
+++ b/fastmcp_slim/fastmcp/server/http.py
@@ -27,7 +27,7 @@ from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send
from fastmcp.server.auth import AuthProvider
from fastmcp.server.auth.middleware import RequireAuthMiddleware
-from fastmcp.server.event_store import SessionScopedEventStore
+from fastmcp.server.session_scoped_event_store import SessionScopedEventStore
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
diff --git a/fastmcp_slim/fastmcp/server/mixins/transport.py b/fastmcp_slim/fastmcp/server/mixins/transport.py
index ddc96db20..13bef1ced 100644
--- a/fastmcp_slim/fastmcp/server/mixins/transport.py
+++ b/fastmcp_slim/fastmcp/server/mixins/transport.py
@@ -11,13 +11,13 @@ import anyio
import uvicorn
from mcp.server.lowlevel.server import NotificationOptions
from mcp.server.stdio import stdio_server
+from mcp.server.streamable_http import EventStore
from starlette.middleware import Middleware as ASGIMiddleware
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import BaseRoute, Route
import fastmcp
-from fastmcp.server.event_store import EventStore
from fastmcp.server.http import (
HostOriginProtection,
StarletteWithLifespan,
diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py
index 77081165a..3f4b322a5 100644
--- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py
+++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py
@@ -28,17 +28,10 @@ from mcp_types import ToolAnnotations
from fastmcp.tools.base import Tool
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.utilities.authorization import AuthCheck
+from fastmcp.utilities.prefab import is_prefab_type, prefab_available
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import AnyFunction, NotSet, NotSetT
-try:
- from prefab_ui.app import PrefabApp as _PrefabApp
- from prefab_ui.components.base import Component as _PrefabComponent
-
- _HAS_PREFAB = True
-except ImportError:
- _HAS_PREFAB = False
-
if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
@@ -51,7 +44,7 @@ PREFAB_RENDERER_URI = "ui://prefab/renderer.html"
def _is_prefab_type(tp: Any) -> bool:
"""Check if *tp* is or contains a prefab type, recursing through unions and Annotated."""
- if isinstance(tp, type) and issubclass(tp, (_PrefabApp, _PrefabComponent)):
+ if is_prefab_type(tp):
return True
origin = get_origin(tp)
if origin is Union or origin is types.UnionType or origin is Annotated:
@@ -61,7 +54,7 @@ def _is_prefab_type(tp: Any) -> bool:
def _has_prefab_return_type(tool: Tool) -> bool:
"""Check if a FunctionTool's return type annotation is a prefab type."""
- if not _HAS_PREFAB or not isinstance(tool, FunctionTool):
+ if not isinstance(tool, FunctionTool):
return False
rt = tool.return_type
if rt is None or rt is inspect.Parameter.empty:
@@ -94,13 +87,10 @@ def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None:
it. ``app=True``, return-type inference, and ``PrefabAppConfig`` all
funnel through the same placeholder marker.
"""
- if not _HAS_PREFAB:
- return
-
meta = tool.meta or {}
ui = meta.get("ui")
- if ui is True:
+ if ui is True and prefab_available():
# Explicit app=True: stamp the placeholder so the synthesizer finds it.
_stamp_prefab_marker(tool)
elif ui is None and _has_prefab_return_type(tool):
diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py
index 6fb97bace..a25a78712 100644
--- a/fastmcp_slim/fastmcp/server/server.py
+++ b/fastmcp_slim/fastmcp/server/server.py
@@ -20,9 +20,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import httpx2
import mcp_types
-from key_value.aio.adapters.pydantic import PydanticAdapter
-from key_value.aio.protocols import AsyncKeyValue
-from key_value.aio.stores.memory import MemoryStore
from mcp.server.lowlevel.server import LifespanResultT
from mcp.server.request_state import RequestStateSecurity
from mcp.shared.exceptions import MCPError
@@ -101,6 +98,9 @@ from fastmcp.utilities.versions import (
)
if TYPE_CHECKING:
+ from key_value.aio.adapters.pydantic import PydanticAdapter
+ from key_value.aio.protocols import AsyncKeyValue
+
from fastmcp.client import Client
from fastmcp.client.client import SDKServer
from fastmcp.client.transports import ClientTransport, ClientTransportT
@@ -333,12 +333,8 @@ class FastMCP(
self._additional_http_routes: list[BaseRoute] = []
# Session-scoped state store (shared across all requests)
- self._state_storage: AsyncKeyValue = session_state_store or MemoryStore()
- self._state_store: PydanticAdapter[StateValue] = PydanticAdapter[StateValue](
- key_value=self._state_storage,
- pydantic_model=StateValue,
- default_collection="fastmcp_state",
- )
+ self._state_storage: AsyncKeyValue | None = session_state_store
+ self.__state_store: PydanticAdapter[StateValue] | None = None
# Create LocalProvider for local components
self._local_provider: LocalProvider = LocalProvider(
@@ -496,6 +492,22 @@ class FastMCP(
def __repr__(self) -> str:
return f"{type(self).__name__}({self.name!r})"
+ @property
+ def _state_store(self) -> PydanticAdapter[StateValue]:
+ """Create the session-state adapter only when state is first used."""
+ if self.__state_store is None:
+ from key_value.aio.adapters.pydantic import PydanticAdapter
+ from key_value.aio.stores.memory import MemoryStore
+
+ if self._state_storage is None:
+ self._state_storage = MemoryStore()
+ self.__state_store = PydanticAdapter[StateValue](
+ key_value=self._state_storage,
+ pydantic_model=StateValue,
+ default_collection="fastmcp_state",
+ )
+ return self.__state_store
+
@property
def name(self) -> str:
return self._mcp_server.name
diff --git a/fastmcp_slim/fastmcp/server/session_scoped_event_store.py b/fastmcp_slim/fastmcp/server/session_scoped_event_store.py
new file mode 100644
index 000000000..9d0adada4
--- /dev/null
+++ b/fastmcp_slim/fastmcp/server/session_scoped_event_store.py
@@ -0,0 +1,68 @@
+"""Lightweight session scoping for Streamable HTTP event stores."""
+
+from __future__ import annotations
+
+from mcp.server.streamable_http import (
+ EventCallback,
+ EventId,
+ EventMessage,
+ EventStore,
+ StreamId,
+)
+from mcp_types import JSONRPCMessage
+
+from fastmcp.utilities.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class SessionScopedEventStore(EventStore):
+ """EventStore adapter that isolates stream IDs to one transport session."""
+
+ def __init__(self, event_store: EventStore, session_id: str):
+ self._event_store = event_store
+ self._stream_prefix = f"{len(session_id)}:{session_id}:"
+
+ def _scope_stream_id(self, stream_id: StreamId) -> StreamId:
+ return f"{self._stream_prefix}{stream_id}"
+
+ def _unscope_stream_id(self, stream_id: StreamId) -> StreamId | None:
+ if not stream_id.startswith(self._stream_prefix):
+ return None
+ return stream_id[len(self._stream_prefix) :]
+
+ async def store_event(
+ self, stream_id: StreamId, message: JSONRPCMessage | None
+ ) -> EventId:
+ return await self._event_store.store_event(
+ self._scope_stream_id(stream_id), message
+ )
+
+ async def replay_events_after(
+ self,
+ last_event_id: EventId,
+ send_callback: EventCallback,
+ ) -> StreamId | None:
+ replayed_events: list[EventMessage] = []
+
+ async def buffer_event(event: EventMessage) -> None:
+ replayed_events.append(event)
+
+ scoped_stream_id = await self._event_store.replay_events_after(
+ last_event_id, buffer_event
+ )
+ if scoped_stream_id is None:
+ return None
+
+ stream_id = self._unscope_stream_id(scoped_stream_id)
+ if stream_id is None:
+ logger.warning(
+ "Event ID %s does not belong to this session-scoped event store",
+ last_event_id,
+ )
+ return None
+
+ for event in replayed_events:
+ await send_callback(event)
+
+ return stream_id
diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py
index 5e02246dd..ccba1f365 100644
--- a/fastmcp_slim/fastmcp/tools/base.py
+++ b/fastmcp_slim/fastmcp/tools/base.py
@@ -26,6 +26,11 @@ from pydantic.json_schema import SkipJsonSchema
from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.prefab import (
+ is_prefab_app,
+ is_prefab_component,
+ prefab_app_from_component,
+)
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import (
Audio,
@@ -35,14 +40,6 @@ from fastmcp.utilities.types import (
NotSetT,
)
-try:
- from prefab_ui.app import PrefabApp as _PrefabApp
- from prefab_ui.components.base import Component as _PrefabComponent
-
- _HAS_PREFAB = True
-except ImportError:
- _HAS_PREFAB = False
-
if TYPE_CHECKING:
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
@@ -128,13 +125,12 @@ class ToolResult(BaseModel):
if structured_content is not None:
# Convert Prefab types to their wire-format envelope before
# generic serialization, so the renderer gets the right shape.
- if _HAS_PREFAB:
- if isinstance(structured_content, _PrefabApp):
- structured_content = _prefab_to_json(structured_content)
- elif isinstance(structured_content, _PrefabComponent):
- structured_content = _prefab_to_json(
- _PrefabApp(view=structured_content)
- )
+ if is_prefab_app(structured_content):
+ structured_content = _prefab_to_json(structured_content)
+ elif is_prefab_component(structured_content):
+ structured_content = _prefab_to_json(
+ prefab_app_from_component(structured_content)
+ )
try:
structured_content = pydantic_core.to_jsonable_python(
@@ -379,17 +375,16 @@ class Tool(FastMCPComponent):
if isinstance(raw_value, CallToolResult):
return ToolResult.from_mcp_result(raw_value)
- if _HAS_PREFAB:
- if isinstance(raw_value, _PrefabApp):
- return _prefab_to_tool_result(
- raw_value,
- fastmcp_app_name=_get_fastmcp_app_name(self),
- )
- if isinstance(raw_value, _PrefabComponent):
- return _prefab_to_tool_result(
- _PrefabApp(view=raw_value),
- fastmcp_app_name=_get_fastmcp_app_name(self),
- )
+ if is_prefab_app(raw_value):
+ return _prefab_to_tool_result(
+ raw_value,
+ fastmcp_app_name=_get_fastmcp_app_name(self),
+ )
+ if is_prefab_component(raw_value):
+ return _prefab_to_tool_result(
+ prefab_app_from_component(raw_value),
+ fastmcp_app_name=_get_fastmcp_app_name(self),
+ )
content = _convert_to_content(raw_value)
diff --git a/fastmcp_slim/fastmcp/tools/function_parsing.py b/fastmcp_slim/fastmcp/tools/function_parsing.py
index cc42550fc..5972e7d0d 100644
--- a/fastmcp_slim/fastmcp/tools/function_parsing.py
+++ b/fastmcp_slim/fastmcp/tools/function_parsing.py
@@ -18,6 +18,7 @@ from fastmcp.tools.base import ToolResult, resolve_serialize_by_alias
from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.prefab import is_prefab_type
from fastmcp.utilities.types import (
Audio,
File,
@@ -27,14 +28,6 @@ from fastmcp.utilities.types import (
replace_type,
)
-try:
- from prefab_ui.app import PrefabApp as _PrefabApp
- from prefab_ui.components.base import Component as _PrefabComponent
-
- _PREFAB_TYPES: tuple[type, ...] = (_PrefabApp, _PrefabComponent)
-except ImportError:
- _PREFAB_TYPES = ()
-
def _contains_bytes_type(tp: Any) -> bool:
"""Check if *tp* is or contains bytes, recursing through unions and Annotated."""
@@ -48,7 +41,7 @@ def _contains_bytes_type(tp: Any) -> bool:
def _contains_prefab_type(tp: Any) -> bool:
"""Check if *tp* is or contains a prefab type, recursing through unions and Annotated."""
- if isinstance(tp, type) and issubclass(tp, _PREFAB_TYPES):
+ if is_prefab_type(tp):
return True
origin = get_origin(tp)
if origin is Union or origin is types.UnionType or origin is Annotated:
@@ -405,7 +398,7 @@ class ParsedFunction:
# so we handle subclass matching explicitly here. We also need
# to handle composite types like ``Column | None`` and
# ``Annotated[PrefabApp, ...]`` by recursing into their args.
- if _PREFAB_TYPES and _contains_prefab_type(output_type):
+ if _contains_prefab_type(output_type):
output_type = _UnserializableType
# ToolResult subclasses should suppress schema generation just
@@ -450,7 +443,6 @@ class ParsedFunction:
# A guard tool's suspend signal is control flow, not
# output data (any residual bare arm is suppressed).
mcp_types.InputRequiredResult,
- *_PREFAB_TYPES,
),
_UnserializableType,
),
diff --git a/fastmcp_slim/fastmcp/utilities/docstring_parsing.py b/fastmcp_slim/fastmcp/utilities/docstring_parsing.py
index babcb1e96..111f37657 100644
--- a/fastmcp_slim/fastmcp/utilities/docstring_parsing.py
+++ b/fastmcp_slim/fastmcp/utilities/docstring_parsing.py
@@ -14,8 +14,6 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
-from griffe import Docstring, DocstringSectionKind
-
_PARSERS = ("google", "numpy", "sphinx")
logger = logging.getLogger("griffe")
@@ -43,6 +41,10 @@ def parse_docstring(fn: Callable[..., Any]) -> ParsedDocstring:
if not doc:
return ParsedDocstring()
+ # Griffe is only needed for functions that actually have docstrings. This
+ # keeps its parser and model graph out of ordinary server startup.
+ from griffe import Docstring, DocstringSectionKind
+
# Try each parser and use the first one that finds parameters.
for parser in _PARSERS:
docstring = Docstring(doc, lineno=1, parser=parser)
diff --git a/fastmcp_slim/fastmcp/utilities/json_schema.py b/fastmcp_slim/fastmcp/utilities/json_schema.py
index 59715e8ed..533a4a7bf 100644
--- a/fastmcp_slim/fastmcp/utilities/json_schema.py
+++ b/fastmcp_slim/fastmcp/utilities/json_schema.py
@@ -3,7 +3,12 @@ from __future__ import annotations
from collections import defaultdict
from typing import Any
-from jsonref import JsonRefError, replace_refs
+
+def replace_refs(*args: Any, **kwargs: Any) -> Any:
+ """Call jsonref lazily while preserving the module's patchable boundary."""
+ from jsonref import replace_refs as _replace_refs
+
+ return _replace_refs(*args, **kwargs)
def _copy_schema(schema: dict[str, Any]) -> dict[str, Any]:
@@ -221,6 +226,10 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]:
if _defs_have_cycles(schema.get("$defs", {})):
return resolve_root_ref(schema)
+ # Most schema operations do not dereference. Keep jsonref (and its requests
+ # dependency tree) out of server startup until a schema actually needs it.
+ from jsonref import JsonRefError
+
try:
# Use jsonref to resolve all $ref references
# proxies=False returns plain dicts (not proxy objects)
diff --git a/fastmcp_slim/fastmcp/utilities/prefab.py b/fastmcp_slim/fastmcp/utilities/prefab.py
new file mode 100644
index 000000000..805a5fede
--- /dev/null
+++ b/fastmcp_slim/fastmcp/utilities/prefab.py
@@ -0,0 +1,74 @@
+"""Lazy helpers for FastMCP's optional Prefab UI integration."""
+
+from __future__ import annotations
+
+import sys
+from functools import lru_cache
+from importlib.util import find_spec
+from typing import Any
+
+
+@lru_cache(maxsize=1)
+def prefab_available() -> bool:
+ """Return whether Prefab UI is installed without importing it."""
+ return find_spec("prefab_ui") is not None
+
+
+@lru_cache(maxsize=1)
+def _get_prefab_types() -> tuple[type[Any], type[Any]] | None:
+ """Import and return Prefab's public app and component types on demand."""
+ if not prefab_available():
+ return None
+
+ from prefab_ui.app import PrefabApp
+ from prefab_ui.components.base import Component
+
+ return PrefabApp, Component
+
+
+def _could_be_prefab(value_or_type: Any) -> bool:
+ """Cheaply reject ordinary values before importing Prefab UI."""
+ candidate_type = (
+ value_or_type if isinstance(value_or_type, type) else type(value_or_type)
+ )
+ module = getattr(candidate_type, "__module__", "")
+ return (
+ "prefab_ui" in sys.modules
+ or module == "prefab_ui"
+ or module.startswith("prefab_ui.")
+ )
+
+
+def is_prefab_type(candidate: Any) -> bool:
+ """Return whether a type is a Prefab app or component type."""
+ if not isinstance(candidate, type) or not _could_be_prefab(candidate):
+ return False
+
+ prefab_types = _get_prefab_types()
+ return prefab_types is not None and issubclass(candidate, prefab_types)
+
+
+def is_prefab_app(value: Any) -> bool:
+ """Return whether a value is a Prefab app."""
+ if not _could_be_prefab(value):
+ return False
+
+ prefab_types = _get_prefab_types()
+ return prefab_types is not None and isinstance(value, prefab_types[0])
+
+
+def is_prefab_component(value: Any) -> bool:
+ """Return whether a value is a Prefab component."""
+ if not _could_be_prefab(value):
+ return False
+
+ prefab_types = _get_prefab_types()
+ return prefab_types is not None and isinstance(value, prefab_types[1])
+
+
+def prefab_app_from_component(component: Any) -> Any:
+ """Wrap a Prefab component in a Prefab app."""
+ prefab_types = _get_prefab_types()
+ if prefab_types is None or not isinstance(component, prefab_types[1]):
+ raise TypeError("Expected a Prefab UI component")
+ return prefab_types[0](view=component)
diff --git a/scripts/benchmark_http_startup.py b/scripts/benchmark_http_startup.py
new file mode 100644
index 000000000..54162ec4f
--- /dev/null
+++ b/scripts/benchmark_http_startup.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python
+"""Benchmark FastMCP's HTTP server cold-start path in fresh interpreters.
+
+The benchmark separates the work users pay before an HTTP server can accept
+requests:
+
+1. import the public ``FastMCP`` entry point;
+2. construct a server and register representative tools;
+3. build the Streamable HTTP ASGI application.
+
+Every sample runs in a fresh interpreter. Use ratios and the shape of the
+results rather than treating single-machine absolute timings as universal.
+
+Usage:
+ uv run python scripts/benchmark_http_startup.py
+ uv run python scripts/benchmark_http_startup.py --runs 10
+ uv run python scripts/benchmark_http_startup.py --json
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import statistics
+import subprocess
+import sys
+import textwrap
+from collections.abc import Sequence
+from typing import TypedDict
+
+
+class Sample(TypedDict):
+ import_ms: float
+ server_ms: float
+ app_ms: float
+ total_ms: float
+ module_count: int
+ rss_mib: float
+ heavy_module_counts: dict[str, int]
+
+
+_PROBE = textwrap.dedent(
+ """
+ import json
+ import resource
+ import sys
+ import time
+
+ started = time.perf_counter()
+ from fastmcp import FastMCP
+ imported = time.perf_counter()
+
+ server = FastMCP("HTTP cold-start benchmark")
+
+ def make_tool(index):
+ def tool(value: int = index) -> int:
+ return value
+
+ tool.__name__ = f"tool_{index}"
+ return tool
+
+ for index in range(10):
+ server.tool(make_tool(index))
+ configured = time.perf_counter()
+
+ app = server.http_app(transport="http", stateless_http=True)
+ assert app is not None
+ ready = time.perf_counter()
+
+ heavy_roots = {
+ "authlib",
+ "cryptography",
+ "httpx2",
+ "key_value",
+ "mcp",
+ "mcp_types",
+ "opentelemetry",
+ "pydantic",
+ "rich",
+ "sse_starlette",
+ "starlette",
+ "uvicorn",
+ }
+ heavy_module_counts = {
+ root: sum(
+ module == root or module.startswith(f"{root}.") for module in sys.modules
+ )
+ for root in sorted(heavy_roots)
+ }
+ heavy_module_counts = {
+ root: count for root, count in heavy_module_counts.items() if count
+ }
+
+ print(
+ json.dumps(
+ {
+ "import_ms": (imported - started) * 1000,
+ "server_ms": (configured - imported) * 1000,
+ "app_ms": (ready - configured) * 1000,
+ "total_ms": (ready - started) * 1000,
+ "module_count": len(sys.modules),
+ "rss_mib": (
+ resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
+ / (1024 * 1024)
+ if sys.platform == "darwin"
+ else resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
+ ),
+ "heavy_module_counts": heavy_module_counts,
+ }
+ )
+ )
+ """
+)
+
+
+def _sample() -> Sample:
+ result = subprocess.run(
+ [sys.executable, "-c", _PROBE],
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(result.stderr)
+ return json.loads(result.stdout.strip().splitlines()[-1])
+
+
+def _median(samples: Sequence[Sample], key: str) -> float:
+ return statistics.median(float(sample[key]) for sample in samples) # type: ignore[literal-required]
+
+
+def _summarize(samples: list[Sample]) -> dict[str, object]:
+ return {
+ "runs": len(samples),
+ "import_ms": round(_median(samples, "import_ms"), 1),
+ "server_ms": round(_median(samples, "server_ms"), 1),
+ "app_ms": round(_median(samples, "app_ms"), 1),
+ "total_ms": round(_median(samples, "total_ms"), 1),
+ "module_count": round(_median(samples, "module_count")),
+ "rss_mib": round(_median(samples, "rss_mib"), 1),
+ "heavy_module_counts": samples[-1]["heavy_module_counts"],
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--runs", type=int, default=5)
+ parser.add_argument("--json", action="store_true")
+ args = parser.parse_args()
+
+ samples = [_sample() for _ in range(args.runs)]
+ summary = _summarize(samples)
+ if args.json:
+ print(json.dumps(summary, indent=2))
+ return
+
+ print(f"Python: {sys.version.split()[0]}")
+ print(f"Runs: {summary['runs']}")
+ print(f"Import FastMCP: {summary['import_ms']:.1f} ms")
+ print(f"Construct + 10 tools: {summary['server_ms']:.1f} ms")
+ print(f"Build HTTP app: {summary['app_ms']:.1f} ms")
+ print(f"Total to ASGI app: {summary['total_ms']:.1f} ms")
+ print(f"Modules: {summary['module_count']}")
+ print(f"Peak RSS: {summary['rss_mib']:.1f} MiB")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/server/http/test_startup_imports.py b/tests/server/http/test_startup_imports.py
new file mode 100644
index 000000000..52af15fc1
--- /dev/null
+++ b/tests/server/http/test_startup_imports.py
@@ -0,0 +1,73 @@
+"""Fresh-interpreter import guards for the default HTTP server path."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+import textwrap
+
+import pytest
+
+
+@pytest.mark.subprocess_heavy
+def test_default_http_app_does_not_load_opt_in_integrations() -> None:
+ script = textwrap.dedent(
+ """
+ import sys
+
+ from fastmcp import FastMCP
+
+ server = FastMCP("HTTP import guard")
+
+ @server.tool
+ def echo(value: str) -> str:
+ return value
+
+ app = server.http_app(transport="http", stateless_http=True)
+ assert app is not None
+
+ forbidden = (
+ "fastmcp.server.event_store",
+ "griffe",
+ "jsonref",
+ "key_value",
+ "prefab_ui",
+ )
+ loaded = [
+ name
+ for name in sys.modules
+ if any(name == root or name.startswith(f"{root}.") for root in forbidden)
+ ]
+ assert not loaded, loaded
+ """
+ )
+
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, result.stderr
+
+
+@pytest.mark.subprocess_heavy
+def test_fastmcp_server_import_does_not_load_context() -> None:
+ script = textwrap.dedent(
+ """
+ import sys
+
+ from fastmcp import FastMCP
+
+ assert FastMCP is not None
+ assert "fastmcp.server.context" not in sys.modules
+ """
+ )
+
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, result.stderr
From 022547ad8c957753d0b5a09290a4be345d3f01d2 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 2 Aug 2026 14:56:05 -0400
Subject: [PATCH 17/32] Preserve string-compatible prompt arguments (#4730)
---
.../fastmcp/prompts/function_prompt.py | 39 ++++++---
tests/prompts/test_prompt.py | 83 +++++++++++++++++--
2 files changed, 104 insertions(+), 18 deletions(-)
diff --git a/fastmcp_slim/fastmcp/prompts/function_prompt.py b/fastmcp_slim/fastmcp/prompts/function_prompt.py
index 9685630b1..e17d17d4e 100644
--- a/fastmcp_slim/fastmcp/prompts/function_prompt.py
+++ b/fastmcp_slim/fastmcp/prompts/function_prompt.py
@@ -217,7 +217,10 @@ class FunctionPrompt(Prompt):
schema_str = json.dumps(param_schema, separators=(",", ":"))
# Append schema info to description
- schema_note = f"Provide as a JSON string matching the following schema: {schema_str}"
+ schema_note = (
+ "Provide a value matching the following JSON schema: "
+ f"{schema_str}. Encode non-string values as JSON."
+ )
if arg_description:
arg_description = f"{arg_description}\n\n{schema_note}"
else:
@@ -263,26 +266,38 @@ class FunctionPrompt(Prompt):
if param_name in sig.parameters:
param = sig.parameters[param_name]
- # If parameter has no annotation or annotation is str, pass as-is
- if (
- param.annotation == inspect.Parameter.empty
- or param.annotation is str
- ) or not isinstance(param_value, str):
+ if param.annotation == inspect.Parameter.empty or not isinstance(
+ param_value, str
+ ):
converted_kwargs[param_name] = param_value
else:
# Try to convert string argument using type adapter
try:
adapter = get_cached_typeadapter(param.annotation)
- # Try JSON parsing first for complex types
+ # Preserve the MCP wire string when validation keeps it
+ # as a string. Non-string results still prefer JSON
+ # decoding so coercible types such as bytes and Path do
+ # not retain JSON quote characters.
try:
+ python_value = adapter.validate_python(param_value)
+ except (ValueError, TypeError, pydantic_core.ValidationError):
converted_kwargs[param_name] = adapter.validate_json(
param_value
)
- except (ValueError, TypeError, pydantic_core.ValidationError):
- # Fallback to direct validation
- converted_kwargs[param_name] = adapter.validate_python(
- param_value
- )
+ else:
+ if isinstance(python_value, str):
+ converted_kwargs[param_name] = python_value
+ else:
+ try:
+ converted_kwargs[param_name] = (
+ adapter.validate_json(param_value)
+ )
+ except (
+ ValueError,
+ TypeError,
+ pydantic_core.ValidationError,
+ ):
+ converted_kwargs[param_name] = python_value
except (ValueError, TypeError, pydantic_core.ValidationError) as e:
# If conversion fails, provide informative error
raise PromptError(
diff --git a/tests/prompts/test_prompt.py b/tests/prompts/test_prompt.py
index 201632e01..d44668783 100644
--- a/tests/prompts/test_prompt.py
+++ b/tests/prompts/test_prompt.py
@@ -1,5 +1,9 @@
+from pathlib import Path
+from typing import Annotated, Any
+
import pytest
from mcp_types import EmbeddedResource, TextResourceContents
+from pydantic import Field
from fastmcp.prompts.base import (
Message,
@@ -313,8 +317,75 @@ class TestPromptTypeConversion:
assert result.messages == [Message("Hello world (repeated 3 times)")]
+ @pytest.mark.parametrize(
+ ("annotation", "value"),
+ [
+ (Annotated[str, Field(description="Text")], '"hello"'),
+ (str | None, "null"),
+ (Any, "123"),
+ (object, "true"),
+ (int | str, "42"),
+ ],
+ )
+ async def test_string_compatible_annotations_preserve_wire_strings(
+ self, annotation: Any, value: str
+ ):
+ def typed_prompt(value):
+ return f"{type(value).__name__}:{value!r}"
+
+ typed_prompt.__annotations__ = {"value": annotation, "return": str}
+ prompt = Prompt.from_function(typed_prompt)
+
+ result = await prompt.render(arguments={"value": value})
+
+ assert result.messages == [Message(f"str:{value!r}")]
+
+ async def test_optional_non_string_still_decodes_json_null(self):
+ def optional_integer_prompt(value: int | None) -> str:
+ return f"{type(value).__name__}:{value!r}"
+
+ prompt = Prompt.from_function(optional_integer_prompt)
+
+ result = await prompt.render(arguments={"value": "null"})
+
+ assert result.messages == [Message("NoneType:None")]
+
+ @pytest.mark.parametrize(
+ ("annotation", "value", "expected"),
+ [
+ (bytes, '"hello"', b"hello"),
+ (Path, '"folder/file.txt"', Path("folder/file.txt")),
+ ],
+ )
+ async def test_string_coercible_non_string_annotations_decode_json(
+ self, annotation: Any, value: str, expected: Any
+ ):
+ def typed_prompt(value):
+ return f"{type(value).__name__}:{value!r}"
+
+ typed_prompt.__annotations__ = {"value": annotation, "return": str}
+ prompt = Prompt.from_function(typed_prompt)
+
+ result = await prompt.render(arguments={"value": value})
+
+ assert result.messages == [Message(f"{type(expected).__name__}:{expected!r}")]
+
class TestPromptArgumentDescriptions:
+ def test_string_compatible_annotation_guidance_preserves_raw_strings(self):
+ def documented_prompt(
+ text: Annotated[str, Field(description="Text")],
+ ) -> str:
+ return text
+
+ prompt = Prompt.from_function(documented_prompt)
+
+ assert prompt.arguments is not None
+ text_arg = next(arg for arg in prompt.arguments if arg.name == "text")
+ assert text_arg.description is not None
+ assert "Provide as a JSON string" not in text_arg.description
+ assert "Encode non-string values as JSON." in text_arg.description
+
def test_enhanced_descriptions_for_non_string_types(self):
"""Test that non-string argument types get enhanced descriptions with JSON schema."""
@@ -343,7 +414,7 @@ class TestPromptArgumentDescriptions:
assert numbers_arg is not None
assert numbers_arg.description is not None
assert (
- "Provide as a JSON string matching the following schema:"
+ "Provide a value matching the following JSON schema:"
in numbers_arg.description
)
assert '{"items":{"type":"integer"},"type":"array"}' in numbers_arg.description
@@ -354,7 +425,7 @@ class TestPromptArgumentDescriptions:
assert metadata_arg is not None
assert metadata_arg.description is not None
assert (
- "Provide as a JSON string matching the following schema:"
+ "Provide a value matching the following JSON schema:"
in metadata_arg.description
)
assert (
@@ -368,7 +439,7 @@ class TestPromptArgumentDescriptions:
assert threshold_arg is not None
assert threshold_arg.description is not None
assert (
- "Provide as a JSON string matching the following schema:"
+ "Provide a value matching the following JSON schema:"
in threshold_arg.description
)
assert '{"type":"number"}' in threshold_arg.description
@@ -379,7 +450,7 @@ class TestPromptArgumentDescriptions:
assert active_arg is not None
assert active_arg.description is not None
assert (
- "Provide as a JSON string matching the following schema:"
+ "Provide a value matching the following JSON schema:"
in active_arg.description
)
assert '{"type":"boolean"}' in active_arg.description
@@ -410,7 +481,7 @@ class TestPromptArgumentDescriptions:
assert "A list of integers to process" in numbers_arg.description
assert "\n\n" in numbers_arg.description # Should have newline separator
assert (
- "Provide as a JSON string matching the following schema:"
+ "Provide a value matching the following JSON schema:"
in numbers_arg.description
)
@@ -427,7 +498,7 @@ class TestPromptArgumentDescriptions:
# String parameters should not have schema enhancement
if arg.description is not None:
assert (
- "Provide as a JSON string matching the following schema:"
+ "Provide a value matching the following JSON schema:"
not in arg.description
)
From a7e9b709192d19a9c014d95ef4fbedc35befeeec Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Mon, 3 Aug 2026 10:53:23 -0500
Subject: [PATCH 18/32] Fix static analysis with latest ty (#4739)
* Fix upgrade static analysis
Generated with Codex
* Preserve concrete transport return types
Generated with Codex
* Avoid widening transport return types
Generated with Codex
* Model transforming transport return types
Generated with Codex
* Exclude standalone screenshot examples from ty
Generated with Codex
---
fastmcp_slim/fastmcp/mcp_config.py | 9 ++++++---
pyproject.toml | 7 ++++---
tests/test_mcp_config.py | 2 +-
3 files changed, 11 insertions(+), 7 deletions(-)
diff --git a/fastmcp_slim/fastmcp/mcp_config.py b/fastmcp_slim/fastmcp/mcp_config.py
index c09d9f3c6..b18ea1a3e 100644
--- a/fastmcp_slim/fastmcp/mcp_config.py
+++ b/fastmcp_slim/fastmcp/mcp_config.py
@@ -45,6 +45,7 @@ from fastmcp import _install_hints
if TYPE_CHECKING:
from fastmcp.client.transports import (
ClientTransport,
+ FastMCPTransport,
SSETransport,
StdioTransport,
StreamableHttpTransport,
@@ -153,7 +154,7 @@ class _TransformingMCPServerMixin(BaseModel):
return wrapped_mcp_server, transport
- def to_transport(self) -> ClientTransport:
+ def to_transport(self) -> FastMCPTransport:
"""Get the transport for the transforming MCP server."""
try:
from fastmcp.client.transports import FastMCPTransport
@@ -209,7 +210,7 @@ class StdioMCPServer(BaseModel):
model_config = ConfigDict(extra="allow") # Preserve unknown fields
- def to_transport(self) -> StdioTransport:
+ def to_transport(self) -> StdioTransport | FastMCPTransport:
from fastmcp.client.transports import StdioTransport
return StdioTransport(
@@ -261,7 +262,9 @@ class RemoteMCPServer(BaseModel):
extra="allow", arbitrary_types_allowed=True
) # Preserve unknown fields
- def to_transport(self) -> StreamableHttpTransport | SSETransport:
+ def to_transport(
+ self,
+ ) -> StreamableHttpTransport | SSETransport | FastMCPTransport:
from fastmcp.client.transports import (
SSETransport,
StreamableHttpTransport,
diff --git a/pyproject.toml b/pyproject.toml
index 005194822..27b3596b0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -157,6 +157,8 @@ exclude = [
"examples/smart_home", # needs phue
"examples/apps/qr_server", # needs qrcode
"examples/providers/sqlite", # needs aiosqlite
+ "examples/fastmcp_config_demo", # needs pyautogui, Pillow
+ "examples/screenshot.py", # needs pyautogui, Pillow
"examples/memory.py", # needs asyncpg, numpy, pydantic_ai, pgvector
"examples/get_file.py", # needs aiohttp
]
@@ -165,9 +167,8 @@ exclude = [
python-version = "3.10"
[tool.ty.analysis]
-# prefab_ui is the apps SDK; pyautogui/PIL are optional runtime deps used only
-# inside example tool bodies (screenshot demos) and are not installed here.
-replace-imports-with-any = ["prefab_ui.**", "pyautogui", "PIL", "PIL.**"]
+# prefab_ui is the apps SDK and is not installed here.
+replace-imports-with-any = ["prefab_ui.**"]
[tool.ty.rules]
division-by-zero = "warn"
diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py
index a35af2287..93d673e61 100644
--- a/tests/test_mcp_config.py
+++ b/tests/test_mcp_config.py
@@ -96,7 +96,7 @@ class InMemoryStdioMCPServer(StdioMCPServer):
mcp: FastMCP
command: str = "in-memory"
- def to_transport(self) -> FastMCPTransport: # ty: ignore[invalid-method-override]
+ def to_transport(self) -> FastMCPTransport:
return FastMCPTransport(mcp=self.mcp)
From d267792653a11faa736ffd0fff073a2f815a3b56 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 3 Aug 2026 18:02:09 -0400
Subject: [PATCH 19/32] docs: clarify external OAuth consent mode (#4746)
---
docs/servers/auth/oauth-proxy.mdx | 8 +++++---
docs/servers/auth/oidc-proxy.mdx | 2 +-
docs/v2/servers/auth/oauth-proxy.mdx | 8 +++++---
docs/v2/servers/auth/oidc-proxy.mdx | 2 +-
docs/v3/servers/auth/oauth-proxy.mdx | 8 +++++---
docs/v3/servers/auth/oidc-proxy.mdx | 2 +-
fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py | 6 ++++--
fastmcp_slim/fastmcp/server/auth/oidc_proxy.py | 5 +++--
fastmcp_slim/fastmcp/server/auth/providers/auth0.py | 5 +++--
fastmcp_slim/fastmcp/server/auth/providers/aws.py | 5 +++--
fastmcp_slim/fastmcp/server/auth/providers/azure.py | 5 +++--
fastmcp_slim/fastmcp/server/auth/providers/clerk.py | 5 +++--
fastmcp_slim/fastmcp/server/auth/providers/discord.py | 5 +++--
fastmcp_slim/fastmcp/server/auth/providers/github.py | 5 +++--
fastmcp_slim/fastmcp/server/auth/providers/google.py | 5 +++--
fastmcp_slim/fastmcp/server/auth/providers/workos.py | 5 +++--
16 files changed, 49 insertions(+), 32 deletions(-)
diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx
index dd4751350..03727ec87 100644
--- a/docs/servers/auth/oauth-proxy.mdx
+++ b/docs/servers/auth/oauth-proxy.mdx
@@ -315,8 +315,10 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
**`"remember"` โ silent consent on return:**
Users see the consent screen on first authorization; subsequent flows from the same browser for the same `(client_id, redirect_uri)` are silently approved via a signed cookie. Cross-site navigations (detected via `Sec-Fetch-Site`) fall back to the prompt. `Sec-Fetch-Site` is a browser-level heuristic rather than a protocol guarantee: an attacker who finds a way to initiate a non-cross-site navigation (XSS on a sibling origin, a same-site redirect chain, etc.) can reach the silent-consent path. `True` does not depend on this signal. See [Confused Deputy Attacks](#confused-deputy-attacks) for the underlying attack class.
- **`"external"` โ delegate to upstream:**
- Skip the built-in consent page; consent is collected by the upstream IdP or a custom login page referenced via `upstream_authorization_endpoint`. No security warning is logged.
+ **`"external"` โ externally managed:**
+ Follows the same authorization path as `False`: FastMCP skips its consent page and associated browser-binding protections, then redirects directly to the upstream provider. The difference is logging. `False` emits a security warning, while `"external"` suppresses that warning as an explicit acknowledgment that the operator is enforcing equivalent consent and transaction-binding protections elsewhere. FastMCP does not provide or verify those external protections.
+
+ Ordinary upstream OAuth consent is generally not equivalent. It typically authorizes FastMCP's shared upstream application without identifying the downstream MCP client or binding approval to that client's transaction. Use `"external"` only when your surrounding authorization system supplies those protections.
**`False` โ disable entirely:**
Authorization proceeds directly to the upstream provider without any consent UI. Logs a security warning. Only for local development or testing.
@@ -336,7 +338,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
```
- Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients.
+ Both `False` and `"external"` disable FastMCP's consent and browser-binding protections. `False` warns about this configuration; `"external"` suppresses the warning because it is an operator acknowledgment that equivalent protections exist elsewhere. Prefer the default `True` unless you own that external authorization flow.
diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx
index 006efc8d6..be4bcb22d 100644
--- a/docs/servers/auth/oidc-proxy.mdx
+++ b/docs/servers/auth/oidc-proxy.mdx
@@ -206,7 +206,7 @@ auth = OIDCProxy(
- Consent screen behavior for authorization requests. Accepts `True` (default; always prompt โ strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (consent handled by upstream IdP or custom page), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
+ Consent screen behavior for authorization requests. Accepts `True` (default; always prompt โ strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
diff --git a/docs/v2/servers/auth/oauth-proxy.mdx b/docs/v2/servers/auth/oauth-proxy.mdx
index eef3bce1c..678c396b5 100644
--- a/docs/v2/servers/auth/oauth-proxy.mdx
+++ b/docs/v2/servers/auth/oauth-proxy.mdx
@@ -296,8 +296,10 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
**`"remember"` โ silent consent on return:**
Users see the consent screen on first authorization; subsequent flows from the same browser for the same `(client_id, redirect_uri)` are silently approved via a signed cookie. Cross-site navigations (detected via `Sec-Fetch-Site`) fall back to the prompt. `Sec-Fetch-Site` is a browser-level heuristic rather than a protocol guarantee: an attacker who finds a way to initiate a non-cross-site navigation (XSS on a sibling origin, a same-site redirect chain, etc.) can reach the silent-consent path. `True` does not depend on this signal. See [Confused Deputy Attacks](#confused-deputy-attacks) for the underlying attack class.
- **`"external"` โ delegate to upstream:**
- Skip the built-in consent page; consent is collected by the upstream IdP or a custom login page referenced via `upstream_authorization_endpoint`. No security warning is logged.
+ **`"external"` โ externally managed:**
+ Follows the same authorization path as `False`: FastMCP skips its consent page and associated browser-binding protections, then redirects directly to the upstream provider. The difference is logging. `False` emits a security warning, while `"external"` suppresses that warning as an explicit acknowledgment that the operator is enforcing equivalent consent and transaction-binding protections elsewhere. FastMCP does not provide or verify those external protections.
+
+ Ordinary upstream OAuth consent is generally not equivalent. It typically authorizes FastMCP's shared upstream application without identifying the downstream MCP client or binding approval to that client's transaction. Use `"external"` only when your surrounding authorization system supplies those protections.
**`False` โ disable entirely:**
Authorization proceeds directly to the upstream provider without any consent UI. Logs a security warning. Only for local development or testing.
@@ -317,7 +319,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
```
- Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients.
+ Both `False` and `"external"` disable FastMCP's consent and browser-binding protections. `False` warns about this configuration; `"external"` suppresses the warning because it is an operator acknowledgment that equivalent protections exist elsewhere. Prefer the default `True` unless you own that external authorization flow.
diff --git a/docs/v2/servers/auth/oidc-proxy.mdx b/docs/v2/servers/auth/oidc-proxy.mdx
index 750298298..a7988995e 100644
--- a/docs/v2/servers/auth/oidc-proxy.mdx
+++ b/docs/v2/servers/auth/oidc-proxy.mdx
@@ -199,7 +199,7 @@ auth = OIDCProxy(
- Consent screen behavior for authorization requests. Accepts `True` (default; always prompt โ strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (consent handled by upstream IdP or custom page), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/v2/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
+ Consent screen behavior for authorization requests. Accepts `True` (default; always prompt โ strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/v2/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
diff --git a/docs/v3/servers/auth/oauth-proxy.mdx b/docs/v3/servers/auth/oauth-proxy.mdx
index e35f244eb..79ba7bf09 100644
--- a/docs/v3/servers/auth/oauth-proxy.mdx
+++ b/docs/v3/servers/auth/oauth-proxy.mdx
@@ -310,8 +310,10 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
**`"remember"` โ silent consent on return:**
Users see the consent screen on first authorization; subsequent flows from the same browser for the same `(client_id, redirect_uri)` are silently approved via a signed cookie. Cross-site navigations (detected via `Sec-Fetch-Site`) fall back to the prompt. `Sec-Fetch-Site` is a browser-level heuristic rather than a protocol guarantee: an attacker who finds a way to initiate a non-cross-site navigation (XSS on a sibling origin, a same-site redirect chain, etc.) can reach the silent-consent path. `True` does not depend on this signal. See [Confused Deputy Attacks](#confused-deputy-attacks) for the underlying attack class.
- **`"external"` โ delegate to upstream:**
- Skip the built-in consent page; consent is collected by the upstream IdP or a custom login page referenced via `upstream_authorization_endpoint`. No security warning is logged.
+ **`"external"` โ externally managed:**
+ Follows the same authorization path as `False`: FastMCP skips its consent page and associated browser-binding protections, then redirects directly to the upstream provider. The difference is logging. `False` emits a security warning, while `"external"` suppresses that warning as an explicit acknowledgment that the operator is enforcing equivalent consent and transaction-binding protections elsewhere. FastMCP does not provide or verify those external protections.
+
+ Ordinary upstream OAuth consent is generally not equivalent. It typically authorizes FastMCP's shared upstream application without identifying the downstream MCP client or binding approval to that client's transaction. Use `"external"` only when your surrounding authorization system supplies those protections.
**`False` โ disable entirely:**
Authorization proceeds directly to the upstream provider without any consent UI. Logs a security warning. Only for local development or testing.
@@ -331,7 +333,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
```
- Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients.
+ Both `False` and `"external"` disable FastMCP's consent and browser-binding protections. `False` warns about this configuration; `"external"` suppresses the warning because it is an operator acknowledgment that equivalent protections exist elsewhere. Prefer the default `True` unless you own that external authorization flow.
diff --git a/docs/v3/servers/auth/oidc-proxy.mdx b/docs/v3/servers/auth/oidc-proxy.mdx
index fde747e2b..81ca677a2 100644
--- a/docs/v3/servers/auth/oidc-proxy.mdx
+++ b/docs/v3/servers/auth/oidc-proxy.mdx
@@ -199,7 +199,7 @@ auth = OIDCProxy(
- Consent screen behavior for authorization requests. Accepts `True` (default; always prompt โ strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (consent handled by upstream IdP or custom page), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
+ Consent screen behavior for authorization requests. Accepts `True` (default; always prompt โ strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
index 2ca7fa311..760552bcc 100644
--- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
+++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
@@ -397,8 +397,10 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
redirect_uri) in the same browser. Cross-site navigations are
still prompted to block AS-in-the-middle attacks. Lower UX
friction, but weaker protection than True.
- - "external": skip the built-in consent screen; consent is handled
- externally (e.g. by the upstream IdP or a custom login page).
+ - "external": follow the same authorization path as False, but
+ suppress the warning as an operator acknowledgment that equivalent
+ consent and transaction-binding protections are enforced externally.
+ FastMCP does not provide or verify those external protections.
- False: skip consent entirely. SECURITY WARNING: only set to
False for local development or testing environments.
consent_csp_policy: Content Security Policy for the consent page.
diff --git a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py
index e2bda58ab..5e4086c7a 100644
--- a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py
+++ b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py
@@ -306,8 +306,9 @@ class OIDCProxy(OAuthProxy):
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
When True, users see a consent screen before being redirected to the upstream IdP.
When False, authorization proceeds directly without user confirmation.
- When "external", the built-in consent screen is skipped but no warning is
- logged, indicating that consent is handled externally (e.g. by the upstream IdP).
+ When "external", authorization follows the same direct path as False,
+ but the warning is suppressed as an operator acknowledgment that
+ equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments.
consent_csp_policy: Content Security Policy for the consent page.
If None (default), uses the built-in CSP policy with appropriate directives.
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/auth0.py b/fastmcp_slim/fastmcp/server/auth/providers/auth0.py
index 0a3120a2b..29cc02a88 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/auth0.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/auth0.py
@@ -135,8 +135,9 @@ class Auth0Provider(OIDCProxy):
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
When True, users see a consent screen before being redirected to Auth0.
When False, authorization proceeds directly without user confirmation.
- When "external", the built-in consent screen is skipped but no warning is
- logged, indicating that consent is handled externally (e.g. by the upstream IdP).
+ When "external", authorization follows the same direct path as False,
+ but the warning is suppressed as an operator acknowledgment that
+ equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments.
fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued
refresh token when the upstream provider omits `refresh_expires_in`
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/aws.py b/fastmcp_slim/fastmcp/server/auth/providers/aws.py
index 01b1bcbd5..553c9b705 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/aws.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/aws.py
@@ -175,8 +175,9 @@ class AWSCognitoProvider(OIDCProxy):
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
When True, users see a consent screen before being redirected to AWS Cognito.
When False, authorization proceeds directly without user confirmation.
- When "external", the built-in consent screen is skipped but no warning is
- logged, indicating that consent is handled externally (e.g. by the upstream IdP).
+ When "external", authorization follows the same direct path as False,
+ but the warning is suppressed as an operator acknowledgment that
+ equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments.
fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued
refresh token when the upstream provider omits `refresh_expires_in`
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/azure.py b/fastmcp_slim/fastmcp/server/auth/providers/azure.py
index b8d4332e2..b0cf9d360 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/azure.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/azure.py
@@ -173,8 +173,9 @@ class AzureProvider(OAuthProxy):
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
When True, users see a consent screen before being redirected to Azure.
When False, authorization proceeds directly without user confirmation.
- When "external", the built-in consent screen is skipped but no warning is
- logged, indicating that consent is handled externally (e.g. by the upstream IdP).
+ When "external", authorization follows the same direct path as False,
+ but the warning is suppressed as an operator acknowledgment that
+ equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx2.AsyncClient for connection pooling in JWKS fetches.
When provided, the client is reused for JWT key fetches and the caller
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/clerk.py b/fastmcp_slim/fastmcp/server/auth/providers/clerk.py
index 6378e5de8..cb7a20660 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/clerk.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/clerk.py
@@ -327,8 +327,9 @@ class ClerkProvider(OAuthProxy):
into a 32-byte key. If not provided, the upstream client secret will be used to
derive a 32-byte key using PBKDF2.
require_authorization_consent: Whether to require user consent before authorizing
- clients (default True). When "external", the built-in consent screen is skipped
- but no warning is logged, indicating that consent is handled externally by Clerk.
+ clients (default True). When "external", authorization follows the same direct
+ path as False, but the warning is suppressed as an operator acknowledgment that
+ equivalent protections are enforced externally.
consent_csp_policy: Custom CSP policy for the consent page.
extra_authorize_params: Additional parameters to forward to Clerk's authorization
endpoint. Example: {"prompt": "login"} to force re-authentication.
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/discord.py b/fastmcp_slim/fastmcp/server/auth/providers/discord.py
index bdb922fa2..8a6b657b6 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/discord.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/discord.py
@@ -241,8 +241,9 @@ class DiscordProvider(OAuthProxy):
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
When True, users see a consent screen before being redirected to Discord.
When False, authorization proceeds directly without user confirmation.
- When "external", the built-in consent screen is skipped but no warning is
- logged, indicating that consent is handled externally (e.g. by the upstream IdP).
+ When "external", authorization follows the same direct path as False,
+ but the warning is suppressed as an operator acknowledgment that
+ equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/github.py b/fastmcp_slim/fastmcp/server/auth/providers/github.py
index db6718922..214d24c3b 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/github.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/github.py
@@ -257,8 +257,9 @@ class GitHubProvider(OAuthProxy):
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
When True, users see a consent screen before being redirected to GitHub.
When False, authorization proceeds directly without user confirmation.
- When "external", the built-in consent screen is skipped but no warning is
- logged, indicating that consent is handled externally (e.g. by the upstream IdP).
+ When "external", authorization follows the same direct path as False,
+ but the warning is suppressed as an operator acknowledgment that
+ equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/google.py b/fastmcp_slim/fastmcp/server/auth/providers/google.py
index 7a9e4ba18..a8536b223 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/google.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/google.py
@@ -290,8 +290,9 @@ class GoogleProvider(OAuthProxy):
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
When True, users see a consent screen before being redirected to Google.
When False, authorization proceeds directly without user confirmation.
- When "external", the built-in consent screen is skipped but no warning is
- logged, indicating that consent is handled externally (e.g. by Google's own consent).
+ When "external", authorization follows the same direct path as False,
+ but the warning is suppressed as an operator acknowledgment that
+ equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments.
extra_authorize_params: Additional parameters to forward to Google's authorization endpoint.
By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/workos.py b/fastmcp_slim/fastmcp/server/auth/providers/workos.py
index bf955e821..18c0a763a 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/workos.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/workos.py
@@ -213,8 +213,9 @@ class WorkOSProvider(OAuthProxy):
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
When True, users see a consent screen before being redirected to WorkOS.
When False, authorization proceeds directly without user confirmation.
- When "external", the built-in consent screen is skipped but no warning is
- logged, indicating that consent is handled externally (e.g. by the upstream IdP).
+ When "external", authorization follows the same direct path as False,
+ but the warning is suppressed as an operator acknowledgment that
+ equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments.
extra_authorize_params: Additional parameters to forward to WorkOS's authorization endpoint.
Useful for forcing scopes like `offline_access` so WorkOS issues a refresh token,
From 886776f5fced4b312154bf770b0662128b23936d Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:25:46 -0400
Subject: [PATCH 20/32] Canonicalize response cache arguments (#4753)
Co-authored-by: LHMQ878
---
.../fastmcp/server/middleware/caching.py | 11 ++++-
tests/server/middleware/test_caching.py | 49 +++++++++++++++++++
2 files changed, 58 insertions(+), 2 deletions(-)
diff --git a/fastmcp_slim/fastmcp/server/middleware/caching.py b/fastmcp_slim/fastmcp/server/middleware/caching.py
index d470f1b90..e96948fb0 100644
--- a/fastmcp_slim/fastmcp/server/middleware/caching.py
+++ b/fastmcp_slim/fastmcp/server/middleware/caching.py
@@ -1,6 +1,7 @@
"""A middleware for response caching."""
import hashlib
+import json
from collections.abc import Sequence
from logging import Logger
from typing import Any, TypedDict
@@ -593,13 +594,19 @@ class ResponseCachingMiddleware(Middleware):
def _get_arguments_str(arguments: dict[str, Any] | None) -> str:
- """Get a string representation of the arguments."""
+ """Get a canonical string representation of the arguments."""
if arguments is None:
return "null"
try:
- return pydantic_core.to_json(value=arguments, fallback=str).decode()
+ return json.dumps(
+ pydantic_core.to_jsonable_python(arguments, fallback=str),
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ default=str,
+ )
except TypeError:
return repr(arguments)
diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py
index b45961bcf..fc7c1ffc2 100644
--- a/tests/server/middleware/test_caching.py
+++ b/tests/server/middleware/test_caching.py
@@ -4,6 +4,7 @@ import sys
import tempfile
import warnings
from pathlib import Path
+from typing import Any
from unittest.mock import AsyncMock, MagicMock
import mcp_types
@@ -284,6 +285,42 @@ class TestResponseCachingMiddleware:
)
assert middleware1._matches_tool_cache_settings(tool_name=tool_name) is result
+ @pytest.mark.parametrize(
+ ("first", "second"),
+ [
+ ({"a": 5, "b": 3}, {"b": 3, "a": 5}),
+ ({"q": {"x": 1, "y": 2}}, {"q": {"y": 2, "x": 1}}),
+ ({"items": [{"x": 1, "y": 2}]}, {"items": [{"y": 2, "x": 1}]}),
+ ],
+ ids=["top level", "nested dict", "dict inside a list"],
+ )
+ def test_call_tool_cache_key_ignores_argument_order(
+ self, first: dict[str, Any], second: dict[str, Any]
+ ):
+ assert _make_call_tool_cache_key(
+ mcp_types.CallToolRequestParams(name="tool", arguments=first)
+ ) == _make_call_tool_cache_key(
+ mcp_types.CallToolRequestParams(name="tool", arguments=second)
+ )
+
+ def test_get_prompt_cache_key_ignores_argument_order(self):
+ assert _make_get_prompt_cache_key(
+ mcp_types.GetPromptRequestParams(
+ name="prompt", arguments={"a": "5", "b": "3"}
+ )
+ ) == _make_get_prompt_cache_key(
+ mcp_types.GetPromptRequestParams(
+ name="prompt", arguments={"b": "3", "a": "5"}
+ )
+ )
+
+ def test_call_tool_cache_key_distinguishes_arguments(self):
+ assert _make_call_tool_cache_key(
+ mcp_types.CallToolRequestParams(name="tool", arguments={"a": 5, "b": 3})
+ ) != _make_call_tool_cache_key(
+ mcp_types.CallToolRequestParams(name="tool", arguments={"a": 3, "b": 5})
+ )
+
@pytest.mark.skipif(
sys.platform == "win32",
@@ -424,6 +461,18 @@ class TestResponseCachingMiddlewareIntegration:
)
assert call_tool_result_one == call_tool_result_two
+ async def test_call_tool_with_reordered_arguments_hits_cache(
+ self,
+ caching_server: FastMCP,
+ tracking_calculator: TrackingCalculator,
+ ):
+ async with Client[FastMCPTransport](transport=caching_server) as client:
+ first = await client.call_tool("add", {"a": 5, "b": 3})
+ second = await client.call_tool("add", {"b": 3, "a": 5})
+
+ assert first == second
+ assert tracking_calculator.add_calls == 1
+
async def test_call_tool_very_large_value(
self,
caching_server: FastMCP,
From db92d44ef510eca7d4fbd1c3108a7482d8021ce1 Mon Sep 17 00:00:00 2001
From: LHMQ878 <72402929@cityu-dg.edu.cn>
Date: Wed, 5 Aug 2026 05:11:42 +0800
Subject: [PATCH 21/32] Serve empty list results from the response cache
(#4738)
---
.../fastmcp/server/middleware/caching.py | 14 ++-
tests/server/middleware/test_caching.py | 87 ++++++++++++++++++-
2 files changed, 97 insertions(+), 4 deletions(-)
diff --git a/fastmcp_slim/fastmcp/server/middleware/caching.py b/fastmcp_slim/fastmcp/server/middleware/caching.py
index e96948fb0..66918d1a5 100644
--- a/fastmcp_slim/fastmcp/server/middleware/caching.py
+++ b/fastmcp_slim/fastmcp/server/middleware/caching.py
@@ -355,7 +355,11 @@ class ResponseCachingMiddleware(Middleware):
cache_key: str = _get_auth_partition_key()
- if cached_value := await self._list_tools_cache.get(key=cache_key):
+ # an empty list is a cached result, not a miss: `get` returns None when the key is
+ # absent, so testing truthiness would re-list on every request for any caller whose
+ # filtered view is empty
+ cached_value = await self._list_tools_cache.get(key=cache_key)
+ if cached_value is not None:
return cached_value
tools: Sequence[Tool] = await call_next(context)
@@ -384,7 +388,9 @@ class ResponseCachingMiddleware(Middleware):
cache_key: str = _get_auth_partition_key()
- if cached_value := await self._list_resources_cache.get(key=cache_key):
+ # an empty list is a cached result, not a miss (see on_list_tools)
+ cached_value = await self._list_resources_cache.get(key=cache_key)
+ if cached_value is not None:
return cached_value
resources: Sequence[Resource] = await call_next(context)
@@ -415,7 +421,9 @@ class ResponseCachingMiddleware(Middleware):
cache_key: str = _get_auth_partition_key()
- if cached_value := await self._list_prompts_cache.get(key=cache_key):
+ # an empty list is a cached result, not a miss (see on_list_tools)
+ cached_value = await self._list_prompts_cache.get(key=cache_key)
+ if cached_value is not None:
return cached_value
prompts: Sequence[Prompt] = await call_next(context)
diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py
index fc7c1ffc2..c2d229468 100644
--- a/tests/server/middleware/test_caching.py
+++ b/tests/server/middleware/test_caching.py
@@ -3,6 +3,7 @@
import sys
import tempfile
import warnings
+from collections.abc import Sequence
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
@@ -41,7 +42,11 @@ from fastmcp.server.middleware.caching import (
_make_get_prompt_cache_key,
_make_read_resource_cache_key,
)
-from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext
+from fastmcp.server.middleware.middleware import (
+ CallNext,
+ Middleware,
+ MiddlewareContext,
+)
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.tasks import TaskConfig
@@ -951,3 +956,83 @@ class TestAuthAwareCaching:
assert {p.name for p in prompts} == {"public_prompt"}
finally:
auth_context_var.reset(tok)
+
+
+class CountingDownstream(Middleware):
+ """Counts the list calls that get past the caching middleware, i.e. cache misses."""
+
+ def __init__(self) -> None:
+ self.list_calls = 0
+
+ async def on_list_tools(
+ self,
+ context: MiddlewareContext[mcp_types.ListToolsRequest],
+ call_next: CallNext[mcp_types.ListToolsRequest, Sequence[Tool]],
+ ) -> Sequence[Tool]:
+ self.list_calls += 1
+ return await call_next(context)
+
+ async def on_list_resources(
+ self,
+ context: MiddlewareContext[mcp_types.ListResourcesRequest],
+ call_next: CallNext[mcp_types.ListResourcesRequest, Sequence[Resource]],
+ ) -> Sequence[Resource]:
+ self.list_calls += 1
+ return await call_next(context)
+
+ async def on_list_prompts(
+ self,
+ context: MiddlewareContext[mcp_types.ListPromptsRequest],
+ call_next: CallNext[mcp_types.ListPromptsRequest, Sequence[Prompt]],
+ ) -> Sequence[Prompt]:
+ self.list_calls += 1
+ return await call_next(context)
+
+
+class TestEmptyListCaching:
+ """An empty list is a cached result, not a cache miss.
+
+ Regression tests for issue #4733: the list hooks tested the cached value for
+ truthiness, so a server - or a per-user filtered view - with nothing to list
+ re-ran the listing on every single request and never served a cache hit.
+ """
+
+ @pytest.mark.parametrize("operation", ["tools", "resources", "prompts"])
+ async def test_empty_list_is_served_from_cache(self, operation: str):
+ counter = CountingDownstream()
+ mcp_server = FastMCP("test", middleware=[ResponseCachingMiddleware(), counter])
+
+ list_operation = getattr(mcp_server, f"list_{operation}")
+ for _ in range(3):
+ assert len(await list_operation()) == 0
+
+ assert counter.list_calls == 1
+
+ async def test_empty_filtered_view_is_served_from_cache(self):
+ from mcp.server.auth.middleware.auth_context import auth_context_var
+ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
+
+ from fastmcp.server.auth import AccessToken, require_scopes
+
+ counter = CountingDownstream()
+ mcp_server = FastMCP("test", middleware=[ResponseCachingMiddleware(), counter])
+
+ @mcp_server.tool(auth=require_scopes("admin"))
+ def admin_only() -> str:
+ return "ok"
+
+ token = AccessToken(
+ token="token-read",
+ client_id="test-client",
+ scopes=["read"],
+ expires_at=None,
+ claims={},
+ )
+ tok = auth_context_var.set(AuthenticatedUser(token))
+ try:
+ for _ in range(3):
+ assert len(await mcp_server.list_tools()) == 0
+ finally:
+ auth_context_var.reset(tok)
+
+ assert counter.list_calls == 1
From 4f28dceac87cc9e4610c01ffdc0499b4acd7c218 Mon Sep 17 00:00:00 2001
From: Sai Mouli <141447420+SaiMouli3@users.noreply.github.com>
Date: Wed, 5 Aug 2026 02:45:01 +0530
Subject: [PATCH 22/32] Don't cache error results in ResponseCachingMiddleware
(#4705)
---
.../fastmcp/server/middleware/caching.py | 8 ++++
tests/server/middleware/test_caching.py | 45 +++++++++++++++++++
2 files changed, 53 insertions(+)
diff --git a/fastmcp_slim/fastmcp/server/middleware/caching.py b/fastmcp_slim/fastmcp/server/middleware/caching.py
index 66918d1a5..a6db7eced 100644
--- a/fastmcp_slim/fastmcp/server/middleware/caching.py
+++ b/fastmcp_slim/fastmcp/server/middleware/caching.py
@@ -483,6 +483,14 @@ class ResponseCachingMiddleware(Middleware):
if not isinstance(tool_result, ToolResult):
return tool_result
+ # Never cache an error result. A tool that reports failure by returning
+ # is_error=True is describing this attempt, not a stable answer โ the
+ # upstream 503 or bad gateway it is reporting is exactly the kind of
+ # thing that clears on retry. Caching it would pin the failure in place
+ # for the full TTL and stop the tool from ever being retried.
+ if tool_result.is_error:
+ return tool_result
+
cacheable_tool_result: CacheableToolResult = CacheableToolResult.wrap(
value=tool_result
)
diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py
index c2d229468..fec5424c1 100644
--- a/tests/server/middleware/test_caching.py
+++ b/tests/server/middleware/test_caching.py
@@ -658,6 +658,51 @@ class TestCacheableToolResult:
assert cached_tool_result.is_error is True
+class TestErrorResultsAreNotCached:
+ """Regression tests for issue #4395: an error result was cached for the full
+ TTL, so a transient failure permanently shadowed the tool until it expired."""
+
+ async def test_error_result_is_not_cached(self):
+ mcp = FastMCP("ErrorCachingTestServer")
+ mcp.add_middleware(ResponseCachingMiddleware(cache_storage=MemoryStore()))
+
+ call_count = 0
+
+ @mcp.tool
+ def flakey() -> ToolResult:
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ return ToolResult("upstream 503", is_error=True)
+ return ToolResult("recovered")
+
+ async with Client(mcp) as client:
+ first = await client.call_tool("flakey", {}, raise_on_error=False)
+ assert first.is_error is True
+
+ # The tool must actually run again rather than replay the error.
+ second = await client.call_tool("flakey", {}, raise_on_error=False)
+ assert second.is_error is False
+ assert call_count == 2
+
+ async def test_successful_result_is_still_cached(self):
+ mcp = FastMCP("SuccessCachingTestServer")
+ mcp.add_middleware(ResponseCachingMiddleware(cache_storage=MemoryStore()))
+
+ call_count = 0
+
+ @mcp.tool
+ def stable() -> str:
+ nonlocal call_count
+ call_count += 1
+ return "ok"
+
+ async with Client(mcp) as client:
+ await client.call_tool("stable", {})
+ await client.call_tool("stable", {})
+ assert call_count == 1
+
+
class TestCachingWithImportedServerPrefixes:
"""Test that caching preserves prefixes from imported servers.
From b9b7ea691461c7a4779374f8f0c82c67941efb2e Mon Sep 17 00:00:00 2001
From: nate nowack <153965+jlowin@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:39:35 -0500
Subject: [PATCH 23/32] Declare run-claude extra allowed tools input (#4740)
Generated with Codex
---
.github/actions/run-claude/action.yml | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/.github/actions/run-claude/action.yml b/.github/actions/run-claude/action.yml
index 66f4bb286..f97598d66 100644
--- a/.github/actions/run-claude/action.yml
+++ b/.github/actions/run-claude/action.yml
@@ -37,6 +37,11 @@ inputs:
required: false
default: ""
+ extra-allowed-tools:
+ description: "Additional comma-separated tools to append to allowed-tools"
+ required: false
+ default: ""
+
model:
description: "Model to use for Claude"
required: false
From 2c2f98691f3e0b21ed6cc98ad4823d44fa93c5e8 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Wed, 5 Aug 2026 10:53:15 -0400
Subject: [PATCH 24/32] Docs: mirror v3.4.6 release notes (#4764)
---
docs/changelog.mdx | 16 ++++++++++++++++
docs/updates.mdx | 10 ++++++++++
2 files changed, 26 insertions(+)
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index de17a3f8e..0f7efeb17 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -5,6 +5,22 @@ rss: true
tag: NEW
---
+
+
+**[v3.4.6: Trust, but Proxy](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.6)**
+
+FastMCP 3.4.6 backports trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches. Deployments can now route these requests through a mandated corporate proxy while preserving custom CA certificates; FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request.
+
+### Fixes ๐
+* Backport #4412 to 3.x: support trusted SSRF proxies by [@jlowin](https://github.com/jlowin) in [#4755](https://github.com/PrefectHQ/fastmcp/pull/4755)
+
+### Docs ๐
+* Docs: add v3.4.6 changelog entries by [@jlowin](https://github.com/jlowin) in [#4761](https://github.com/PrefectHQ/fastmcp/pull/4761)
+
+**Full Changelog**: [v3.4.5...v3.4.6](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v3.4.6)
+
+
+
**[v4.0.0b1: Fourgone Conclusion](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1)**
diff --git a/docs/updates.mdx b/docs/updates.mdx
index e930cb36c..26e83c917 100644
--- a/docs/updates.mdx
+++ b/docs/updates.mdx
@@ -5,6 +5,16 @@ icon: "sparkles"
tag: NEW
---
+
+
+FastMCP 3.4.6 adds trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches on the 3.x line. Deployments can route these requests through a mandated corporate proxy while preserving custom CA certificates, and FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request.
+
+
+
Date: Wed, 5 Aug 2026 10:56:13 -0400
Subject: [PATCH 25/32] Avoid loading MCP and CLI stacks during lightweight
imports (#4763)
---
fastmcp_slim/fastmcp/__init__.py | 15 +------
fastmcp_slim/fastmcp/_compat.py | 2 +-
fastmcp_slim/fastmcp/_warnings.py | 10 +++++
fastmcp_slim/fastmcp/exceptions.py | 11 ++---
.../fastmcp/server/mixins/transport.py | 5 ++-
fastmcp_slim/fastmcp/utilities/logging.py | 26 +++++++----
tests/server/http/test_startup_imports.py | 45 +++++++++++++++++++
tests/test_compat.py | 5 +++
tests/utilities/test_logging.py | 18 +++++++-
9 files changed, 104 insertions(+), 33 deletions(-)
create mode 100644 fastmcp_slim/fastmcp/_warnings.py
diff --git a/fastmcp_slim/fastmcp/__init__.py b/fastmcp_slim/fastmcp/__init__.py
index 170ffb0f4..9d64f128e 100644
--- a/fastmcp_slim/fastmcp/__init__.py
+++ b/fastmcp_slim/fastmcp/__init__.py
@@ -6,15 +6,13 @@ from importlib.metadata import PackageNotFoundError, version as _version
from typing import TYPE_CHECKING
from fastmcp import _install_hints
+from fastmcp._warnings import FastMCPDeprecationWarning
from fastmcp.settings import Settings
from fastmcp.utilities.logging import configure_logging as _configure_logging
if TYPE_CHECKING:
from fastmcp.client import Client as Client
from fastmcp.apps.app import FastMCPApp as FastMCPApp
- from fastmcp.exceptions import (
- FastMCPDeprecationWarning as FastMCPDeprecationWarning,
- )
from fastmcp.server.context import Context as Context
from fastmcp.server.server import FastMCP as FastMCP
@@ -39,12 +37,7 @@ except PackageNotFoundError:
__version__ = _version("fastmcp")
if settings.deprecation_warnings:
- try:
- from fastmcp.exceptions import FastMCPDeprecationWarning
- except ImportError:
- pass
- else:
- warnings.simplefilter("default", FastMCPDeprecationWarning)
+ warnings.simplefilter("default", FastMCPDeprecationWarning)
# --- Lazy imports for performance (see #3292) ---
@@ -81,10 +74,6 @@ def __getattr__(name: str) -> object:
raise ImportError(_install_hints.APP_SUPPORT) from exc
return FastMCPApp
- if name == "FastMCPDeprecationWarning":
- from fastmcp.exceptions import FastMCPDeprecationWarning
-
- return FastMCPDeprecationWarning
if name == "client":
try:
return importlib.import_module("fastmcp.client")
diff --git a/fastmcp_slim/fastmcp/_compat.py b/fastmcp_slim/fastmcp/_compat.py
index 402b8c238..cefc67b49 100644
--- a/fastmcp_slim/fastmcp/_compat.py
+++ b/fastmcp_slim/fastmcp/_compat.py
@@ -33,7 +33,7 @@ import warnings
import mcp_types
-from fastmcp.exceptions import FastMCPDeprecationWarning
+from fastmcp._warnings import FastMCPDeprecationWarning
# Map each SDK model class to the camelCase -> snake_case field reads we bridge.
# Limited to fields FastMCP users actually read (docs boundary inventory).
diff --git a/fastmcp_slim/fastmcp/_warnings.py b/fastmcp_slim/fastmcp/_warnings.py
new file mode 100644
index 000000000..c63b97a5d
--- /dev/null
+++ b/fastmcp_slim/fastmcp/_warnings.py
@@ -0,0 +1,10 @@
+"""Warning types that can be imported without loading FastMCP's exception stack."""
+
+
+class FastMCPDeprecationWarning(DeprecationWarning):
+ """Deprecation warning for FastMCP APIs.
+
+ Subclass of DeprecationWarning so that standard warning filters
+ still apply, but FastMCP can selectively enable its own warnings
+ without affecting other libraries in the process.
+ """
diff --git a/fastmcp_slim/fastmcp/exceptions.py b/fastmcp_slim/fastmcp/exceptions.py
index 4042f80b7..3fa255acd 100644
--- a/fastmcp_slim/fastmcp/exceptions.py
+++ b/fastmcp_slim/fastmcp/exceptions.py
@@ -5,6 +5,8 @@ from typing import Any
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, ErrorData
+from fastmcp import _warnings
+
try:
from mcp import MCPError
except ImportError:
@@ -30,14 +32,7 @@ except ImportError:
# see the migration notes.
McpError = MCPError
-
-class FastMCPDeprecationWarning(DeprecationWarning):
- """Deprecation warning for FastMCP APIs.
-
- Subclass of DeprecationWarning so that standard warning filters
- still apply, but FastMCP can selectively enable its own warnings
- without affecting other libraries in the process.
- """
+FastMCPDeprecationWarning = _warnings.FastMCPDeprecationWarning
class FastMCPError(Exception):
diff --git a/fastmcp_slim/fastmcp/server/mixins/transport.py b/fastmcp_slim/fastmcp/server/mixins/transport.py
index 13bef1ced..b26e02d18 100644
--- a/fastmcp_slim/fastmcp/server/mixins/transport.py
+++ b/fastmcp_slim/fastmcp/server/mixins/transport.py
@@ -28,7 +28,6 @@ from fastmcp.server.http import (
from fastmcp.server.providers.base import Provider
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
from fastmcp.server.providers.wrapped_provider import _WrappedProvider
-from fastmcp.utilities.cli import log_server_banner
from fastmcp.utilities.logging import get_logger, temporary_log_level
if TYPE_CHECKING:
@@ -230,6 +229,8 @@ class TransportMixin:
# Display server banner
if show_banner:
+ from fastmcp.utilities.cli import log_server_banner
+
log_server_banner(server=self)
token = set_transport("stdio")
@@ -337,6 +338,8 @@ class TransportMixin:
# Display server banner
if show_banner:
+ from fastmcp.utilities.cli import log_server_banner
+
log_server_banner(server=self)
uvicorn_config_from_user = uvicorn_config or {}
diff --git a/fastmcp_slim/fastmcp/utilities/logging.py b/fastmcp_slim/fastmcp/utilities/logging.py
index d39d6ef4a..eebf4eda8 100644
--- a/fastmcp_slim/fastmcp/utilities/logging.py
+++ b/fastmcp_slim/fastmcp/utilities/logging.py
@@ -1,7 +1,9 @@
"""Logging utilities for FastMCP."""
import contextlib
+import importlib.util
import logging
+from pathlib import Path
from typing import Any, Literal, cast
from rich.console import Console
@@ -11,6 +13,17 @@ from typing_extensions import override
import fastmcp
+def _get_package_path(package: str) -> str | None:
+ """Return a package directory without importing the package."""
+ try:
+ spec = importlib.util.find_spec(package)
+ except ImportError:
+ return None
+ if spec is None or spec.origin is None:
+ return None
+ return str(Path(spec.origin).parent)
+
+
def get_logger(name: str) -> logging.Logger:
"""Get a logger nested under FastMCP namespace.
@@ -83,14 +96,11 @@ def configure_logging(
# no path or level name to maximize width available for the traceback
# suppress framework frames and limit the number of frames to 3
- import pydantic
-
- try:
- import mcp
- except ImportError:
- tracebacks_suppress = [fastmcp, pydantic]
- else:
- tracebacks_suppress = [fastmcp, mcp, pydantic]
+ tracebacks_suppress = [
+ package_path
+ for package in ("fastmcp", "mcp", "pydantic")
+ if (package_path := _get_package_path(package)) is not None
+ ]
# Build traceback kwargs with defaults that can be overridden
traceback_kwargs = {
diff --git a/tests/server/http/test_startup_imports.py b/tests/server/http/test_startup_imports.py
index 52af15fc1..addcd99b7 100644
--- a/tests/server/http/test_startup_imports.py
+++ b/tests/server/http/test_startup_imports.py
@@ -9,6 +9,51 @@ import textwrap
import pytest
+@pytest.mark.subprocess_heavy
+def test_root_import_does_not_load_mcp_sdk() -> None:
+ script = textwrap.dedent(
+ """
+ import sys
+
+ import fastmcp
+
+ assert fastmcp.settings is not None
+ assert "mcp" not in sys.modules
+ assert "fastmcp.exceptions" not in sys.modules
+ """
+ )
+
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, result.stderr
+
+
+@pytest.mark.subprocess_heavy
+def test_server_import_does_not_load_cli() -> None:
+ script = textwrap.dedent(
+ """
+ import sys
+
+ from fastmcp import FastMCP
+
+ assert FastMCP is not None
+ assert "fastmcp.utilities.cli" not in sys.modules
+ """
+ )
+
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, result.stderr
+
+
@pytest.mark.subprocess_heavy
def test_default_http_app_does_not_load_opt_in_integrations() -> None:
script = textwrap.dedent(
diff --git a/tests/test_compat.py b/tests/test_compat.py
index ff5356a64..a44991f02 100644
--- a/tests/test_compat.py
+++ b/tests/test_compat.py
@@ -10,6 +10,7 @@ from mcp import MCPError as SDKMCPError
import fastmcp
import fastmcp._compat as _compat
from fastmcp import Client, FastMCP
+from fastmcp import FastMCPDeprecationWarning as PublicWarning
from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import FastMCPDeprecationWarning, MCPError, McpError
@@ -30,6 +31,10 @@ def _reset_warn_once() -> None:
_compat.install()
+def test_deprecation_warning_is_same_from_public_imports() -> None:
+ assert PublicWarning is FastMCPDeprecationWarning
+
+
@pytest.fixture(autouse=True)
def fresh_shims():
_reset_warn_once()
diff --git a/tests/utilities/test_logging.py b/tests/utilities/test_logging.py
index d15b9c355..c74b6c436 100644
--- a/tests/utilities/test_logging.py
+++ b/tests/utilities/test_logging.py
@@ -1,4 +1,7 @@
import logging
+from pathlib import Path
+
+from rich.logging import RichHandler
import fastmcp
from fastmcp.utilities.logging import configure_logging, get_logger
@@ -42,6 +45,19 @@ def test_configure_logging_with_traceback_kwargs():
assert len(logger.handlers) == 2 # One for normal logs, one for tracebacks
+def test_configure_logging_suppresses_framework_package_paths():
+ configure_logging(enable_rich_tracebacks=True)
+
+ traceback_handler = logging.getLogger("fastmcp").handlers[-1]
+ assert isinstance(traceback_handler, RichHandler)
+ suppressed_packages = {
+ Path(path).name
+ for path in traceback_handler.tracebacks_suppress
+ if isinstance(path, str)
+ }
+ assert {"fastmcp", "mcp", "pydantic"} <= suppressed_packages
+
+
def test_configure_logging_traceback_defaults_can_be_overridden():
"""Test that default traceback settings can be overridden by kwargs."""
configure_logging(
@@ -91,8 +107,6 @@ def test_configure_logging_with_rich_enabled():
# Should have two handlers when rich logging is enabled (normal + traceback)
assert len(logger.handlers) == 2
# Both should be RichHandler instances
- from rich.logging import RichHandler
-
assert all(isinstance(h, RichHandler) for h in logger.handlers)
finally:
fastmcp.settings.enable_rich_logging = original_enable_rich
From 8661193411ec0ed16bcf33513e7e5d59b6cab3e3 Mon Sep 17 00:00:00 2001
From: nate nowack <153965+jlowin@users.noreply.github.com>
Date: Wed, 5 Aug 2026 10:15:51 -0500
Subject: [PATCH 26/32] Quote run-claude allowed tools argument (#4741)
Generated with Codex
---
.github/actions/run-claude/action.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/actions/run-claude/action.yml b/.github/actions/run-claude/action.yml
index f97598d66..b79131462 100644
--- a/.github/actions/run-claude/action.yml
+++ b/.github/actions/run-claude/action.yml
@@ -93,7 +93,7 @@ runs:
track_progress: ${{ inputs.track-progress }}
prompt: ${{ inputs.prompt }}
claude_args: |
- ${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools {0}{1}', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }}
+ ${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools ''{0}{1}''', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }}
${{ inputs.mcp-servers != '' && format('--mcp-config ''{0}''', inputs.mcp-servers) || '' }}
--model ${{ inputs.model }}
settings: |
From 959daf232157a5e4dd76e0b82326029c7513cb85 Mon Sep 17 00:00:00 2001
From: Jake Kaplan <40362401+jakekaplan@users.noreply.github.com>
Date: Wed, 5 Aug 2026 15:46:10 -0400
Subject: [PATCH 27/32] Sanitize forwarded request metadata where the proxy
copies it (#4770)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Separate proxy protocol policy from client construction
๐ค Generated with OpenAI Codex
* Strip connection-owned request metadata at the proxy backend boundary
Co-Authored-By: Claude Fable 5
* Sanitize forwarded request metadata where the proxy copies it
Co-Authored-By: Claude Fable 5
* Forward hop-safe request metadata for proxied resources, templates, and prompts
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Claude Fable 5
---
docs/servers/providers/proxy.mdx | 4 +
.../fastmcp/server/providers/proxy.py | 75 +++++--
.../providers/proxy/test_proxy_client.py | 18 +-
.../proxy/test_proxy_request_meta.py | 191 ++++++++++++++++++
.../providers/proxy/test_proxy_server.py | 19 +-
.../proxy/test_stateful_proxy_client.py | 9 +-
6 files changed, 263 insertions(+), 53 deletions(-)
create mode 100644 tests/server/providers/proxy/test_proxy_request_meta.py
diff --git a/docs/servers/providers/proxy.mdx b/docs/servers/providers/proxy.mdx
index 2be18cefd..df09b1c80 100644
--- a/docs/servers/providers/proxy.mdx
+++ b/docs/servers/providers/proxy.mdx
@@ -240,6 +240,10 @@ proxy = create_proxy(
A modern client here reaches both `weather` and `calendar` on modern sessions, so a guard tool on either one round-trips end to end. An explicit `mode` pins every backend in the configuration, the same way it pins a single one.
+### Request Metadata
+
+Request `_meta` follows the same connection boundary. Progress tokens, tracing, task state, and application or vendor metadata pass through the proxy to the backend. The connection-owned keys โ protocol version, client identity, and client capabilities โ never copy from the frontend connection: a modern backend session stamps its own negotiated values, and a handshake-era backend receives none. This holds even when the two connections negotiate different eras, such as a modern client reaching a handshake-only backend through an explicit `mode`.
+
## Configuration-Based Proxies
diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py
index 9b1cd4a69..a304eacc3 100644
--- a/fastmcp_slim/fastmcp/server/providers/proxy.py
+++ b/fastmcp_slim/fastmcp/server/providers/proxy.py
@@ -130,6 +130,50 @@ def _proxy_upstream_error(error: Exception) -> MCPError:
)
+# Request `_meta` keys that describe one negotiated MCP connection. They never
+# cross the proxy: a modern backend session stamps its own negotiated values on
+# every request, and a handshake-era backend must not receive them at all.
+_CONNECTION_META_KEYS = frozenset(
+ {
+ mcp_types.PROTOCOL_VERSION_META_KEY,
+ mcp_types.CLIENT_INFO_META_KEY,
+ mcp_types.CLIENT_CAPABILITIES_META_KEY,
+ }
+)
+
+
+def _forwardable_request_meta(ctx: Context | None) -> dict[str, Any] | None:
+ """Frontend request metadata that may cross onto the backend connection.
+
+ This is the proxy's one sanctioned read of the inbound request's `_meta`:
+ progress tokens, tracing, task, and application metadata pass through,
+ while connection-owned keys (`_CONNECTION_META_KEYS`) are dropped because
+ they describe the frontend connection, not the backend one.
+ """
+ request_context = ctx.request_context if ctx is not None else None
+ if request_context is None or not request_context.meta:
+ return None
+ forwarded = {
+ key: value
+ for key, value in request_context.meta.items()
+ if key not in _CONNECTION_META_KEYS
+ }
+ return forwarded or None
+
+
+def _session_request_meta(
+ meta: dict[str, Any] | None,
+) -> mcp_types.RequestParamsMeta | None:
+ """Adapt forwardable metadata for a direct backend-session call.
+
+ Direct session calls bypass the high-level client mixins, so trace context
+ is injected here, matching what the mixins do on the legacy client paths.
+ """
+ return cast(
+ "mcp_types.RequestParamsMeta | None", inject_trace_context(meta) or None
+ )
+
+
async def _relay_read_resource(
client: Client, uri: str, ctx: Context | None
) -> (
@@ -143,15 +187,15 @@ async def _relay_read_resource(
to forward, instead of the high-level client trying to answer it here โ the
proxy has no back-channel to the real user, so driving it fails outright.
The inbound request's continuation state travels down so the backend guard
- sees the client's answers on its own `ctx.input_responses`. Trace context
- still propagates: the SDK's JSON-RPC dispatcher injects it on every outgoing
- request (SEP-414), below whichever client layer issued the call.
+ sees the client's answers on its own `ctx.input_responses`.
"""
+ meta = _forwardable_request_meta(ctx)
if client.protocol_version not in MODERN_PROTOCOL_VERSIONS:
- return await client.read_resource(uri)
+ return await client.read_resource(uri, meta=meta)
result = await client._await_with_session_monitoring(
client.session.read_resource(
uri,
+ meta=_session_request_meta(meta),
input_responses=ctx.input_responses if ctx else None,
request_state=ctx.request_state if ctx else None,
allow_input_required=True,
@@ -311,15 +355,11 @@ class ProxyTool(Tool):
async with client:
ctx = context or get_context()
_stash_proxy_request_context(client, ctx)
- # Forward the inbound request's `_meta` block (trace context,
- # version, etc.) to the backend. In SDK v2 the request context
- # exposes the lifted `_meta` dict directly; task submission is a
- # first-class params field rather than context state, so there
- # is no separate task-metadata injection here.
- req_ctx = ctx.request_context
- meta: dict[str, Any] | None = (
- dict(req_ctx.meta) if req_ctx is not None and req_ctx.meta else None
- )
+ # Forward the inbound request's hop-safe `_meta` (trace
+ # context, progress token, etc.) to the backend. Task
+ # submission is a first-class params field rather than context
+ # state, so there is no separate task-metadata injection here.
+ meta = _forwardable_request_meta(ctx)
if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
# Modern backend: call the session directly (not
@@ -330,10 +370,7 @@ class ProxyTool(Tool):
# round. Forward the inbound request's continuation state
# down so the backend guard tool sees the client's answers
# on its own `ctx.input_responses` / `ctx.request_state`.
- request_meta = cast(
- "mcp_types.RequestParamsMeta | None",
- inject_trace_context(meta) or None,
- )
+ request_meta = _session_request_meta(meta)
# SEP-2243: a modern backend rejects a `tools/call` whose
# `x-mcp-header` argument is not mirrored into an `Mcp-Param-*`
# header. The SDK client emits those headers only for tools it
@@ -704,6 +741,7 @@ class ProxyPrompt(Prompt):
ctx = get_context()
async with client:
_stash_proxy_request_context(client, ctx)
+ meta = _forwardable_request_meta(ctx)
if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
# See `_relay_read_resource`: surface a backend guard's ask
# instead of trying to answer it inside the proxy.
@@ -711,6 +749,7 @@ class ProxyPrompt(Prompt):
client.session.get_prompt(
backend_name,
arguments,
+ meta=_session_request_meta(meta),
input_responses=ctx.input_responses if ctx else None,
request_state=ctx.request_state if ctx else None,
allow_input_required=True,
@@ -720,7 +759,7 @@ class ProxyPrompt(Prompt):
return InputRequiredPromptResult(raw)
result = raw
else:
- result = await client.get_prompt(backend_name, arguments)
+ result = await client.get_prompt(backend_name, arguments, meta=meta)
# Convert GetPromptResult to PromptResult, preserving meta from result
# (not the static prompt meta which includes fastmcp tags)
# Convert PromptMessages to Messages
diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py
index caa4b9075..53b37129a 100644
--- a/tests/server/providers/proxy/test_proxy_client.py
+++ b/tests/server/providers/proxy/test_proxy_client.py
@@ -150,19 +150,11 @@ async def proxy_server(fastmcp_server: FastMCP):
`ProxyClient(fastmcp_server)` defaults to `mode="legacy"` (see
`TestProxyClientEraDefault` above โ a directly-constructed `ProxyClient`
always pins the handshake era, independent of `create_proxy`'s era
- mirroring). Every test below that forwards a tool call through this
- fixture (not just a listing) needs its front `Client` pinned to
- `mode="legacy"` too, for either or both of two reasons:
-
- - The test's subject is itself a handshake-only feature (roots / sampling
- / elicitation push, logging, progress): the modern era has no
- back-channel for server-initiated requests at all, so these forwarding
- paths cannot exist there.
- - Even for subjects that work on both eras, a modern front's request
- `_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s
- legacy-backend path forwards verbatim onto this legacy-locked backend
- session, which the backend server then rejects as a protocol
- violation.
+ mirroring). Tests below that exercise a handshake-only feature (roots /
+ sampling / elicitation push, logging, progress) pin their front `Client`
+ to `mode="legacy"` too: the modern era has no back-channel for
+ server-initiated requests at all, so these forwarding paths cannot exist
+ there.
"""
return create_proxy(ProxyClient(fastmcp_server))
diff --git a/tests/server/providers/proxy/test_proxy_request_meta.py b/tests/server/providers/proxy/test_proxy_request_meta.py
new file mode 100644
index 000000000..4776eedb5
--- /dev/null
+++ b/tests/server/providers/proxy/test_proxy_request_meta.py
@@ -0,0 +1,191 @@
+"""Request `_meta` ownership at the proxy's backend connection boundary.
+
+Protocol version, client identity, and client capabilities describe one
+negotiated MCP connection. The proxy must never copy them from its frontend
+connection onto its backend connection: a modern backend session stamps its
+own values, and a handshake-era backend must not receive them at all.
+Progress, tracing, task, and application metadata pass through untouched.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import mcp_types
+import pytest
+from mcp.client.extension import ClientExtension
+from mcp_types.version import MODERN_PROTOCOL_VERSIONS
+
+from fastmcp import Client, Context, FastMCP
+from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
+
+FRONT_EXTENSION_ID = "example.com/frontend"
+FRONT_INFO = mcp_types.Implementation(name="frontend-client", version="1.0")
+BACKEND_INFO = mcp_types.Implementation(name="proxy-backend", version="1.0")
+RESERVED_META_KEYS = {
+ mcp_types.PROTOCOL_VERSION_META_KEY,
+ mcp_types.CLIENT_INFO_META_KEY,
+ mcp_types.CLIENT_CAPABILITIES_META_KEY,
+}
+
+
+@dataclass
+class _RecordedRequest:
+ protocol_version: str
+ meta: dict[str, Any]
+
+
+class _FrontendExtension(ClientExtension):
+ identifier = FRONT_EXTENSION_ID
+
+ def settings(self) -> dict[str, Any]:
+ return {"frontend": True}
+
+
+def _recording_backend(seen: dict[str, _RecordedRequest]) -> FastMCP:
+ backend = FastMCP("metadata-backend")
+
+ def record(operation: str, ctx: Context) -> None:
+ request_context = ctx.request_context
+ assert request_context is not None
+ seen[operation] = _RecordedRequest(
+ protocol_version=request_context.protocol_version,
+ meta=dict(request_context.meta or {}),
+ )
+
+ @backend.tool
+ def inspect_tool(ctx: Context) -> str:
+ record("tool", ctx)
+ return "ok"
+
+ @backend.resource("data://metadata")
+ def inspect_resource(ctx: Context) -> str:
+ record("resource", ctx)
+ return "ok"
+
+ @backend.resource("data://items/{item_id}")
+ def inspect_template(item_id: str, ctx: Context) -> str:
+ record("template", ctx)
+ return "ok"
+
+ @backend.prompt
+ def inspect_prompt(ctx: Context) -> str:
+ record("prompt", ctx)
+ return "ok"
+
+ return backend
+
+
+def _proxy(
+ backend: FastMCP, *, backend_mode: str, client_class: type[Client]
+) -> FastMCPProxy:
+ return FastMCPProxy(
+ client_factory=lambda: client_class(
+ backend,
+ mode=backend_mode,
+ client_info=BACKEND_INFO,
+ )
+ )
+
+
+def _assert_backend_connection_meta(record: _RecordedRequest, modern: bool) -> None:
+ """The backend request carries the backend connection's own envelope.
+
+ On a handshake-era backend the reserved keys are absent. On a modern
+ backend they hold the backend session's negotiated version and the proxy
+ client's identity and capabilities โ never the frontend client's.
+ """
+ meta = record.meta
+ if not modern:
+ assert RESERVED_META_KEYS.isdisjoint(meta)
+ return
+
+ assert meta[mcp_types.PROTOCOL_VERSION_META_KEY] == record.protocol_version
+ assert meta[mcp_types.CLIENT_INFO_META_KEY] == BACKEND_INFO.model_dump(
+ by_alias=True, mode="json", exclude_none=True
+ )
+ capabilities = meta[mcp_types.CLIENT_CAPABILITIES_META_KEY]
+ assert FRONT_EXTENSION_ID not in capabilities.get("extensions", {})
+
+
+# Every allowed ClientFactoryT shape must be hop-safe, not just ProxyClient:
+# a plain Client backend runs the SDK's stock ClientSession rather than the
+# proxy's session class, so it exercises the copy-site sanitization alone.
+@pytest.mark.parametrize("client_class", [ProxyClient, Client])
+@pytest.mark.parametrize(
+ ("front_mode", "backend_mode", "backend_is_modern"),
+ [
+ ("auto", "auto", True),
+ ("auto", "legacy", False),
+ ("legacy", "auto", True),
+ ("legacy", "legacy", False),
+ ],
+)
+async def test_forwarded_tool_meta_stays_hop_safe(
+ front_mode: str,
+ backend_mode: str,
+ backend_is_modern: bool,
+ client_class: type[Client],
+):
+ seen: dict[str, _RecordedRequest] = {}
+ proxy = _proxy(
+ _recording_backend(seen), backend_mode=backend_mode, client_class=client_class
+ )
+
+ async with Client(
+ proxy,
+ mode=front_mode,
+ client_info=FRONT_INFO,
+ extensions=[_FrontendExtension()],
+ ) as client:
+ await client.call_tool(
+ "inspect_tool",
+ meta={
+ "progressToken": "front-progress",
+ "example.com/vendor": {"request": "kept"},
+ },
+ )
+
+ record = seen["tool"]
+ assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
+ assert isinstance(record.meta["progressToken"], str | int)
+ assert record.meta["example.com/vendor"] == {"request": "kept"}
+ _assert_backend_connection_meta(record, backend_is_modern)
+
+
+@pytest.mark.parametrize("client_class", [ProxyClient, Client])
+@pytest.mark.parametrize(
+ ("backend_mode", "backend_is_modern"),
+ [("auto", True), ("legacy", False)],
+)
+@pytest.mark.parametrize("operation", ["resource", "template", "prompt"])
+async def test_non_tool_requests_forward_hop_safe_metadata(
+ operation: str,
+ backend_mode: str,
+ backend_is_modern: bool,
+ client_class: type[Client],
+):
+ seen: dict[str, _RecordedRequest] = {}
+ proxy = _proxy(
+ _recording_backend(seen), backend_mode=backend_mode, client_class=client_class
+ )
+ meta = {"example.com/vendor": {"operation": operation}}
+
+ async with Client(
+ proxy,
+ mode="auto",
+ client_info=FRONT_INFO,
+ extensions=[_FrontendExtension()],
+ ) as client:
+ if operation == "resource":
+ await client.read_resource("data://metadata", meta=meta)
+ elif operation == "template":
+ await client.read_resource("data://items/42", meta=meta)
+ else:
+ await client.get_prompt("inspect_prompt", meta=meta)
+
+ record = seen[operation]
+ assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
+ assert record.meta["example.com/vendor"] == {"operation": operation}
+ _assert_backend_connection_meta(record, backend_is_modern)
diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py
index 8971b5ba0..fe489d56f 100644
--- a/tests/server/providers/proxy/test_proxy_server.py
+++ b/tests/server/providers/proxy/test_proxy_server.py
@@ -172,13 +172,7 @@ async def proxy_server(fastmcp_server):
raw `FastMCP`/URL/etc.) means `create_proxy` reuses that client as-is
instead of building one through the era-mirroring factory โ so this
backend stays pinned to `ProxyClient`'s own default of `mode="legacy"`
- regardless of what era the front client negotiates. A test that actually
- forwards a tool *call* through this fixture (not just a listing) needs
- its own front `Client` pinned to `mode="legacy"` too: otherwise a modern
- front's request `_meta` carries the reserved modern-envelope keys, which
- `ProxyTool.run`'s legacy-backend path forwards verbatim onto this
- legacy-locked backend session, and the backend server rejects it as a
- protocol violation.
+ regardless of what era the front client negotiates.
"""
return create_proxy(ProxyClient(transport=FastMCPTransport(fastmcp_server)))
@@ -1250,11 +1244,7 @@ class TestProxyOutputSchemaEnforcement:
# This proxy's backend is built via `ProxyProvider(lambda: ProxyClient(...))`
# directly rather than through `create_proxy`'s era-mirroring factory, so it
# stays pinned to `ProxyClient`'s own default of `mode="legacy"` regardless
- # of the front era (see the `proxy_server` fixture docstring above for the
- # full explanation). Pin the end client to match: a modern front's request
- # `_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s
- # legacy-backend path forwards verbatim, and this legacy-locked backend
- # session rejects them as a protocol violation.
+ # of the front era (see the `proxy_server` fixture docstring above).
client = Client(server, mode="legacy")
client._transport_options = TransportOptions(
session_class=_ForwardingClientSession
@@ -1428,10 +1418,7 @@ class TestProxyForwardingAppliesToEveryBackendClient:
# era. A multi-server config instead mounts a router with a
# StatefulProxyClient per configured server leg โ an already-constructed
# ProxyClient subclass, same as the `proxy_server` fixture above, pinned
- # to `mode="legacy"` regardless of the front. Callers with that backend
- # shape must pin the end client to legacy too, for the reason explained
- # there (a modern front's request `_meta` gets forwarded verbatim onto a
- # legacy-locked backend session and rejected as a protocol violation).
+ # to `mode="legacy"` regardless of the front.
client = Client(server, mode=mode)
client._transport_options = TransportOptions(
session_class=_ForwardingClientSession
diff --git a/tests/server/providers/proxy/test_stateful_proxy_client.py b/tests/server/providers/proxy/test_stateful_proxy_client.py
index 1b64afd2d..0bf832440 100644
--- a/tests/server/providers/proxy/test_stateful_proxy_client.py
+++ b/tests/server/providers/proxy/test_stateful_proxy_client.py
@@ -62,12 +62,9 @@ async def stateful_proxy_server(fastmcp_server: FastMCP):
# `mode="legacy"` default for a directly-constructed instance (see
# `TestProxyClientEraDefault` in test_proxy_client.py) โ this backend isn't
# built through `create_proxy`'s era-mirroring factory, so it stays pinned
- # regardless of the front era. Every test below that forwards a real tool
- # call through this fixture pins its front `Client` to `mode="legacy"` too:
- # otherwise a modern front's request `_meta` carries reserved
- # modern-envelope keys that `ProxyTool.run`'s legacy-backend path forwards
- # verbatim, and this legacy-locked backend session rejects them as a
- # protocol violation.
+ # regardless of the front era. Tests of handshake-only forwarding pin their
+ # front `Client` to `mode="legacy"` too: those server-initiated
+ # interactions do not exist on modern connections.
client = StatefulProxyClient(transport=FastMCPTransport(fastmcp_server))
return FastMCPProxy(client_factory=client.new_stateful)
From 875e8e18bd41a81a6614183d7b982a4c79bb1be2 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Wed, 5 Aug 2026 18:09:16 -0400
Subject: [PATCH 28/32] Preserve legacy httpx compatibility without importing
it (#4766)
---
.../upgrading/from-fastmcp-3.mdx | 2 +-
.../server/auth/oauth_proxy/upstream.py | 3 +-
.../server/providers/openapi/README.md | 6 +-
.../server/providers/openapi/components.py | 101 +++++------
.../server/providers/openapi/provider.py | 22 ++-
fastmcp_slim/fastmcp/server/server.py | 55 ++----
fastmcp_slim/fastmcp/utilities/exceptions.py | 52 +++---
.../fastmcp/utilities/openapi/README.md | 14 +-
.../providers/openapi/test_comprehensive.py | 7 -
.../openapi/test_legacy_client_compat.py | 168 ++++++------------
tests/server/test_legacy_httpx_errors.py | 33 ++++
tests/test_no_legacy_httpx.py | 41 ++++-
12 files changed, 256 insertions(+), 248 deletions(-)
create mode 100644 tests/server/test_legacy_httpx_errors.py
diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx
index 37ff33f62..1cd484680 100644
--- a/docs/getting-started/upgrading/from-fastmcp-3.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx
@@ -209,7 +209,7 @@ transport = StreamableHttpTransport(
)
```
-The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) is now type-hinted `httpx2.AsyncClient`. FastMCP does not gate on the type, so an existing `httpx.AsyncClient` keeps working at runtime via duck-typing this release โ but switching it to `httpx2.AsyncClient` clears the type hint and is the supported path going forward. HTTP made inside your own tools is entirely yours and is unaffected either way.
+The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) should now be an `httpx2.AsyncClient`. Existing `httpx.AsyncClient` instances remain temporarily accepted via duck typing, but emit a `FastMCPDeprecationWarning` and will be rejected in a future release. HTTP made inside your own tools is entirely yours and is unaffected.
**The subtlest break is exception handlers, and no type checker will catch it.** `httpx` very likely remains installed in your environment (the Anthropic, OpenAI, and Google SDKs all depend on it), so code that catches old-httpx exceptions around FastMCP calls still imports and still type-checks โ it just never matches, because FastMCP now raises `httpx2` exceptions. The handler silently becomes dead code:
diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py
index 8739aa7a3..bed40f670 100644
--- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py
+++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py
@@ -43,8 +43,7 @@ class AsyncOAuth2Client:
Drop-in replacement for the slice of authlib's `AsyncOAuth2Client` that
`OAuthProxy` uses. Subclasses of `OAuthProxy` that override
`_create_upstream_oauth_client` may return any object with the same
- `fetch_token`/`refresh_token`/`client_secret`/`aclose` surface (including
- an authlib client, if legacy httpx is installed in their environment).
+ `fetch_token`/`refresh_token`/`client_secret`/`aclose` surface.
"""
def __init__(
diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/README.md b/fastmcp_slim/fastmcp/server/providers/openapi/README.md
index 8c5e890c4..8b55d8753 100644
--- a/fastmcp_slim/fastmcp/server/providers/openapi/README.md
+++ b/fastmcp_slim/fastmcp/server/providers/openapi/README.md
@@ -53,7 +53,7 @@ The main server class orchestrates the stateless request building approach:
```python
class FastMCPOpenAPI(FastMCP):
- def __init__(self, openapi_spec: dict, client: httpx.AsyncClient, **kwargs):
+ def __init__(self, openapi_spec: dict, client: httpx2.AsyncClient, **kwargs):
# 1. Parse OpenAPI spec to HTTP routes with pre-calculated schemas
self._routes = parse_openapi_to_http_routes(openapi_spec)
@@ -92,7 +92,7 @@ OpenAPI Spec โ HTTPRoute with Pre-calculated Fields โ RequestDirector โ HT
2. **RequestDirector Setup**: openapi-core Spec initialized for request building
3. **Component Creation**: Create components with RequestDirector reference
4. **Request Building**: RequestDirector builds HTTP request from flat parameters
-5. **Request Execution**: Execute request with httpx client
+5. **Request Execution**: Execute request with httpx2 client
6. **Response Processing**: Return structured MCP response
## Key Features
@@ -263,4 +263,4 @@ logging.getLogger("fastmcp.server.openapi_new").setLevel(logging.DEBUG)
- `/utilities/openapi_new/README.md` - Utility implementation details
- `/server/openapi/README.md` - Legacy implementation reference
- `/tests/server/openapi_new/` - Comprehensive test suite
-- Project documentation on OpenAPI integration patterns
\ No newline at end of file
+- Project documentation on OpenAPI integration patterns
diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/components.py b/fastmcp_slim/fastmcp/server/providers/openapi/components.py
index 3cb36abcf..a6b88538e 100644
--- a/fastmcp_slim/fastmcp/server/providers/openapi/components.py
+++ b/fastmcp_slim/fastmcp/server/providers/openapi/components.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import json
import re
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any
import httpx2
from mcp_types import ToolAnnotations
@@ -18,11 +18,7 @@ from fastmcp.resources import (
)
from fastmcp.server.dependencies import get_http_headers
from fastmcp.tools.base import Tool, ToolResult
-from fastmcp.utilities.exceptions import (
- HTTP_STATUS_ERRORS,
- REQUEST_ERRORS,
- TIMEOUT_ERRORS,
-)
+from fastmcp.utilities.exceptions import is_request_error, is_timeout_error
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HTTPRoute
from fastmcp.utilities.openapi.director import RequestDirector
@@ -63,6 +59,36 @@ logger = get_logger(__name__)
_DEFAULT_MIME_TYPE = "application/json"
+def _raise_for_status(response: httpx2.Response) -> None:
+ """Raise an OpenAPI-formatted error without relying on client exception types."""
+ if 200 <= response.status_code < 300:
+ return
+
+ error_message = f"HTTP error {response.status_code}: {response.reason_phrase}"
+ try:
+ error_data = response.json()
+ error_message += f" - {error_data}"
+ except (json.JSONDecodeError, ValueError):
+ if response.text:
+ error_message += f" - {response.text}"
+ raise ValueError(error_message)
+
+
+async def _send_request(
+ client: httpx2.AsyncClient,
+ request: httpx2.Request,
+) -> httpx2.Response:
+ """Send a request while preserving transitional legacy-client errors."""
+ try:
+ return await client.send(request)
+ except Exception as exc:
+ if is_timeout_error(exc):
+ raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
+ if is_request_error(exc):
+ raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
+ raise
+
+
def _extract_mime_type_from_route(route: HTTPRoute) -> str:
"""Extract the primary MIME type from an HTTPRoute's response definitions.
@@ -176,12 +202,8 @@ class OpenAPITool(Tool):
base_url = str(self._client.base_url) or "http://localhost"
directed_request = self._director.build(self._route, arguments, base_url)
- # Rebuild through the user's client so the request object comes
- # from whichever httpx library the client belongs to (a legacy
- # httpx.AsyncClient cannot send an httpx2.Request). Primitive
- # values (str/bytes/tuples) cross that boundary safely; client
- # default headers merge in with directed headers taking priority,
- # matching the previous manual merge.
+ # Rebuild through the configured client so its default headers are
+ # merged with the directed headers taking priority.
request = self._client.build_request(
method=directed_request.method,
url=str(directed_request.url.copy_with(query=None)),
@@ -210,8 +232,8 @@ class OpenAPITool(Tool):
f"run - sending request; headers: {_redact_headers(request.headers)}"
)
- response = await self._client.send(request)
- response.raise_for_status()
+ response = await _send_request(self._client, request)
+ _raise_for_status(response)
# Try to parse as JSON first
try:
@@ -238,25 +260,11 @@ class OpenAPITool(Tool):
except json.JSONDecodeError:
return ToolResult(content=response.text)
- except HTTP_STATUS_ERRORS as e:
- status_error = cast("httpx2.HTTPStatusError", e)
- error_message = (
- f"HTTP error {status_error.response.status_code}: "
- f"{status_error.response.reason_phrase}"
- )
- try:
- error_data = status_error.response.json()
- error_message += f" - {error_data}"
- except (json.JSONDecodeError, ValueError):
- if status_error.response.text:
- error_message += f" - {status_error.response.text}"
- raise ValueError(error_message) from e
+ except httpx2.TimeoutException as exc:
+ raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
- except TIMEOUT_ERRORS as e:
- raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
-
- except REQUEST_ERRORS as e:
- raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
+ except httpx2.RequestError as exc:
+ raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
class OpenAPIResource(Resource):
@@ -298,8 +306,7 @@ class OpenAPIResource(Resource):
directed_request = self._director.build(
self._route, self._arguments, base_url
)
- # Primitive values only: a legacy httpx.AsyncClient cannot accept
- # httpx2 URL/QueryParams/Headers objects.
+ # Build through the configured client so its defaults are applied.
request = self._client.build_request(
method=directed_request.method,
url=str(directed_request.url.copy_with(query=None)),
@@ -314,8 +321,8 @@ class OpenAPIResource(Resource):
if mcp_headers:
request.headers.update(mcp_headers)
- response = await self._client.send(request)
- response.raise_for_status()
+ response = await _send_request(self._client, request)
+ _raise_for_status(response)
content_type = response.headers.get("content-type", "").lower()
@@ -343,25 +350,11 @@ class OpenAPIResource(Resource):
]
)
- except HTTP_STATUS_ERRORS as e:
- status_error = cast("httpx2.HTTPStatusError", e)
- error_message = (
- f"HTTP error {status_error.response.status_code}: "
- f"{status_error.response.reason_phrase}"
- )
- try:
- error_data = status_error.response.json()
- error_message += f" - {error_data}"
- except (json.JSONDecodeError, ValueError):
- if status_error.response.text:
- error_message += f" - {status_error.response.text}"
- raise ValueError(error_message) from e
+ except httpx2.TimeoutException as exc:
+ raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
- except TIMEOUT_ERRORS as e:
- raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
-
- except REQUEST_ERRORS as e:
- raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
+ except httpx2.RequestError as exc:
+ raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str:
diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/provider.py b/fastmcp_slim/fastmcp/server/providers/openapi/provider.py
index 4048479a0..c16f14034 100644
--- a/fastmcp_slim/fastmcp/server/providers/openapi/provider.py
+++ b/fastmcp_slim/fastmcp/server/providers/openapi/provider.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import warnings
from collections import Counter
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
@@ -10,6 +11,7 @@ from typing import Any, Literal, cast
import httpx2
from jsonschema_path import SchemaPath
+from fastmcp._warnings import FastMCPDeprecationWarning
from fastmcp.prompts import Prompt
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.providers.base import Provider
@@ -48,6 +50,14 @@ logger = get_logger(__name__)
DEFAULT_TIMEOUT: float = 30.0
+def _is_legacy_httpx_client(client: object) -> bool:
+ """Detect a legacy httpx client without importing the legacy package."""
+ return any(
+ cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == "AsyncClient"
+ for cls in type(client).__mro__
+ )
+
+
class OpenAPIProvider(Provider):
"""Provider that creates MCP components from an OpenAPI specification.
@@ -84,10 +94,12 @@ class OpenAPIProvider(Provider):
Args:
openapi_spec: OpenAPI schema as a dictionary
- client: Optional httpx AsyncClient for making HTTP requests.
+ client: Optional httpx2 AsyncClient for making HTTP requests.
If not provided, a default client is created using the first
server URL from the OpenAPI spec with a 30-second timeout.
To customize timeout or other settings, pass your own client.
+ Legacy httpx clients are temporarily accepted with a deprecation
+ warning.
route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization
@@ -103,6 +115,14 @@ class OpenAPIProvider(Provider):
self._owns_client = client is None
if client is None:
client = self._create_default_client(openapi_spec)
+ elif _is_legacy_httpx_client(client):
+ warnings.warn(
+ "Passing an httpx.AsyncClient to OpenAPIProvider is deprecated "
+ "and will be removed in a future release. Pass an "
+ "httpx2.AsyncClient instead.",
+ FastMCPDeprecationWarning,
+ stacklevel=2,
+ )
self._client = client
self._mcp_component_fn = mcp_component_fn
self._validate_output = validate_output
diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py
index a25a78712..de3cba77b 100644
--- a/fastmcp_slim/fastmcp/server/server.py
+++ b/fastmcp_slim/fastmcp/server/server.py
@@ -88,7 +88,7 @@ from fastmcp.tools.base import Tool, ToolResult
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ToolTransformConfig
from fastmcp.utilities.components import FastMCPComponent, _coerce_version
-from fastmcp.utilities.exceptions import HTTP_STATUS_ERRORS, TIMEOUT_ERRORS
+from fastmcp.utilities.exceptions import get_http_status_code, is_timeout_error
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT
@@ -112,11 +112,6 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
-# Both-library catch tuples for user-supplied code that may still raise legacy
-# httpx exceptions; see fastmcp.utilities.exceptions for the defensive import.
-_ACTIONABLE_HTTP_STATUS_ERRORS = HTTP_STATUS_ERRORS
-_ACTIONABLE_TIMEOUT_ERRORS = TIMEOUT_ERRORS
-
def _version_request_meta(
version: VersionSpec | None,
@@ -1546,15 +1541,11 @@ class FastMCP(
logger.exception(f"Error calling tool {name!r}")
# Handle actionable errors that should reach the LLM
# even when masking is enabled
- if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS):
- if (
- cast("httpx2.HTTPStatusError", e).response.status_code
- == 429
- ):
- raise ToolError(
- "Rate limited by upstream API, please retry later"
- ) from e
- if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
+ if get_http_status_code(e) == 429:
+ raise ToolError(
+ "Rate limited by upstream API, please retry later"
+ ) from e
+ if is_timeout_error(e):
raise ToolError(
"Upstream request timed out, please retry"
) from e
@@ -1649,15 +1640,11 @@ class FastMCP(
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
# Handle actionable errors that should reach the LLM
- if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS):
- if (
- cast("httpx2.HTTPStatusError", e).response.status_code
- == 429
- ):
- raise ResourceError(
- "Rate limited by upstream API, please retry later"
- ) from e
- if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
+ if get_http_status_code(e) == 429:
+ raise ResourceError(
+ "Rate limited by upstream API, please retry later"
+ ) from e
+ if is_timeout_error(e):
raise ResourceError(
"Upstream request timed out, please retry"
) from e
@@ -1712,15 +1699,11 @@ class FastMCP(
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
# Handle actionable errors that should reach the LLM
- if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS):
- if (
- cast("httpx2.HTTPStatusError", e).response.status_code
- == 429
- ):
- raise ResourceError(
- "Rate limited by upstream API, please retry later"
- ) from e
- if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
+ if get_http_status_code(e) == 429:
+ raise ResourceError(
+ "Rate limited by upstream API, please retry later"
+ ) from e
+ if is_timeout_error(e):
raise ResourceError(
"Upstream request timed out, please retry"
) from e
@@ -2412,10 +2395,10 @@ class FastMCP(
Args:
openapi_spec: OpenAPI schema as a dictionary
client: Optional httpx2 AsyncClient for making HTTP requests.
- An httpx (v1) AsyncClient is also accepted and works via
- duck-typing. If not provided, a default client is created
- using the first
+ If not provided, a default client is created using the first
server URL from the OpenAPI spec with a 30-second timeout.
+ Legacy httpx clients are temporarily accepted with a deprecation
+ warning.
name: Name for the MCP server
route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping
diff --git a/fastmcp_slim/fastmcp/utilities/exceptions.py b/fastmcp_slim/fastmcp/utilities/exceptions.py
index f9166a2b6..97cea8f29 100644
--- a/fastmcp_slim/fastmcp/utilities/exceptions.py
+++ b/fastmcp_slim/fastmcp/utilities/exceptions.py
@@ -7,30 +7,42 @@ from mcp import MCPError
import fastmcp
-# FastMCP uses httpx2 internally, but user-supplied code (tools, resources, and
-# clients handed to the OpenAPI integration) may still raise exceptions from the
-# legacy httpx package. These catch tuples include both families when httpx is
-# installed, so user errors keep their specific handling without making httpx a
-# FastMCP dependency. The two libraries' exception hierarchies match name-for-name.
-try:
- import httpx
- HTTP_STATUS_ERRORS: tuple[type[BaseException], ...] = (
- httpx2.HTTPStatusError,
- httpx.HTTPStatusError,
+def _is_legacy_httpx_exception(exc: BaseException, exception_type: str) -> bool:
+ """Check a legacy-httpx exception without importing the legacy package."""
+ return any(
+ cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == exception_type
+ for cls in type(exc).__mro__
)
- TIMEOUT_ERRORS: tuple[type[BaseException], ...] = (
- httpx2.TimeoutException,
- httpx.TimeoutException,
+
+
+def is_http_status_error(exc: BaseException) -> bool:
+ """Return whether an exception is an httpx2 or legacy-httpx status error."""
+ return isinstance(exc, httpx2.HTTPStatusError) or _is_legacy_httpx_exception(
+ exc, "HTTPStatusError"
)
- REQUEST_ERRORS: tuple[type[BaseException], ...] = (
- httpx2.RequestError,
- httpx.RequestError,
+
+
+def get_http_status_code(exc: BaseException) -> int | None:
+ """Return the response status code from a recognized HTTP status error."""
+ if not is_http_status_error(exc):
+ return None
+ status_code = getattr(getattr(exc, "response", None), "status_code", None)
+ return status_code if isinstance(status_code, int) else None
+
+
+def is_timeout_error(exc: BaseException) -> bool:
+ """Return whether an exception is an httpx2 or legacy-httpx timeout."""
+ return isinstance(exc, httpx2.TimeoutException) or _is_legacy_httpx_exception(
+ exc, "TimeoutException"
+ )
+
+
+def is_request_error(exc: BaseException) -> bool:
+ """Return whether an exception is an httpx2 or legacy-httpx request error."""
+ return isinstance(exc, httpx2.RequestError) or _is_legacy_httpx_exception(
+ exc, "RequestError"
)
-except ImportError:
- HTTP_STATUS_ERRORS = (httpx2.HTTPStatusError,)
- TIMEOUT_ERRORS = (httpx2.TimeoutException,)
- REQUEST_ERRORS = (httpx2.RequestError,)
def iter_exc(group: BaseExceptionGroup):
diff --git a/fastmcp_slim/fastmcp/utilities/openapi/README.md b/fastmcp_slim/fastmcp/utilities/openapi/README.md
index 2f2a5f45f..c5e478e19 100644
--- a/fastmcp_slim/fastmcp/utilities/openapi/README.md
+++ b/fastmcp_slim/fastmcp/utilities/openapi/README.md
@@ -47,7 +47,7 @@ OpenAPI Spec โ Parser โ HTTPRoute with Pre-calculated Fields โ RequestDire
### Request Processing
```
-MCP Tool Call โ RequestDirector.build() โ httpx.Request โ HTTP Response โ Structured Output
+MCP Tool Call โ RequestDirector.build() โ httpx2.Request โ HTTP Response โ Structured Output
```
1. **Tool Invocation**: FastMCP receives tool call with parameters
@@ -103,14 +103,14 @@ All components use the same RequestDirector approach:
### Basic Server Setup
```python
-import httpx
+import httpx2
from fastmcp.server.openapi import FastMCPOpenAPI
# OpenAPI spec (can be loaded from file/URL)
openapi_spec = {...}
# Create HTTP client
-async with httpx.AsyncClient() as client:
+async with httpx2.AsyncClient() as client:
# Create server with stateless request building
server = FastMCPOpenAPI(
openapi_spec=openapi_spec,
@@ -134,8 +134,8 @@ director = RequestDirector(spec)
# Build HTTP request
request = director.build(route, flat_arguments, base_url)
-# Execute with httpx
-async with httpx.AsyncClient() as client:
+# Execute with httpx2
+async with httpx2.AsyncClient() as client:
response = await client.send(request)
```
@@ -206,6 +206,6 @@ Tests are located in `/tests/server/openapi/`:
## Dependencies
- `openapi-core` - OpenAPI specification processing and validation
-- `httpx` - HTTP client library
+- `httpx2` - HTTP client library
- `pydantic` - Data validation and serialization
-- `urllib.parse` - URL building and manipulation
\ No newline at end of file
+- `urllib.parse` - URL building and manipulation
diff --git a/tests/server/providers/openapi/test_comprehensive.py b/tests/server/providers/openapi/test_comprehensive.py
index d5b8ceffd..ace2a03a5 100644
--- a/tests/server/providers/openapi/test_comprehensive.py
+++ b/tests/server/providers/openapi/test_comprehensive.py
@@ -653,13 +653,6 @@ class TestOpenAPIComprehensive:
mock_response.json.return_value = {"code": 404, "message": "User not found"}
mock_response.text = json.dumps({"code": 404, "message": "User not found"})
- # Configure raise_for_status to raise HTTPStatusError
- def raise_for_status():
- raise httpx2.HTTPStatusError(
- "404 Not Found", request=Mock(), response=mock_response
- )
-
- mock_response.raise_for_status = raise_for_status
mock_client.send = AsyncMock(return_value=mock_response)
server = create_openapi_server(
diff --git a/tests/server/providers/openapi/test_legacy_client_compat.py b/tests/server/providers/openapi/test_legacy_client_compat.py
index 707be823d..3b0361ad8 100644
--- a/tests/server/providers/openapi/test_legacy_client_compat.py
+++ b/tests/server/providers/openapi/test_legacy_client_compat.py
@@ -1,20 +1,9 @@
-"""Legacy-httpx client compatibility for the OpenAPI integration.
-
-The upgrade guide promises that an existing legacy ``httpx.AsyncClient`` passed
-to ``OpenAPIProvider``/``FastMCP.from_openapi`` keeps working via duck-typing.
-That requires two things of the OpenAPI request path: requests must be built
-through the user's own client (``build_request``), and errors raised by that
-client โ which are legacy-httpx exceptions, not httpx2 โ must still receive the
-integration's specific error formatting rather than surfacing as generic
-failures.
-"""
+"""Deprecation bridge for legacy-httpx OpenAPI clients."""
import pytest
-from fastmcp import FastMCP
-from fastmcp.client import Client
+from fastmcp import Client, FastMCP, FastMCPDeprecationWarning
from fastmcp.exceptions import ToolError
-from fastmcp.server.providers.openapi import OpenAPIProvider
httpx = pytest.importorskip("httpx", reason="legacy httpx not installed")
@@ -26,7 +15,6 @@ SPEC = {
"/items": {
"get": {
"operationId": "list_items",
- "summary": "List items",
"responses": {
"200": {
"description": "Items",
@@ -46,121 +34,77 @@ SPEC = {
}
},
}
- },
+ }
},
}
-def _legacy_client(handler) -> "httpx.AsyncClient":
- transport = httpx.MockTransport(handler)
- return httpx.AsyncClient(transport=transport, base_url="https://api.example.com")
-
-
-def _server(client) -> FastMCP:
- mcp = FastMCP("Legacy Client Server")
- mcp.add_provider(OpenAPIProvider(openapi_spec=SPEC, client=client))
- return mcp
-
-
-async def test_tool_call_with_legacy_client_succeeds():
- """A legacy httpx.AsyncClient drives an OpenAPI tool end-to-end."""
-
+async def test_legacy_client_warns_and_remains_usable() -> None:
def handler(request: "httpx.Request") -> "httpx.Response":
- assert isinstance(request, httpx.Request)
return httpx.Response(200, json={"items": ["a", "b"]})
- async with _legacy_client(handler) as client:
- async with Client(_server(client)) as mcp_client:
+ transport = httpx.MockTransport(handler)
+ async with httpx.AsyncClient(
+ transport=transport,
+ base_url="https://api.example.com",
+ ) as client:
+ with pytest.warns(
+ FastMCPDeprecationWarning,
+ match="httpx.AsyncClient.*deprecated",
+ ):
+ server = FastMCP.from_openapi(SPEC, client=client)
+
+ async with Client(server) as mcp_client:
result = await mcp_client.call_tool("list_items", {})
- assert result.structured_content == {"items": ["a", "b"]}
+
+ assert result.structured_content == {"items": ["a", "b"]}
-async def test_tool_http_error_keeps_openapi_formatting_with_legacy_client():
- """A legacy client's HTTP error still gets the integration's message format.
-
- The handler raises legacy ``httpx.HTTPStatusError``; the catch tuples must
- recognize it so the error carries the formatted status + body rather than a
- generic failure.
- """
-
+async def test_legacy_client_preserves_http_error_details() -> None:
def handler(request: "httpx.Request") -> "httpx.Response":
- return httpx.Response(500, json={"detail": "boom"})
+ return httpx.Response(404, json={"detail": "items not found"})
- async with _legacy_client(handler) as client:
- async with Client(_server(client)) as mcp_client:
- with pytest.raises(ToolError, match="HTTP error 500") as excinfo:
- await mcp_client.call_tool("list_items", {})
- assert "boom" in str(excinfo.value)
+ transport = httpx.MockTransport(handler)
+ async with httpx.AsyncClient(
+ transport=transport,
+ base_url="https://api.example.com",
+ ) as client:
+ with pytest.warns(FastMCPDeprecationWarning):
+ server = FastMCP.from_openapi(SPEC, client=client)
-
-async def test_tool_request_error_keeps_openapi_formatting_with_legacy_client():
- """A legacy client's transport error maps to the formatted request error."""
-
- def handler(request: "httpx.Request") -> "httpx.Response":
- raise httpx.ConnectError("connection refused")
-
- async with _legacy_client(handler) as client:
- async with Client(_server(client)) as mcp_client:
- with pytest.raises(ToolError, match="Request error"):
+ async with Client(server) as mcp_client:
+ with pytest.raises(ToolError, match="HTTP error 404") as exc_info:
await mcp_client.call_tool("list_items", {})
+ assert "items not found" in str(exc_info.value)
-async def test_multipart_tool_call_with_legacy_client():
- """Multipart bodies must materialize and send through a legacy client too."""
- spec = {
- "openapi": "3.0.0",
- "info": {"title": "Upload API", "version": "1.0.0"},
- "servers": [{"url": "https://api.example.com"}],
- "paths": {
- "/upload": {
- "post": {
- "operationId": "upload_file",
- "summary": "Upload a file",
- "requestBody": {
- "required": True,
- "content": {
- "multipart/form-data": {
- "schema": {
- "type": "object",
- "properties": {"file": {"type": "string"}},
- }
- }
- },
- },
- "responses": {
- "200": {
- "description": "Uploaded",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {"ok": {"type": "boolean"}},
- }
- }
- },
- }
- },
- }
- }
- },
- }
- received: dict[str, object] = {}
+@pytest.mark.parametrize(
+ ("error_kind", "message"),
+ [
+ ("timeout", "HTTP request timed out (ReadTimeout)"),
+ ("connect", "Request error (ConnectError)"),
+ ],
+)
+async def test_legacy_client_preserves_transport_error_details(
+ error_kind: str,
+ message: str,
+) -> None:
def handler(request: "httpx.Request") -> "httpx.Response":
- received["content_type"] = request.headers.get("content-type", "")
- received["body"] = request.read()
- return httpx.Response(200, json={"ok": True})
+ if error_kind == "timeout":
+ raise httpx.ReadTimeout("transport failed", request=request)
+ raise httpx.ConnectError("transport failed", request=request)
- async with _legacy_client(handler) as client:
- mcp = FastMCP("Legacy Multipart Server")
- mcp.add_provider(OpenAPIProvider(openapi_spec=spec, client=client))
- async with Client(mcp) as mcp_client:
- result = await mcp_client.call_tool("upload_file", {"file": "data"})
- assert result.structured_content == {"ok": True}
+ transport = httpx.MockTransport(handler)
+ async with httpx.AsyncClient(
+ transport=transport,
+ base_url="https://api.example.com",
+ ) as client:
+ with pytest.warns(FastMCPDeprecationWarning):
+ server = FastMCP.from_openapi(SPEC, client=client)
- content_type = received["content_type"]
- assert isinstance(content_type, str)
- assert "multipart/form-data" in content_type
- body = received["body"]
- assert isinstance(body, bytes)
- assert b"data" in body
+ async with Client(server) as mcp_client:
+ with pytest.raises(ToolError) as exc_info:
+ await mcp_client.call_tool("list_items", {})
+
+ assert message in str(exc_info.value)
diff --git a/tests/server/test_legacy_httpx_errors.py b/tests/server/test_legacy_httpx_errors.py
new file mode 100644
index 000000000..38f9434a0
--- /dev/null
+++ b/tests/server/test_legacy_httpx_errors.py
@@ -0,0 +1,33 @@
+"""Compatibility tests for legacy-httpx exceptions raised by user code."""
+
+import pytest
+
+from fastmcp import FastMCP
+from fastmcp.exceptions import ResourceError, ToolError
+
+httpx = pytest.importorskip("httpx", reason="legacy httpx not installed")
+
+
+async def test_legacy_httpx_rate_limit_remains_actionable() -> None:
+ server = FastMCP("Legacy httpx errors", mask_error_details=True)
+
+ @server.tool
+ def rate_limited() -> None:
+ request = httpx.Request("GET", "https://example.com")
+ response = httpx.Response(429, request=request)
+ raise httpx.HTTPStatusError("rate limited", request=request, response=response)
+
+ with pytest.raises(ToolError, match="Rate limited by upstream API"):
+ await server.call_tool("rate_limited", {})
+
+
+async def test_legacy_httpx_resource_timeout_remains_actionable() -> None:
+ server = FastMCP("Legacy httpx errors", mask_error_details=True)
+
+ @server.resource("resource://timed-out")
+ def timed_out() -> str:
+ request = httpx.Request("GET", "https://example.com")
+ raise httpx.ReadTimeout("timed out", request=request)
+
+ with pytest.raises(ResourceError, match="Upstream request timed out"):
+ await server.read_resource("resource://timed-out")
diff --git a/tests/test_no_legacy_httpx.py b/tests/test_no_legacy_httpx.py
index 5cd439f87..24ee01366 100644
--- a/tests/test_no_legacy_httpx.py
+++ b/tests/test_no_legacy_httpx.py
@@ -6,11 +6,9 @@ masks clean-install regressions: an accidental ``import httpx`` (directly or
via a third-party integration such as authlib's httpx client) passes CI but
breaks any install without those extras.
-This test simulates the clean install by running a subprocess that blocks
-legacy httpx imports at the meta-path level, then imports the modules that
-have historically regressed. The defensive user-compat shim in
-``fastmcp.server.server`` catches ImportError by design and must keep working
-when httpx is absent.
+These tests simulate a clean install by blocking legacy httpx imports at the
+meta-path level and verify that ordinary server startup leaves both legacy
+packages unloaded.
"""
import subprocess
@@ -45,6 +43,28 @@ _BLOCKER_SCRIPT = textwrap.dedent(
"""
)
+_STARTUP_SCRIPT = textwrap.dedent(
+ """
+ import sys
+
+ from fastmcp import FastMCP
+
+ server = FastMCP("Legacy httpx import guard")
+ app = server.http_app(transport="http", stateless_http=True)
+ assert app is not None
+
+ loaded = [
+ name
+ for name in sys.modules
+ if name == "httpx"
+ or name.startswith("httpx.")
+ or name == "httpcore"
+ or name.startswith("httpcore.")
+ ]
+ assert not loaded, loaded
+ """
+)
+
@pytest.mark.subprocess_heavy
def test_fastmcp_imports_without_legacy_httpx():
@@ -58,3 +78,14 @@ def test_fastmcp_imports_without_legacy_httpx():
f"Import failed with legacy httpx blocked:\n{result.stderr}"
)
assert "OK" in result.stdout
+
+
+@pytest.mark.subprocess_heavy
+def test_default_http_app_does_not_load_legacy_httpx():
+ result = subprocess.run(
+ [sys.executable, "-c", _STARTUP_SCRIPT],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
From 2bee9aeb58c83f1ce7dc2f66240d1ef60b1b2c48 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Thu, 6 Aug 2026 09:34:11 -0400
Subject: [PATCH 29/32] Clarify review of closed contributor PRs (#4780)
---
CLAUDE.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 1a2cbddb0..79b040531 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -56,6 +56,8 @@ When modifying MCP functionality, changes typically need to be applied across al
**Read `CONTRIBUTING.md` before opening issues or PRs.** It describes when PRs are appropriate, what we expect from enhancement proposals, and what we'll close without review.
+**Review closed contributor PRs.** When reviewing an issue, inspect every associated non-maintainer PR, including closed PRs. External PRs may be closed as part of the issue-link and assignment workflow, so closure alone is not a negative signal. Read `CONTRIBUTING.md` and the PR timeline and comments to understand its status before evaluating it.
+
### Git & CI
- Prek hooks are required (run automatically on commits)
From c8b88b3a3763c3ce76868b1e92fa80f417084f46 Mon Sep 17 00:00:00 2001
From: Yonatan
Date: Thu, 6 Aug 2026 16:35:49 +0300
Subject: [PATCH 30/32] fix(context): move elicit overload docs inside the
stubs so mypy sees the chain (#4774)
---
fastmcp_slim/fastmcp/server/context.py | 39 ++++++++++----------------
1 file changed, 15 insertions(+), 24 deletions(-)
diff --git a/fastmcp_slim/fastmcp/server/context.py b/fastmcp_slim/fastmcp/server/context.py
index 3373e09da..0e3ccf9d5 100644
--- a/fastmcp_slim/fastmcp/server/context.py
+++ b/fastmcp_slim/fastmcp/server/context.py
@@ -962,9 +962,8 @@ class Context:
*,
response_title: str | None = None,
response_description: str | None = None,
- ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ...
-
- """The accepted elicitation will contain the response data"""
+ ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation:
+ """The accepted elicitation will contain the response data"""
@overload
async def elicit(
@@ -974,10 +973,9 @@ class Context:
*,
response_title: str | None = None,
response_description: str | None = None,
- ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
-
- """When response_type is a list of strings, the accepted elicitation will
- contain the selected string response"""
+ ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation:
+ """When response_type is a list of strings, the accepted elicitation will
+ contain the selected string response"""
@overload
async def elicit(
@@ -987,10 +985,9 @@ class Context:
*,
response_title: str | None = None,
response_description: str | None = None,
- ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
-
- """When response_type is a dict mapping keys to title dicts, the accepted
- elicitation will contain the selected key"""
+ ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation:
+ """When response_type is a dict mapping keys to title dicts, the accepted
+ elicitation will contain the selected key"""
@overload
async def elicit(
@@ -1000,12 +997,9 @@ class Context:
*,
response_title: str | None = None,
response_description: str | None = None,
- ) -> (
- AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
- ): ...
-
- """When response_type is a list containing a list of strings (multi-select),
- the accepted elicitation will contain a list of selected strings"""
+ ) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation:
+ """When response_type is a list containing a list of strings (multi-select),
+ the accepted elicitation will contain a list of selected strings"""
@overload
async def elicit(
@@ -1015,13 +1009,10 @@ class Context:
*,
response_title: str | None = None,
response_description: str | None = None,
- ) -> (
- AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
- ): ...
-
- """When response_type is a list containing a dict mapping keys to title dicts
- (multi-select with titles), the accepted elicitation will contain a list of
- selected keys"""
+ ) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation:
+ """When response_type is a list containing a dict mapping keys to title dicts
+ (multi-select with titles), the accepted elicitation will contain a list of
+ selected keys"""
async def elicit(
self,
From 6fb34e9383cd6b44bef606c06b493503c8354b61 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Thu, 6 Aug 2026 11:15:38 -0400
Subject: [PATCH 31/32] Document MCP protocol support and conformance (#4781)
---
docs/more/faq.mdx | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/docs/more/faq.mdx b/docs/more/faq.mdx
index 1ac3c2c7d..5566e908f 100644
--- a/docs/more/faq.mdx
+++ b/docs/more/faq.mdx
@@ -22,16 +22,24 @@ The client probes `server/discover` and adopts the modern protocol when the serv
## What are the two protocol eras, and which one does my server speak?
-Both. A FastMCP server serves every era from one deployment and one URL, and the SDK negotiates per connection โ the client picks, not the server.
+Both. A FastMCP 4 server supports the handshake revisions `2024-11-05`, `2025-03-26`, `2025-06-18`, and `2025-11-25`, plus the modern `2026-07-28` protocol. It serves all of them from one deployment and one URL, and the SDK negotiates per connection โ the client picks, not the server.
The *handshake* era (`2025-11-25` and earlier) opens each connection with `initialize` and holds a session, which gives the server a back-channel it can push requests down. The *modern* era (`2026-07-28`) is sessionless: the client learns what the server offers through `server/discover`, every request stands alone, and there is no back-channel. Inside a tool, `ctx.request_context.protocol_version` tells you which era the current call arrived on; on the client, `client.protocol_version` reports it after connecting.
+A protocol version establishes the wire format, while capabilities describe which optional operations a particular server provides. The capabilities returned by `server/discover` or `initialize` are therefore the authoritative way for a client to determine what is available.
+
## Can FastMCP 4 talk to older clients and servers?
Yes, in both directions, with no configuration. A FastMCP 4 server answers a handshake-era client and a modern one from the same process: the old client sends `initialize` and gets a session id, the modern client discovers and stays stateless.
A FastMCP 4 client is equally happy against an old server, because `mode="auto"` falls back to the handshake when discovery finds no modern peer. The client-side handlers for server-initiated capabilities are all still there too โ passing `sampling_handler=` or `roots=` answers a legacy server's requests exactly as before, which is what a modern client needs in order to interoperate. See [client sampling](/clients/sampling) and [client roots](/clients/roots).
+## How does FastMCP verify protocol conformance?
+
+FastMCP runs the [official MCP conformance suite](https://github.com/modelcontextprotocol/conformance) in CI against a pinned suite release. A failing scenario for a released capability that FastMCP advertises as supported is treated as a regression.
+
+The suite's `all` mode also exercises draft, pending, retired, and deliberately unsupported capabilities, so its raw pass count is broader than FastMCP's support contract. Known exceptions are recorded in [`expected-failures.yml`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/conformance/expected-failures.yml) with their rationale, and new upstream scenarios arrive through deliberate suite-version updates rather than silently changing CI.
+
## When should I pin `mode="legacy"`?
Pin it when your code depends on the session the handshake creates: `client.ping()` and `transport.get_session_id()` have no modern equivalent, since a sessionless connection has neither a live back-channel to ping nor an id to hold. It is also the escape hatch when a server misbehaves under discovery or you need the classic `initialize` result object.
From 75fb116e36807aa2edfa191974c8dcbca24927c0 Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Thu, 6 Aug 2026 13:09:37 -0500
Subject: [PATCH 32/32] Support EdDSA verification in JWTVerifier (#4752)
---
docs/servers/auth/token-verification.mdx | 18 +-
.../fastmcp/server/auth/providers/jwt.py | 54 ++++--
fastmcp_slim/pyproject.toml | 2 +-
tests/server/auth/test_jwt_provider.py | 164 +++++++++++++++---
uv.lock | 2 +-
5 files changed, 195 insertions(+), 45 deletions(-)
diff --git a/docs/servers/auth/token-verification.mdx b/docs/servers/auth/token-verification.mdx
index b21e8b54d..9e55640ba 100644
--- a/docs/servers/auth/token-verification.mdx
+++ b/docs/servers/auth/token-verification.mdx
@@ -80,6 +80,19 @@ This configuration creates a server that validates JWTs issued by `auth.yourcomp
The `issuer` parameter ensures tokens come from your trusted authentication system, while `audience` validation prevents tokens intended for other services from being accepted by your MCP server.
+`JWTVerifier` accepts RSA (`RS*` and `PS*`), ECDSA (`ES*`), and Edwards-curve (`Ed25519` and `Ed448`) signatures from JWKS endpoints. Set `algorithm` when your issuer does not use the default `RS256`:
+
+```python
+verifier = JWTVerifier(
+ jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json",
+ issuer="https://auth.yourcompany.com",
+ audience="mcp-production-api",
+ algorithm="Ed25519",
+)
+```
+
+The legacy `EdDSA` identifier is also accepted for compatibility with identity providers that have not yet adopted the fully specified identifiers from RFC 9864.
+
### Symmetric Key Verification (HMAC)
Symmetric key verification uses a shared secret for both signing and validation, making it ideal for internal microservices and trusted environments where the same secret can be securely distributed to both token issuers and validators.
@@ -121,7 +134,7 @@ The parameter is named `public_key` for backwards compatibility, but when using
### Static Public Key Verification
-Static public key verification works when you have a fixed RSA or ECDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available.
+Static public key verification works when you have a fixed RSA, ECDSA, or EdDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available.
```python
from fastmcp import FastMCP
@@ -141,7 +154,7 @@ verifier = JWTVerifier(
mcp = FastMCP(name="Protected API", auth=verifier)
```
-This configuration validates tokens using a specific RSA or ECDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys.
+This configuration validates tokens using a specific RSA, ECDSA, or EdDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys.
## Opaque Token Verification
Many authorization servers issue opaque tokens rather than self-contained JWTs. Opaque tokens are random strings that carry no information themselves - the authorization server maintains their state and validation requires querying the server. FastMCP supports opaque token validation through OAuth 2.0 Token Introspection (RFC 7662).
@@ -425,4 +438,3 @@ mcp = FastMCP(name="Production API", auth=verifier)
This keeps configuration out of your codebase while maintaining explicit setup.
This approach enables the same codebase to run across development, staging, and production environments with different authentication requirements. Development might use static tokens while production uses JWT verification, all controlled through environment configuration.
-
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/jwt.py b/fastmcp_slim/fastmcp/server/auth/providers/jwt.py
index 64c8549cf..d991b2ab6 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/jwt.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/jwt.py
@@ -6,7 +6,7 @@ import contextlib
import json
import time
from dataclasses import dataclass
-from typing import Any, TypeAlias, cast
+from typing import Any, Literal, TypeAlias, cast
import httpx2
from cryptography.hazmat.primitives import serialization
@@ -29,22 +29,30 @@ JWKKeyData: TypeAlias = dict[str, str | list[str]]
SUPPORTED_JWS_HEADER_FIELDS = frozenset(JWS_HEADER_REGISTRY)
-def _import_key_for_algorithm(key: str | bytes | JWKKeyData, algorithm: str):
+def _key_type_for_algorithm(algorithm: str) -> Literal["oct", "RSA", "EC", "OKP"]:
if algorithm.startswith("HS"):
- return jwk.import_key(key, "oct")
+ return "oct"
if algorithm.startswith(("RS", "PS")):
- return jwk.import_key(key, "RSA")
+ return "RSA"
if algorithm.startswith("ES"):
- return jwk.import_key(key, "EC")
+ return "EC"
+ if algorithm in {"EdDSA", "Ed25519", "Ed448"}:
+ return "OKP"
raise ValueError(f"Unsupported algorithm: {algorithm}.")
+def _import_key_for_algorithm(key: str | bytes | JWKKeyData, algorithm: str):
+ return jwk.import_key(key, _key_type_for_algorithm(algorithm))
+
+
def _jwk_to_pem(key_data: JWKKeyData) -> str:
key_type = key_data.get("kty")
if key_type == "RSA":
return jwk.import_key(key_data, "RSA").as_pem().decode("utf-8")
if key_type == "EC":
return jwk.import_key(key_data, "EC").as_pem().decode("utf-8")
+ if key_type == "OKP":
+ return jwk.import_key(key_data, "OKP").as_pem().decode("utf-8")
raise ValueError(f"Unsupported JWK key type: {key_type!r}")
@@ -72,6 +80,8 @@ class JWKData(TypedDict, total=False):
alg: str # Algorithm (e.g., "RS256")
n: str # Modulus (for RSA keys)
e: str # Exponent (for RSA keys)
+ crv: str # Curve name (for EC and OKP keys)
+ x: str # Public key coordinate (for EC and OKP keys)
x5c: list[str] # X.509 certificate chain (for JWKs)
x5t: str # X.509 certificate thumbprint (for JWKs)
@@ -194,10 +204,11 @@ def _looks_like_pem_public_key(key: str | bytes) -> bool:
class JWTVerifier(TokenVerifier):
"""
- JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms.
+ JWT token verifier supporting asymmetric (RSA/ECDSA/EdDSA) and symmetric (HMAC) algorithms.
This verifier validates JWT tokens using various signing algorithms:
- - **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512):
+ - **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512,
+ Ed25519, Ed448, and legacy EdDSA):
Uses public/private key pairs. Ideal for external clients and services where
only the authorization server has the private key.
- **Symmetric algorithms** (HS256/384/512): Uses a shared secret for both
@@ -232,7 +243,7 @@ class JWTVerifier(TokenVerifier):
jwks_uri: URI to fetch a JSON Web Key Set; used when verifying tokens with remote JWKS.
issuer: Expected issuer claim value or list of allowed issuer values.
audience: Expected audience claim value or list of allowed audience values.
- algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512.
+ algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512, Ed25519, Ed448, and legacy EdDSA.
required_scopes: Scopes that must be present in validated tokens.
base_url: Base URL passed to the parent TokenVerifier.
ssrf_safe: If True, JWKS fetches use SSRF protection (HTTPS-only,
@@ -275,6 +286,9 @@ class JWTVerifier(TokenVerifier):
"PS256",
"PS384",
"PS512",
+ "EdDSA",
+ "Ed25519",
+ "Ed448",
}:
raise ValueError(f"Unsupported algorithm: {algorithm}.")
@@ -347,19 +361,31 @@ class JWTVerifier(TokenVerifier):
try:
jwks_data = await self._fetch_jwks()
- # Cache all usable keys. A key that cannot be converted (e.g. an
- # unsupported kty like OKP/Ed25519) is skipped rather than failing
- # the whole set โ per RFC 7517 ยง5, clients should ignore JWKs they
- # don't understand. Otherwise one exotic key published by the
- # authorization server would reject every token, including ones
- # signed by supported keys in the same set (#4515).
+ # Cache all usable keys. A key that cannot be converted is skipped
+ # rather than failing the whole set โ per RFC 7517 ยง5, clients
+ # should ignore JWKs they don't understand. Otherwise one exotic
+ # key published by the authorization server would reject every
+ # token, including ones signed by supported keys in the same set
+ # (#4515).
self._jwks_cache = {}
skipped_kids: set[str] = set()
+ expected_key_type = _key_type_for_algorithm(self.algorithm)
for key_data in jwks_data.get("keys", []):
if not isinstance(key_data, dict):
self.logger.debug("Skipping non-object JWKS entry: %r", key_data)
continue
key_kid = key_data.get("kid")
+ if key_data.get("kty") != expected_key_type:
+ self.logger.debug(
+ "Skipping JWKS key %r: key type %r is incompatible "
+ "with algorithm %s",
+ key_kid,
+ key_data.get("kty"),
+ self.algorithm,
+ )
+ if key_kid:
+ skipped_kids.add(key_kid)
+ continue
try:
public_key = _jwk_to_pem(key_data)
except (JoseError, TypeError, KeyError, ValueError) as e:
diff --git a/fastmcp_slim/pyproject.toml b/fastmcp_slim/pyproject.toml
index b009efd42..6d56f3e93 100644
--- a/fastmcp_slim/pyproject.toml
+++ b/fastmcp_slim/pyproject.toml
@@ -96,7 +96,7 @@ server = [
"griffelib>=2.0.0",
"jsonref>=1.1.0",
"jsonschema-path>=0.3.4",
- "joserfc>=1.1.0",
+ "joserfc>=1.5.0",
"openapi-pydantic>=0.5.1",
"packaging>=24.0",
"py-key-value-aio[filetree,keyring,memory]>=0.4.4,<0.5.0",
diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py
index 7a39eb9df..20007f3e5 100644
--- a/tests/server/auth/test_jwt_provider.py
+++ b/tests/server/auth/test_jwt_provider.py
@@ -4,6 +4,9 @@ from typing import Any, cast
from unittest.mock import MagicMock, patch
import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from joserfc import jwk as jose_jwk
from joserfc import jwt
from joserfc.jws import JWSRegistry
@@ -81,6 +84,49 @@ class SymmetricKeyHelper:
return token
+def create_okp_key_pair(
+ private_key: Ed25519PrivateKey | Ed448PrivateKey,
+) -> tuple[str, str]:
+ """Serialize an EdDSA key pair as PEM strings."""
+ private_pem = private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption(),
+ ).decode()
+ public_pem = (
+ private_key.public_key()
+ .public_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
+ )
+ .decode()
+ )
+ return private_pem, public_pem
+
+
+def create_okp_token(
+ private_key: str,
+ algorithm: str,
+ *,
+ kid: str | None = None,
+) -> str:
+ """Create a JWT signed by an OKP key."""
+ header = {"alg": algorithm}
+ if kid is not None:
+ header["kid"] = kid
+ return jwt.encode(
+ header,
+ {
+ "sub": "test-user",
+ "iss": "https://test.example.com",
+ "aud": "https://api.example.com",
+ "exp": int(time.time()) + 3600,
+ },
+ jose_jwk.import_key(private_key, "OKP"),
+ algorithms=[algorithm],
+ )
+
+
@pytest.fixture(scope="module")
def symmetric_key_helper() -> SymmetricKeyHelper:
"""Generate a symmetric key helper for testing."""
@@ -485,6 +531,58 @@ class TestSymmetricKeyJWT:
assert access_token is None
+class TestEdDSAJWT:
+ """Tests for JWT verification using Edwards-curve keys."""
+
+ @pytest.mark.parametrize("algorithm", ["Ed25519", "Ed448"])
+ async def test_static_public_key(self, algorithm: str):
+ """Fully specified EdDSA algorithms verify with a static public key."""
+ if algorithm == "Ed25519":
+ private_key = Ed25519PrivateKey.generate()
+ else:
+ private_key = Ed448PrivateKey.generate()
+ private_pem, public_pem = create_okp_key_pair(private_key)
+ verifier = JWTVerifier(
+ public_key=public_pem,
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ algorithm=algorithm,
+ )
+
+ access_token = await verifier.load_access_token(
+ create_okp_token(private_pem, algorithm)
+ )
+
+ assert access_token is not None
+ assert access_token.client_id == "test-user"
+
+ @pytest.mark.filterwarnings(
+ "ignore:EdDSA is deprecated via RFC 9864:joserfc.errors.SecurityWarning"
+ )
+ async def test_legacy_eddsa_jwks(
+ self,
+ httpx_mock: HTTPXMock,
+ ):
+ """Legacy EdDSA tokens verify against an Ed25519 JWKS entry."""
+ private_pem, public_pem = create_okp_key_pair(Ed25519PrivateKey.generate())
+ public_jwk = jose_jwk.import_key(public_pem, "OKP").as_dict()
+ public_jwk.update(kid="ed25519-key", alg="EdDSA", use="sig")
+ httpx_mock.add_response(json={"keys": [public_jwk]})
+ verifier = JWTVerifier(
+ jwks_uri="https://test.example.com/.well-known/jwks.json",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ algorithm="EdDSA",
+ )
+
+ access_token = await verifier.load_access_token(
+ create_okp_token(private_pem, "EdDSA", kid="ed25519-key")
+ )
+
+ assert access_token is not None
+ assert access_token.client_id == "test-user"
+
+
def _create_token_without_sub(
rsa_key_pair: RSAKeyPair,
*,
@@ -662,7 +760,7 @@ class TestBearerTokenJWKS:
assert access_token.claims.get("iss") == issuer
assert access_token.claims.get("aud") == audience
- async def test_jwks_skips_unsupported_key_types(
+ async def test_jwks_skips_unusable_keys(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: JWTVerifier,
@@ -670,28 +768,19 @@ class TestBearerTokenJWKS:
httpx_mock: HTTPXMock,
mock_dns,
):
- """An unsupported key type in the JWKS (e.g. OKP/Ed25519) must be
- skipped, not poison the whole key set - #4515.
-
- Some authorization servers (e.g. Rauthy, Ory Hydra) publish an
- Ed25519 key alongside RSA keys; tokens signed by the RSA keys must
- still verify.
- """
- okp_key = cast(
+ """An unusable key must not poison the whole key set - #4515."""
+ malformed_key = cast(
"JWKData",
{
- "kty": "OKP",
- "crv": "Ed25519",
- "kid": "ed25519-key",
- "alg": "EdDSA",
+ "kty": "RSA",
+ "kid": "malformed-key",
"use": "sig",
- "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
},
)
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
- # Unsupported key FIRST, so an unguarded conversion loop would
+ # Malformed key FIRST, so an unguarded conversion loop would
# abort before reaching the RSA key the token needs
- mock_jwks_data["keys"].insert(0, okp_key)
+ mock_jwks_data["keys"].insert(0, malformed_key)
httpx_mock.add_response(json=mock_jwks_data)
token = rsa_key_pair.create_token(
@@ -705,37 +794,60 @@ class TestBearerTokenJWKS:
assert access_token is not None
assert access_token.client_id == "test-user"
- async def test_jwks_with_only_unsupported_keys_rejects_cleanly(
+ async def test_jwks_ignores_other_algorithm_key_types_without_kid(
+ self,
+ rsa_key_pair: RSAKeyPair,
+ jwks_provider: JWTVerifier,
+ mock_jwks_data: JWKSData,
+ httpx_mock: HTTPXMock,
+ mock_dns,
+ ):
+ """Unrelated key types do not make a no-kid lookup ambiguous."""
+ _, public_pem = create_okp_key_pair(Ed25519PrivateKey.generate())
+ okp_key = jose_jwk.import_key(public_pem, "OKP").as_dict()
+ okp_key.update(kid="ed25519-key", alg="Ed25519", use="sig")
+ mock_jwks_data["keys"].append(cast("JWKData", okp_key))
+ httpx_mock.add_response(json=mock_jwks_data)
+
+ token = rsa_key_pair.create_token(
+ subject="test-user",
+ issuer="https://test.example.com",
+ audience="https://api.example.com",
+ )
+
+ access_token = await jwks_provider.load_access_token(token)
+
+ assert access_token is not None
+ assert access_token.client_id == "test-user"
+
+ async def test_jwks_with_only_unusable_keys_rejects_cleanly(
self,
rsa_key_pair: RSAKeyPair,
jwks_provider: JWTVerifier,
httpx_mock: HTTPXMock,
mock_dns,
):
- """If every key in the JWKS is unsupported, verification fails
+ """If every key in the JWKS is unusable, verification fails
cleanly (returns None) rather than crashing - #4515."""
- okp_only = {
+ unusable_only = {
"keys": [
cast(
"JWKData",
{
- "kty": "OKP",
- "crv": "Ed25519",
- "kid": "ed25519-key",
- "alg": "EdDSA",
+ "kty": "RSA",
+ "kid": "malformed-key",
"use": "sig",
- "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
},
)
]
}
- httpx_mock.add_response(json=okp_only)
+ httpx_mock.add_response(json=unusable_only)
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
audience="https://api.example.com",
- kid="ed25519-key",
+ kid="malformed-key",
)
access_token = await jwks_provider.load_access_token(token)
diff --git a/uv.lock b/uv.lock
index 727bf1183..58fe0e134 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1046,7 +1046,7 @@ requires-dist = [
{ name = "httpx2", marker = "extra == 'client'", specifier = ">=2.5.0" },
{ name = "httpx2", marker = "extra == 'mcp'", specifier = ">=2.5.0" },
{ name = "httpx2", marker = "extra == 'server'", specifier = ">=2.5.0" },
- { name = "joserfc", marker = "extra == 'server'", specifier = ">=1.1.0" },
+ { name = "joserfc", marker = "extra == 'server'", specifier = ">=1.5.0" },
{ name = "jsonref", marker = "extra == 'gemini'", specifier = ">=1.1.0" },
{ name = "jsonref", marker = "extra == 'server'", specifier = ">=1.1.0" },
{ name = "jsonschema-path", marker = "extra == 'server'", specifier = ">=0.3.4" },