mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Publish FastMCP 4.0.0b1 docs to gofastmcp.com (#4695)
This commit is contained in:
parent
8cf4506aa9
commit
0747ce0bc1
220 changed files with 12093 additions and 8314 deletions
|
|
@ -61,17 +61,43 @@ The final tool result has two parts: `content` (a list of `TextContent` blocks f
|
|||
|
||||
## Tool call routing
|
||||
|
||||
Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters) before resolving by name. App UI calls need a different path.
|
||||
A tool has two things that behave very differently. Its **name** is unstable by design — namespace transforms rename it, so `save_contact` becomes `contacts_save_contact` in one composition and something else in another. Its **identity** is a hash of the app name and the registered tool name, written once at registration and never changed.
|
||||
|
||||
### The hashed lookup bypass
|
||||
A UI is serialized during the entry tool's call, deep inside whatever composition the server happens to have, so it cannot know what its backend tools will be called by the time the payload reaches a host.
|
||||
|
||||
Backend tools are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — while the renderer needs a stable way to call the original backend.
|
||||
### Late-bound tool names
|
||||
|
||||
Hashed lookup solves both problems. FastMCP first tries normal tool resolution. If no visible tool matches and the requested name looks like `<hash>_<local_name>`, FastMCP calls `get_tool_by_hash(hash, local_name)`. That lookup walks the provider tree directly, skipping transforms. It finds an app-visible tool by its original registered name and verifies that its stored `meta["fastmcp"]["_tool_hash"]` matches the requested hash.
|
||||
The payload leaves the app addressed by identity, and every FastMCP server rewrites those references on the way out to whatever it lists that tool as. Servers unwind innermost-first, so the outermost server rewrites last — and its names are the only ones a client can actually invoke.
|
||||
|
||||
That's why `CallTool(save_contact)` keeps working when the server is mounted under a namespace. The renderer sends a deterministic hashed backend name; the server uses `get_tool_by_hash` to find the original tool without transforms in the way.
|
||||
Rewriting a name in place would destroy the identity for the next layer up, so the payload carries a name-to-identity map under `_meta.fastmcp.toolNames`. Each layer resolves through the map and updates it. The action objects keep the exact shape `prefab_ui` defines: only the value of `tool` changes, and only ever to another valid tool name.
|
||||
|
||||
Authorization still applies. The hashed bypass skips name and visibility transforms, but auth checks still run against the tool's `auth` config before execution.
|
||||
The result is that a renderer receives names that exist in the listing the host is looking at. Under three layers of namespacing the button calls `c_b_a_save`; behind a gateway it calls whatever the gateway lists. No intermediary has to understand a FastMCP-specific convention.
|
||||
|
||||
A reference this server cannot resolve is left alone rather than corrupted. This is what keeps apps working behind [tool search](/servers/transforms/tool-search) and code mode, which replace `tools/list` with a handful of synthetic tools: there is no better name to bind to, so the reference stays identity-addressed and the fallback below carries it.
|
||||
|
||||
### One copy of an app per server
|
||||
|
||||
**An app name must be unique within a server.** Composing the same app twice breaks its UI, and no namespace or mount arrangement makes it work.
|
||||
|
||||
The reason is structural. Identity is derived from the app name and the tool's registered name, and deliberately nothing else — that is what makes it survive renaming. Two copies of one app therefore produce two tools claiming a single identity, and no fact anywhere in the listing says which copy a given button belongs to. The information needed to choose was never recorded.
|
||||
|
||||
FastMCP declines to bind rather than picking a copy, so buttons stop working instead of quietly invoking the wrong tenant's tool. Expect a message naming the cause:
|
||||
|
||||
```
|
||||
Ambiguous app tool 'save': 2 components share the identity '10c0803009ff'.
|
||||
The same app is composed more than once, so this call cannot be routed to a
|
||||
single tool.
|
||||
```
|
||||
|
||||
Give each copy its own app name. Two tenants running the same product want `FastMCPApp("contacts-acme")` and `FastMCPApp("contacts-globex")` — not two instances of `FastMCPApp("contacts")` under different namespaces, since namespaces rename tools and identity is immune to renaming by design.
|
||||
|
||||
### The hashed lookup fallback
|
||||
|
||||
The identity-addressed form `<hash>_<local_name>` remains callable. FastMCP first tries normal tool resolution; if no tool matches and the name has that shape, it calls `get_tool_by_hash(hash, local_name)`, which walks the provider tree directly, skipping transforms.
|
||||
|
||||
When one identity is claimed by more than one tool — which happens when the same app is composed into two branches — the call is refused rather than resolved, since picking either one would silently route into the wrong branch.
|
||||
|
||||
Authorization still applies. The hashed path skips name and visibility transforms, but auth checks still run against the tool's `auth` config before execution.
|
||||
|
||||
### Provider delegation
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,11 @@ A fair question. Any [Interactive Tool](/apps/prefab) can call a server tool —
|
|||
- What happens to `CallTool("add_note")` when you mount this server under a namespace and the tool becomes `notes_add_note`?
|
||||
- How do you keep it all wired correctly as you compose servers?
|
||||
|
||||
`FastMCPApp` owns these concerns. Entry points register as model-visible. Backend tools register as UI-only by default. Backend tools get globally stable identifiers that survive namespacing, and `CallTool` accepts function references, so references stay valid when you compose servers.
|
||||
`FastMCPApp` owns these concerns. Entry points register as model-visible, backend tools register as UI-only, and hosts act on those declarations to decide what the model sees.
|
||||
|
||||
Composition is handled by never writing the name down. `CallTool` takes a function reference, and FastMCP resolves it when the UI is serialized — to whatever that tool is actually called by then. Mount the server under a namespace and the button calls `notes_add_note`; put a gateway in front and it calls whatever the gateway lists. Since you never wrote a name, renaming cannot break it. [The architecture page](/apps/architecture) covers how that resolution works.
|
||||
|
||||
The one rule that comes with this: **an app name must be unique within a server.** Composing the same app twice breaks its UI — two copies of `FastMCPApp("notes")` are indistinguishable no matter what namespaces you mount them under, so FastMCP declines to bind rather than picking one. Name apps for what they serve: `FastMCPApp("notes-acme")` and `FastMCPApp("notes-globex")`. [The architecture page](/apps/architecture) explains why identity works this way.
|
||||
|
||||
The rest of this page covers each piece in turn.
|
||||
|
||||
|
|
|
|||
|
|
@ -70,11 +70,15 @@ def my_tool() -> str:
|
|||
The `visibility` field controls where a tool appears:
|
||||
|
||||
- `["model"]` — visible to the LLM (the default behavior)
|
||||
- `["app"]` — only callable from within the app UI, hidden from the LLM
|
||||
- `["app"]` — callable from within the app UI, kept out of the LLM's tool list
|
||||
- `["model", "app"]` — both
|
||||
|
||||
This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions.
|
||||
|
||||
Visibility is a declaration, and on `tools/list` the host does the filtering — the division the MCP Apps specification defines. Every tool is advertised carrying its `visibility` metadata, which is also what lets a proxy or gateway forward it: an intermediary can only route to a tool it can see.
|
||||
|
||||
That division assumes a host stands between the server and the model. Where one doesn't, FastMCP applies the declaration itself. [Tool search](/servers/transforms/tool-search) and code mode reach the model as ordinary tool output rather than as an advertised listing, and their call-tool proxies execute a name the model supplies — nothing downstream can filter either, so app-only tools are excluded from both. The app's own UI still reaches its backends, because a UI calling by identity is not the model.
|
||||
|
||||
```python
|
||||
@mcp.tool(
|
||||
app=AppConfig(
|
||||
|
|
@ -216,7 +220,7 @@ import qrcode
|
|||
from fastmcp import FastMCP
|
||||
from fastmcp.apps import AppConfig, ResourceCSP
|
||||
from fastmcp.tools import ToolResult
|
||||
from mcp_types import ImageContent
|
||||
from mcp.types import ImageContent
|
||||
|
||||
mcp = FastMCP("QR Code Server")
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,202 @@ rss: true
|
|||
tag: NEW
|
||||
---
|
||||
|
||||
<Update label="v4.0.0b1" description="2026-07-28">
|
||||
|
||||
**[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)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.5" description="2026-07-27">
|
||||
|
||||
**[v3.4.5: Key Change](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.5)**
|
||||
|
||||
FastMCP 3.4.5 collects five fixes for the 3.x line, led by `JWTVerifier` no longer rejecting every token when an authorization server publishes an unrecognized key type such as Ed25519.
|
||||
|
||||
### Fixes 🐞
|
||||
* Backport #4517 to release/3.x: skip unsupported JWKS keys (#4515) by [@kakiii](https://github.com/kakiii) in [#4631](https://github.com/PrefectHQ/fastmcp/pull/4631)
|
||||
* Backport #4469 to release/3.x: fix Azure scope fallback by [@jlowin](https://github.com/jlowin) in [#4662](https://github.com/PrefectHQ/fastmcp/pull/4662)
|
||||
* Backport #4523 to release/3.x: serialize deep object query parameters by [@jlowin](https://github.com/jlowin) in [#4664](https://github.com/PrefectHQ/fastmcp/pull/4664)
|
||||
* Backport #4564 to release/3.x: make transformed tool required order deterministic by [@jlowin](https://github.com/jlowin) in [#4665](https://github.com/PrefectHQ/fastmcp/pull/4665)
|
||||
* Backport #4492 to release/3.x: don't mutate the caller's schema in compress_schema by [@jlowin](https://github.com/jlowin) in [#4663](https://github.com/PrefectHQ/fastmcp/pull/4663)
|
||||
|
||||
## New Contributors
|
||||
* @kakiii made their first contribution in [#4631](https://github.com/PrefectHQ/fastmcp/pull/4631)
|
||||
|
||||
**Full Changelog**: [v3.4.4...v3.4.5](https://github.com/PrefectHQ/fastmcp/compare/v3.4.4...v3.4.5)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.4" description="2026-07-08">
|
||||
|
||||
**[v3.4.4: Host in Translation](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.4)**
|
||||
|
|
|
|||
|
|
@ -185,16 +185,11 @@ Set `mode="legacy"` to force the initialize handshake. This behaves identically
|
|||
client = Client("https://example.com/mcp", mode="legacy")
|
||||
```
|
||||
|
||||
Legacy mode is also what you need for the capabilities that depend on a live session between client and server. The handshake opens a persistent back-channel the server can push requests down, and the modern era removed it. Pin `mode="legacy"` when your code relies on any of these:
|
||||
|
||||
- **[Sampling](/clients/sampling)** — server-initiated LLM completion requests
|
||||
- **[Roots](/clients/roots)** — server-initiated requests for the client's roots
|
||||
- **[Elicitation](/clients/elicitation)** — server-initiated requests for user input, which modern connections replace with [input-required rounds](/clients/elicitation#input-required-rounds)
|
||||
- `client.ping()` and `transport.get_session_id()`
|
||||
Legacy mode is also what carries the *pushed* form of a server's requests. The handshake opens a persistent back-channel down which a server can send a sampling, roots, or elicitation request mid-call, and the modern era removed it. Your handlers are unaffected by that: a [sampling](/clients/sampling), [roots](/clients/roots), or [elicitation](/clients/elicitation) handler you register answers a modern server's [input-required rounds](/clients/elicitation#input-required-rounds) from the same registration. Pin `mode="legacy"` when you connect to a server that pushes, or when your code calls `client.ping()` or `transport.get_session_id()`, which need the session the modern era does not open.
|
||||
|
||||
Conversely, [background tasks](/clients/tasks) are **modern-only**: the tasks capability is negotiated over `2026-07-28` connections, so `mode="legacy"` never triggers one and a task-enabled tool just runs synchronously.
|
||||
|
||||
A FastMCP server serves both eras, so a default client negotiates the modern one and these raise an era-specific error. Pinning the handshake restores them.
|
||||
A FastMCP server serves both eras, so a default client negotiates the modern one and the session-dependent calls raise an era-specific error there. Pinning the handshake restores them.
|
||||
|
||||
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
|
||||
|
||||
|
|
@ -342,7 +337,7 @@ See [Prompts](/clients/prompts) for detailed documentation including argument se
|
|||
|
||||
The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications.
|
||||
|
||||
Sampling, elicitation, and roots are all server-initiated, so they belong to the handshake era described under [protocol negotiation](#protocol-negotiation). A default client negotiates the newest era both peers share, where the server has no back-channel to push those requests down, so an example that exercises them pins `mode="legacy"`. Logging and progress arrive as notifications on the response stream and work in either era.
|
||||
Sampling, elicitation, and roots are the requests a server makes of the client. A server reaches your handler by whichever route its [era](#protocol-negotiation) allows — pushed down the open session on the handshake, returned as an input-required result on the modern protocol — and both routes dispatch to the same handler, so one registration covers both. Logging and progress arrive as notifications on the response stream and work in either era.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
|
@ -360,7 +355,6 @@ async def sampling_handler(messages, params, context):
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
log_handler=log_handler,
|
||||
progress_handler=progress_handler,
|
||||
sampling_handler=sampling_handler,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,8 @@ Use this when you need to respond to server requests for user input during tool
|
|||
|
||||
Elicitation allows MCP servers to request structured input from users during operations. Instead of requiring all inputs upfront, servers can interactively ask for missing parameters, request clarification, or gather additional context.
|
||||
|
||||
Two routes reach that outcome, and the protocol version the client negotiates decides which one applies. On older versions the server pushes an elicitation request down to the client, over the connection the `initialize` handshake opens; that is the flow the next few sections describe. On `2026-07-28` and later the server instead returns a description of what it needs, and the client answers with a fresh call — see [input-required rounds](#input-required-rounds). You write the same `elicitation_handler` either way — FastMCP routes it to whichever mechanism the connection supports.
|
||||
|
||||
<Note>
|
||||
**This page shows the older protocol's elicitation flow.** On protocol version `2026-07-28` the server instead returns a description of what it needs and the client answers with a new call — see [input-required rounds](#input-required-rounds). The same `elicitation_handler` serves both. Clients default to `mode="auto"`, so the examples below pass `mode="legacy"` to exercise the server-initiated flow. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
**These sections show the server-initiated flow, which the handshake-era protocol uses.** On `2026-07-28` the server asks by returning a request instead — see [input-required rounds](#input-required-rounds). One `elicitation_handler` serves both, so the examples below pin `mode="legacy"` only to exercise the pushed form.
|
||||
</Note>
|
||||
|
||||
## Handler Template
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ For fine-grained targeting, subclass `MessageHandler` to use specific hooks:
|
|||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.messages import MessageHandler
|
||||
import mcp_types
|
||||
import mcp.types as mcp_types
|
||||
|
||||
class MyMessageHandler(MessageHandler):
|
||||
async def on_tool_list_changed(
|
||||
|
|
@ -78,7 +78,7 @@ client = Client(
|
|||
|
||||
```python
|
||||
from fastmcp.client.messages import MessageHandler
|
||||
import mcp_types
|
||||
import mcp.types as mcp_types
|
||||
|
||||
class MyMessageHandler(MessageHandler):
|
||||
async def on_message(self, message) -> None:
|
||||
|
|
@ -141,7 +141,7 @@ 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
|
||||
import mcp.types as mcp_types
|
||||
|
||||
class ToolCacheHandler(MessageHandler):
|
||||
def __init__(self):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Client Roots
|
||||
sidebarTitle: Roots
|
||||
description: Provide local context and resource boundaries to MCP servers.
|
||||
description: Tell servers which local paths your client can reach.
|
||||
icon: folder-tree
|
||||
---
|
||||
|
||||
|
|
@ -11,29 +11,26 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
|
|||
|
||||
Use this when you need to tell servers what local resources the client has access to.
|
||||
|
||||
Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses.
|
||||
A root is a path your client is willing to expose — a project directory, a workspace, a document store. Servers read them to scope their work, so a tool that searches files searches where you pointed it, and a server that gets no roots has to ask the user for paths instead. Roots describe where the client can reach; the server takes them as its working boundary.
|
||||
|
||||
<Note>
|
||||
**Roots require the older MCP protocol.** A server reads roots by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so the examples below pass `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
</Note>
|
||||
Register them once with `roots=`, and the client answers however the server asks. A handshake-era server pushes a `roots/list` request down the open session and reads the reply mid-call; a modern (`2026-07-28`) server has no such channel, so it returns a roots request and `fastmcp.Client` fulfils it from the same registration and re-issues the call with the answer attached. The default `mode="auto"` negotiates whichever era the server speaks, so the examples below work on either — see [protocol negotiation](/clients/client#protocol-negotiation) for how that choice is made, and [the guard pattern](/servers/elicitation#sampling-and-roots) for how a server issues the modern form.
|
||||
|
||||
## Static Roots
|
||||
|
||||
Provide a list of roots when creating the client:
|
||||
When the paths are known up front, pass them as a list. The client holds them for the life of the connection and hands back the same set every time a server asks.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
roots=["/path/to/root1", "/path/to/root2"]
|
||||
roots=["file:///path/to/root1", "file:///path/to/root2"]
|
||||
)
|
||||
```
|
||||
|
||||
## Dynamic Roots
|
||||
|
||||
Use a callback to compute roots dynamically when the server requests them:
|
||||
Pass a callback instead when the roots depend on something the client learns at runtime, such as the workspace the user has open. It runs at the moment a server asks, on either route, and receives the request context so you can see which request it is answering:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
|
@ -41,11 +38,10 @@ from fastmcp.client.roots import RequestContext
|
|||
|
||||
async def roots_callback(context: RequestContext) -> list[str]:
|
||||
print(f"Server requested roots (Request ID: {context.request_id})")
|
||||
return ["/path/to/root1", "/path/to/root2"]
|
||||
return ["file:///path/to/root1", "file:///path/to/root2"]
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
roots=roots_callback
|
||||
)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: LLM Sampling
|
||||
sidebarTitle: Sampling
|
||||
description: Handle server-initiated LLM completion requests.
|
||||
description: Answer a server's request for an LLM completion.
|
||||
icon: robot
|
||||
---
|
||||
|
||||
|
|
@ -9,57 +9,46 @@ import { VersionBadge } from "/snippets/version-badge.mdx";
|
|||
|
||||
<VersionBadge version="2.0.0" />
|
||||
|
||||
Use this when you need to respond to server requests for LLM completions.
|
||||
Use this when a server asks your client to run an LLM completion on its behalf.
|
||||
|
||||
MCP servers can request LLM completions from clients during tool execution. This enables servers to delegate AI reasoning to the client, which controls which LLM is used and how requests are made.
|
||||
Sampling is how a server borrows your model. Rather than hold an API key of its own, the server describes the messages it wants completed and asks you to run them — you pick the model, and you pay for the tokens. Your side of that arrangement is one function, a **sampling handler**, registered when you create the client.
|
||||
|
||||
<Note>
|
||||
**Sampling requires the older MCP protocol.** A server requests sampling by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so every example on this page passes `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
</Note>
|
||||
The handler receives the conversation the server wants completed, the parameters it asked for, and a request context carrying metadata about the call. Return the generated text as a string and FastMCP wraps it in the protocol's result for you; return a `CreateMessageResult` yourself when you want to report the real model name or hand back content that isn't text. If the handler raises, the client sends the error back in place of a completion and the server's tool decides what to do about it.
|
||||
|
||||
## Handler Template
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.sampling import SamplingMessage, SamplingParams, RequestContext
|
||||
from mcp.types import TextContent
|
||||
|
||||
|
||||
async def sampling_handler(
|
||||
messages: list[SamplingMessage],
|
||||
params: SamplingParams,
|
||||
context: RequestContext
|
||||
context: RequestContext,
|
||||
) -> str:
|
||||
"""
|
||||
Handle server requests for LLM completions.
|
||||
|
||||
Args:
|
||||
messages: Conversation messages to send to the LLM
|
||||
params: Sampling parameters (temperature, max_tokens, etc.)
|
||||
context: Request context with metadata
|
||||
|
||||
Returns:
|
||||
Generated text response from your LLM
|
||||
"""
|
||||
# Extract message content
|
||||
conversation = []
|
||||
for message in messages:
|
||||
content = message.content.text if hasattr(message.content, 'text') else str(message.content)
|
||||
conversation.append(f"{message.role}: {content}")
|
||||
|
||||
# Use the system prompt if provided
|
||||
"""Run the server's messages against your LLM and return the completion."""
|
||||
conversation = [
|
||||
f"{message.role}: {message.content.text}"
|
||||
for message in messages
|
||||
if isinstance(message.content, TextContent)
|
||||
]
|
||||
system_prompt = params.system_prompt or "You are a helpful assistant."
|
||||
|
||||
# Integrate with your LLM service here
|
||||
# Call your LLM here with `conversation` and `system_prompt`.
|
||||
return "Generated response based on the messages"
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=sampling_handler,
|
||||
)
|
||||
|
||||
client = Client("my_mcp_server.py", sampling_handler=sampling_handler)
|
||||
```
|
||||
|
||||
The client answers with this handler however the server asks for a completion. The default `mode="auto"` negotiates whichever protocol era the server speaks, and one handler covers both of the routes an era can use — see [Request Routes](#request-routes).
|
||||
|
||||
## Handler Parameters
|
||||
|
||||
Everything the server sends arrives in the first two arguments. The messages are the conversation to complete; the parameters are how the server would like it completed. You decide how much of that to honor, since the client owns the model — a preference your provider cannot express is yours to ignore.
|
||||
|
||||
<Card icon="code" title="SamplingMessage">
|
||||
<ResponseField name="role" type='Literal["user", "assistant"]'>
|
||||
The role of the message
|
||||
|
|
@ -71,11 +60,11 @@ client = Client(
|
|||
</Card>
|
||||
|
||||
<Card icon="code" title="SamplingParams">
|
||||
<ResponseField name="systemPrompt" type="str | None">
|
||||
<ResponseField name="system_prompt" type="str | None">
|
||||
Optional system prompt the server wants to use
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="modelPreferences" type="ModelPreferences | None">
|
||||
<ResponseField name="model_preferences" type="ModelPreferences | None">
|
||||
Server preferences for model selection (hints, cost/speed/intelligence priorities)
|
||||
</ResponseField>
|
||||
|
||||
|
|
@ -83,11 +72,11 @@ client = Client(
|
|||
Sampling temperature
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="maxTokens" type="int">
|
||||
<ResponseField name="max_tokens" type="int">
|
||||
Maximum tokens to generate
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="stopSequences" type="list[str] | None">
|
||||
<ResponseField name="stop_sequences" type="list[str] | None">
|
||||
Stop sequences for sampling
|
||||
</ResponseField>
|
||||
|
||||
|
|
@ -95,14 +84,14 @@ client = Client(
|
|||
Tools the LLM can use during sampling
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="toolChoice" type="ToolChoice | None">
|
||||
<ResponseField name="tool_choice" type="ToolChoice | None">
|
||||
Tool usage behavior (`auto`, `required`, or `none`)
|
||||
</ResponseField>
|
||||
</Card>
|
||||
|
||||
## Built-in Handlers
|
||||
|
||||
FastMCP provides built-in handlers for OpenAI, Anthropic, and Google Gemini APIs that support the full sampling API including tool use.
|
||||
Writing the provider call yourself is rarely worth it. FastMCP ships handlers for OpenAI, Anthropic, and Google Gemini that implement the full sampling API, tool use included, and translate the protocol's parameters into each provider's own. Give one a default model and pass it where your own handler would go. Write a custom handler when you need routing across providers, caching, or a provider FastMCP does not cover.
|
||||
|
||||
### OpenAI Handler
|
||||
|
||||
|
|
@ -114,19 +103,19 @@ from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=OpenAISamplingHandler(default_model="gpt-4o"),
|
||||
)
|
||||
```
|
||||
|
||||
For OpenAI-compatible APIs (like local models):
|
||||
Point the handler at any OpenAI-compatible API, including a local model server, by passing your own provider client:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=OpenAISamplingHandler(
|
||||
default_model="llama-3.1-70b",
|
||||
client=AsyncOpenAI(base_url="http://localhost:8000/v1"),
|
||||
|
|
@ -148,7 +137,6 @@ from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"),
|
||||
)
|
||||
```
|
||||
|
|
@ -167,7 +155,6 @@ from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHan
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=GoogleGenaiSamplingHandler(default_model="gemini-2.0-flash"),
|
||||
)
|
||||
```
|
||||
|
|
@ -176,25 +163,32 @@ client = Client(
|
|||
Install the Google Gemini handler with `pip install 'fastmcp[gemini]'`.
|
||||
</Note>
|
||||
|
||||
## Sampling Capabilities
|
||||
The [source of these handlers](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) is the best reference for writing your own.
|
||||
|
||||
When you provide a `sampling_handler`, FastMCP automatically advertises full sampling capabilities to the server, including tool support. To disable tool support for simpler handlers:
|
||||
## Tool Use
|
||||
|
||||
A sampling request can carry tools. When it does, your handler passes them to the model and returns whatever comes back, tool calls included — the server executes the tools itself and sends a follow-up sampling request with the results if it needs another turn. Your handler never runs a tool.
|
||||
|
||||
Registering any `sampling_handler` advertises full sampling support, tools included. A handler that only generates text should say so, so servers know not to send tools it will drop:
|
||||
|
||||
```python
|
||||
from mcp_types import SamplingCapability
|
||||
from fastmcp import Client
|
||||
from mcp.types import SamplingCapability
|
||||
|
||||
|
||||
async def text_only_handler(messages, params, context) -> str:
|
||||
return "Generated response based on the messages"
|
||||
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=basic_handler,
|
||||
sampling_capabilities=SamplingCapability(), # No tool support
|
||||
sampling_handler=text_only_handler,
|
||||
sampling_capabilities=SamplingCapability(),
|
||||
)
|
||||
```
|
||||
|
||||
## Tool Execution
|
||||
## Request Routes
|
||||
|
||||
Tool execution happens on the server side. The client's role is to pass tools to the LLM and return the LLM's response (which may include tool use requests). The server then executes the tools and may send follow-up sampling requests with tool results.
|
||||
Servers reach your handler by two routes, and which one applies depends on the protocol era the connection negotiated. A handshake-era server pushes a `sampling/createMessage` request down the open session while a tool is running and waits for the reply. A modern (`2026-07-28`) connection has no such channel, so the tool ends its round by returning a request for a completion instead; the client answers from your handler and calls the tool again with the result attached.
|
||||
|
||||
<Tip>
|
||||
To implement a custom sampling handler, see the [handler source code](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) as a reference.
|
||||
</Tip>
|
||||
One registration covers both, so this is rarely something you configure — it matters only when you pin an era, since `mode="legacy"` is the sole route that carries a pushed request. See [protocol negotiation](/clients/client#protocol-negotiation) for how the era is chosen, and [Sampling](/servers/sampling) under Servers for how a server issues these requests.
|
||||
|
|
|
|||
|
|
@ -149,6 +149,48 @@ export FASTMCP_HTTP_ALLOWED_ORIGINS='["https://app.example.com"]'
|
|||
|
||||
Use `host_origin_protection="auto"` to protect localhost-bound direct servers while allowing ASGI, serverless, and reverse-proxy deployments to keep their existing Host handling unless they configure explicit trust rules. Use `host_origin_protection=False` to keep the request guard disabled.
|
||||
|
||||
### Gateway Routing Headers
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
A gateway, load balancer, or reverse proxy in front of your MCP server often needs to route a request before it reads the JSON-RPC body — the body may be an SSE stream, or the gateway may simply want to avoid parsing it. On a connection that negotiates the modern `2026-07-28` protocol, Streamable HTTP clients built on the MCP Python SDK (including FastMCP's own client) attach routing information to each request as HTTP headers so an intermediary can dispatch on headers alone:
|
||||
|
||||
- `Mcp-Method` carries the JSON-RPC method (for example `tools/call`) on every request.
|
||||
- `Mcp-Name` carries the target's name on named operations — the tool name for `tools/call`, the prompt name for `prompts/get`, the resource URI for `resources/read`.
|
||||
- `Mcp-Param-*` carries selected argument values for a `tools/call`, one header per opted-in parameter.
|
||||
|
||||
FastMCP's HTTP transport neither strips nor rewrites these headers, so a gateway sees them exactly as the client sent them. The `Host`/`Origin` request guard inspects only `Host` and `Origin` and leaves the routing headers untouched.
|
||||
|
||||
<Warning>
|
||||
These headers are a feature of the modern `2026-07-28` protocol. A client connected over an earlier protocol revision — including one running in legacy mode or one that has fallen back to a legacy server — sends no routing headers at all. Design gateway routing to require the headers rather than assume their presence: if a request arrives without them, fall back to inspecting the body or route it to a default backend, rather than dropping it.
|
||||
</Warning>
|
||||
|
||||
To expose an argument as an `Mcp-Param-*` header, annotate the parameter with the `x-mcp-header` JSON Schema extension. FastMCP carries the annotation into the tool's advertised input schema, and a conforming client mirrors the argument into a header named `Mcp-Param-<token>`:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("My Server")
|
||||
|
||||
@mcp.tool
|
||||
def query_tenant(
|
||||
tenant: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Tenant"})],
|
||||
sql: str,
|
||||
) -> str:
|
||||
"""A call to this tool sends the tenant value as an `Mcp-Param-Tenant` header."""
|
||||
...
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
<Tip>
|
||||
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.
|
||||
</Tip>
|
||||
|
||||
### Health Checks
|
||||
|
||||
Health check endpoints are essential for monitoring your deployed server and ensuring it's responding correctly. FastMCP allows you to add custom routes alongside your MCP endpoints, making it easy to implement health checks that work with both deployment approaches.
|
||||
|
|
@ -504,7 +546,7 @@ base_url="http://localhost:8000/api" # Includes mount prefix
|
|||
mcp_path="/mcp" # Internal MCP path, NOT the mount prefix
|
||||
```
|
||||
|
||||
**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`.
|
||||
**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`. It sets the `issuer` advertised in the authorization server metadata and the `iss` on issued tokens, while the endpoints in that metadata continue to point at `base_url`.
|
||||
|
||||
```python
|
||||
# Usually not needed - just set base_url and it works
|
||||
|
|
@ -658,7 +700,7 @@ When deploying FastMCP behind a load balancer or running multiple server instanc
|
|||
|
||||
#### Understanding Sessions
|
||||
|
||||
By default, FastMCP's Streamable HTTP transport maintains server-side sessions. Sessions enable stateful MCP features like [elicitation](/servers/elicitation) and [sampling](/servers/sampling), where the server needs to maintain context across multiple requests from the same client.
|
||||
By default, FastMCP's Streamable HTTP transport maintains server-side sessions. A session holds the context a server keeps across multiple requests from the same client, and it carries the handshake-era back-channel that server-initiated requests like [elicitation](/servers/elicitation) push down.
|
||||
|
||||
This works perfectly for single-instance deployments. However, sessions are stored in memory on each server instance, which creates challenges when scaling horizontally.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
---
|
||||
title: Auth Provider Environment Variables
|
||||
---
|
||||
|
||||
## Decision: Remove automatic environment variable loading from auth providers
|
||||
|
||||
You can still use environment variables for configuration - you just read them yourself with `os.environ` instead of relying on FastMCP's automatic loading.
|
||||
|
||||
**Status:** Implemented in v3.0.0
|
||||
|
||||
### Background
|
||||
|
||||
Auth providers in v2.x used `pydantic-settings` to automatically load configuration from environment variables with a `FASTMCP_SERVER_AUTH_<PROVIDER>_` prefix. For example, `GitHubProvider` would read from:
|
||||
|
||||
- `FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID`
|
||||
- `FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET`
|
||||
- `FASTMCP_SERVER_AUTH_GITHUB_BASE_URL`
|
||||
- etc.
|
||||
|
||||
This was implemented via a `*ProviderSettings(BaseSettings)` class in each provider, combined with a `NotSet` sentinel pattern to distinguish between "not provided" and `None`.
|
||||
|
||||
### Why remove it
|
||||
|
||||
1. **Maintenance burden**: Every new provider needed to implement the settings class, validators, and the `NotSet` merging logic. This was ~50-100 lines of boilerplate per provider.
|
||||
|
||||
2. **Documentation complexity**: Each provider needed documentation explaining both the parameter and the corresponding environment variable. This doubled the surface area to document and maintain.
|
||||
|
||||
3. **Contributor friction**: New contributors adding providers had to understand and replicate this pattern, which was a source of inconsistency and bugs.
|
||||
|
||||
4. **Marginal user value**: Python developers are comfortable with `os.environ["VAR"]` or `os.environ.get("VAR", default)`. The automatic loading saved a single line of code per parameter while adding significant complexity.
|
||||
|
||||
5. **Implicit behavior**: Magic environment variable loading makes it harder to understand where values come from. Explicit `os.environ` calls are more traceable.
|
||||
|
||||
### Migration path
|
||||
|
||||
The migration is trivial - users add explicit environment variable reads:
|
||||
|
||||
```python
|
||||
# Before (v2.x)
|
||||
auth = GitHubProvider() # Relied on env vars
|
||||
|
||||
# After (v3.0)
|
||||
import os
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id=os.environ["GITHUB_CLIENT_ID"],
|
||||
client_secret=os.environ["GITHUB_CLIENT_SECRET"],
|
||||
base_url=os.environ["MY_BASE_URL"],
|
||||
)
|
||||
```
|
||||
|
||||
Users can also use `os.environ.get()` with defaults, or any other configuration library they prefer (dotenv, dynaconf, etc.).
|
||||
|
||||
### Backwards compatibility
|
||||
|
||||
We chose not to provide backwards compatibility because:
|
||||
|
||||
1. This is a major version bump (v3.0), which is the appropriate time for breaking changes
|
||||
2. The migration is straightforward (add `os.environ` calls)
|
||||
3. Maintaining compatibility would require keeping all the boilerplate we're trying to remove
|
||||
4. The pattern was likely not heavily used - most production deployments pass secrets explicitly rather than relying on magic prefixes
|
||||
|
||||
### What was removed
|
||||
|
||||
- `*ProviderSettings(BaseSettings)` classes from all auth providers
|
||||
- `NotSet` sentinel usage in provider constructors
|
||||
- `pydantic-settings` dependency for auth providers
|
||||
- Environment variable documentation from provider docs
|
||||
- Related test cases for env var loading
|
||||
|
||||
### Result
|
||||
|
||||
Provider constructors are now simple and explicit. Required parameters are actually required (Python raises `TypeError` if missing), and optional parameters have clear defaults. The code is more readable and easier to maintain.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,153 +0,0 @@
|
|||
---
|
||||
title: Background Tasks (SEP-2663)
|
||||
---
|
||||
|
||||
**Status: Shipped (#4602, #4603).** This page is the approved design for rebuilding FastMCP's background-task support on the `io.modelcontextprotocol/tasks` extension. It supersedes the earlier "delete the task machinery" direction recorded during the SDK v2 migration. The [Feature Program](/development/v4-notes/feature-program#background-tasks-sep-2663) carries the one-line status; user-facing usage is documented at [Background Tasks](/servers/tasks) and [Background Tasks (client)](/clients/tasks).
|
||||
|
||||
## TL;DR
|
||||
|
||||
Background tasks live on. The MCP spec moved them out of core and into a **Final, merged** extension — `io.modelcontextprotocol/tasks` (SEP-2663) — that keeps the polling model FastMCP already implements. **No SDK, in any language, ships a runtime for it yet.** FastMCP owns the only production-shaped execution engine (Docket/Redis) built for a near-identical protocol.
|
||||
|
||||
The plan: **rebuild task support on SEP-2663 as `fastmcp-tasks`, an in-repo optional package**, gated by `task=True` exactly as MCP Apps is gated by `app=True`. Remove the SEP-1686 *wire layer*; keep and re-home the *execution engine*. Along the way, introduce a **FastMCP-native server extension API** so tasks (and later Apps) plug in through one documented mechanism instead of bespoke surgery on core.
|
||||
|
||||
Net effect: a server that already uses `@mcp.tool(task=True)` needs **no code change**, and FastMCP plausibly becomes the first runtime implementation of the tasks extension anywhere.
|
||||
|
||||
## Background: where tasks stand today
|
||||
|
||||
FastMCP 3 shipped background tasks against **SEP-1686**, the task protocol that briefly lived in the core MCP spec. The implementation is ~4,000 lines across server, client, CLI, and an SDK shim, split into two very different halves:
|
||||
|
||||
- **A wire layer** — capability advertisement, the `tasks/get|result|list|cancel` handlers, a `CreateTaskResult` on augmented `tools/call`, and a Redis-backed *push* relay that lets a worker reach a client to deliver notifications and elicitation requests.
|
||||
- **An execution engine** — [Docket](https://github.com/chrisguidry/docket) (queue, worker, result store, TTL, `memory://` or `redis://` backends) plus FastMCP-built durability: auth-scoped compound keys that isolate task access by caller, request-context snapshot/restore across worker processes, argument-coercion parity with the sync path, and the `fastmcp tasks worker` CLI.
|
||||
|
||||
The SDK v2 migration removed SEP-1686 from the core spec. The v4 design notes, until now, recorded the consequence as "delete the task machinery; users who need tasks stay on FastMCP 3." That was the right call **given the information at the time** — the assumption was that the successor protocol either didn't exist or wasn't implementable. Both halves of that assumption turned out to be wrong.
|
||||
|
||||
## What changed upstream: SEP-2663
|
||||
|
||||
Tasks were reworked, not removed. **SEP-2663 ("Tasks Extension") is Final and was merged upstream on 2026-05-15**, superseding SEP-1686. It defines the `io.modelcontextprotocol/tasks` extension, a capability-negotiated feature layered on the SEP-2133 extensions mechanism. It keeps SEP-1686's polling core and tightens it.
|
||||
|
||||
**The wire shape:**
|
||||
|
||||
1. Client advertises the tasks capability (per-request, in `_meta`). This is *consent* — "I can handle a task result" — not a request to run one.
|
||||
2. Client issues a normal `tools/call`. **The server decides** whether to run it as a task.
|
||||
3. If tasked, the server returns a `CreateTaskResult` (a claimed result shape carrying `resultType: "task"`) with a **server-generated** `taskId`.
|
||||
4. Client polls `tasks/get` until the status is terminal; the result is **inlined** into that response.
|
||||
5. In-task input (elicit/sample/roots requested *during* execution) is **poll-based**: status flips to `input_required`, outstanding requests appear in an `inputRequests` map, and the client answers via `tasks/update`.
|
||||
6. `tasks/cancel` is cooperative. Optional push exists (`notifications/tasks` over `subscriptions/listen`) but servers need not send it.
|
||||
|
||||
**Delta from SEP-1686** — and the striking thing is that most of it is *deletion*, because the spec moved toward what FastMCP already built:
|
||||
|
||||
| Dimension | SEP-1686 (old) | SEP-2663 (new) | FastMCP today |
|
||||
| --- | --- | --- | --- |
|
||||
| Task-id generation | Client-generated | **Server**-generated | Already server-generated |
|
||||
| `tasks/list` | Present | **Removed** (enumeration risk) | Already a stub returning `[]` |
|
||||
| Result retrieval | Separate `tasks/result` | **Inlined** into `tasks/get` | Merge two handlers into one |
|
||||
| `tasks/delete` | Present | **Removed** (rely on TTL) | TTL is Docket-native |
|
||||
| Creation race | `notifications/tasks/created` | **Durable-creation MUST** | One read-your-writes check away |
|
||||
| In-task input | Push relay + `_meta` tagging | **Poll**: `input_required` + `tasks/update` | Replaces the hairiest module |
|
||||
| Statuses | 7 (incl. `submitted`, `unknown`) | 5 | Shrinks a mapping table |
|
||||
| Augmentable requests | Any | **`tools/call` only** | Tools-only surface (see scope) |
|
||||
| LB routing | Unspecified | `Mcp-Name: <taskId>` header | Moot with shared Redis |
|
||||
|
||||
**Critically: no runtime exists.** The `ext-tasks` repo is schema + prose only. The TypeScript and Python SDKs carry the wire types and conformance fixtures — no client/server implementation. The field is open.
|
||||
|
||||
## The decision
|
||||
|
||||
**Build it.** Two facts flip the earlier "delete and wait" call:
|
||||
|
||||
1. **The spec is what FastMCP already implements**, minus a push relay it can now shed. The rebuild is dominated by deletion and a thin new wire adapter, not a from-scratch effort.
|
||||
2. **FastMCP is uniquely positioned.** SEP-2663 *assumes* a durable server-side store, server-minted high-entropy ids, eventual-consistency-aware creation, and multi-node routing — precisely what Docket/Redis provides. No other framework has this built.
|
||||
|
||||
Maintaining the SEP-1686 machinery through the migration is dead weight (it's the sole reason for the `_sdk_patches.py` shim, the `TaskNotificationHandler`, and a cluster of protocol-era xfails). Rebuilding on SEP-2663 clears that debt *and* produces a flagship v4 capability with a zero-code-change migration story.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Engine and wire split
|
||||
|
||||
The existing code already separates cleanly along this line; the rebuild makes the boundary a package boundary.
|
||||
|
||||
- **Removed:** the SEP-1686 wire layer — capability advertisement, the four CRUD handlers, and (the big win) the entire Redis push relay (`server/tasks/elicitation.py`, `notifications.py`), which existed only because SEP-1686 had no poll-based in-task input channel. SEP-2663's `input_required`/`tasks/update` replaces it; the request/response store survives, the push envelope does not.
|
||||
- **Kept and re-homed:** the Docket execution engine, the auth-scoped key encoding (this is our *authorization* layer for `tasks/get`/`update`/`cancel` — stronger than the spec's "taskIds may be bearer tokens"), context snapshot/restore, argument coercion, and the worker CLI. All of it is wire-agnostic.
|
||||
- **New:** a thin SEP-2663 wire adapter — capability, the `tasks/get`/`update`/`cancel` methods, and a `tools/call` interceptor that decides-and-tasks.
|
||||
|
||||
### Packaging
|
||||
|
||||
`fastmcp-tasks` becomes an in-repo `uv` workspace member on the `fastmcp_remote` template (own `pyproject.toml`, lockstep-versioned, re-exported through the `fastmcp` metapackage). The DX parallel with MCP Apps is exact:
|
||||
|
||||
| Concern | MCP Apps | Background tasks |
|
||||
| --- | --- | --- |
|
||||
| Authoring flag (core) | `@mcp.tool(app=True)` | `@mcp.tool(task=True)` |
|
||||
| Optional package | `prefab-ui` | `fastmcp-tasks` |
|
||||
| Extra | `fastmcp[apps]` | `fastmcp[tasks]` |
|
||||
| Missing-package behavior | Loud install hint | Loud install hint at server build |
|
||||
|
||||
**Core keeps only the declaration:** `task=True` / `TaskConfig` is metadata on a component, with no engine import. Everything else — engine and wire adapter — lives in the `fastmcp-tasks` package. The existing `[tasks]` extra re-points from the SEP-1686 machinery to `fastmcp-tasks`, so `pip install fastmcp[tasks]` and `task=True` keep working with modern wire underneath.
|
||||
|
||||
Activation stays **implicit-but-loud** (the existing `require_docket()` pattern, not silent degradation): `task=True` anywhere triggers a lazy import of `fastmcp-tasks` at build time; a missing install raises immediately. A tool the author marked as a task silently running inline would be a correctness bug, not a graceful fallback.
|
||||
|
||||
### The extension API
|
||||
|
||||
MCP extensions (SEP-2133) are a **genuinely new abstraction in SDK v2** — they did not exist in v1. So MCP Apps hand-rolling its integration wasn't a wrong choice; it predates the tool. Today FastMCP's **server** bypasses the SDK's `Extension` class entirely (it hand-splices the `ui` capability onto the low-level server and walks tool metadata directly), while the **client** forwards `ClientExtension` natively. Every new protocol extension currently means bespoke core surgery.
|
||||
|
||||
Tasks is the forcing function to fix that. The design adds a single registration point:
|
||||
|
||||
```python test="skip"
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
mcp = FastMCP("Server")
|
||||
mcp.add_extension(TasksExtension(url="redis://...")) # required to enable tasks
|
||||
|
||||
|
||||
@mcp.tool(task=True) # intent: this tool CAN run as a task
|
||||
async def crunch(dataset: str) -> str:
|
||||
...
|
||||
```
|
||||
|
||||
`add_extension` is **required** for `task=True` to work — it is not autodetected from the presence of `task=True` flags. This is deliberate. The extension needs configuration that has to live somewhere (backend URL, worker concurrency, TTL defaults), and `add_extension(TasksExtension(...))` is its natural home; autodetection would only scatter that config into settings/env and hide the moment of enablement. Requiring it also keeps capability advertisement honest — the server advertises the `tasks` capability iff the extension is registered — and removes the worst footgun, a tool silently running on an in-memory backend in production because nobody configured Redis. The two concerns stay cleanly separated: `task=True` is per-component intent ("this tool *can* be a task"); `add_extension` is server-wide enablement and config ("this server *runs* tasks, here's how"). Using `task=True` with no extension registered is a loud build-time error.
|
||||
|
||||
The extension API contributes a negotiated capability, additive request methods, and a `tools/call` interceptor — with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is **designed against tasks** because tasks exercises the full surface (capability + methods + interception + client claims + notifications), where Apps exercises only a subset. Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices and confirming the design generalizes.
|
||||
|
||||
**Extension vs. middleware** — the discriminator, so we do not over-apply this: an extension is a *negotiated contract change the client must understand*; middleware is *unilateral server behavior the client never sees*. PII detection, auth, rate limiting → [middleware](/servers/middleware). Tasks, Apps → extensions. Litmus test: delete the capability advertisement — if nothing about the client's behavior changes, it was middleware.
|
||||
|
||||
### Client experience
|
||||
|
||||
SEP-2663 removed the client-side "make this a task" flag — the server decides. That maps onto FastMCP's existing two-tier client surface, the **friendly** `call_tool` vs the **low-level** `call_tool_mcp`, so there is almost no new API:
|
||||
|
||||
- **`call_tool(name, args)` (friendly)** — advertises the capability and, if the server tasks the call, **transparently drives the poll loop** and returns the finished result. Whether the server tasked it is invisible. The machinery already exists: the migration wired claim-resolution through `call_tool_mcp`'s `allow_claimed` path, so a returned `CreateTaskResult` is finished into an ordinary `CallToolResult`. In-task `input_required` routes through the client's **existing elicitation handler**, answered via `tasks/update` — so background elicitation looks identical to foreground elicitation, with zero new client API.
|
||||
- **`call_tool_mcp(...)` (low-level)** — hands back the raw `CreateTaskResult` claimed shape for callers managing the task themselves.
|
||||
- **A "return quickly" flag on the friendly interface** yields the `Task` handle (`.status()`, `.wait()`, `.cancel()`, awaitable) without blocking — the escape hatch for progress and cancellation.
|
||||
|
||||
Server-side, `TaskConfig` modes translate directly: `required` → always task (`-32003` for non-declaring clients), `optional` → task iff the client declared, `forbidden` → never.
|
||||
|
||||
## Sequencing
|
||||
|
||||
1. **Design + unit-test the extension API** against tasks' full surface (capability, methods, interception, client claims/notifications) — as its own testable layer, proven in isolation with a trivial in-test extension before any tasks logic lands on it.
|
||||
2. **Build `fastmcp-tasks`** — extract the engine from the removed SEP-1686 layer, write the SEP-2663 adapter, port the client half.
|
||||
3. **Migrate MCP Apps onto the extension API** — fast-follow, off the critical path, with Apps' existing green tests as the regression net.
|
||||
|
||||
Tasks leads because only it exercises the full API surface; leading with the Apps subset would design us into a corner. Apps becomes the second consumer that confirms generality.
|
||||
|
||||
## Scope for v1 (non-goals)
|
||||
|
||||
- **Polling only.** The optional `notifications/tasks` push and `subscriptions/listen` integration are deferred to a later `fastmcp-tasks` version. This lets the second Redis notification queue die rather than be ported.
|
||||
- **`tools/call` only — do not lead the spec.** SEP-2663 augments `tools/call` only. FastMCP 3 offered `task=True` on prompts and resources *ahead* of the SDK under SEP-1686, and that was a mistake: it produced wire-inexpressible capability, a permanent xfail cluster, and the sdk-feedback #3 gap. The rebuild does **not** repeat it — `task=` is a tools-only surface, and the generic prompt/resource task spine is dropped rather than carried. If the spec extends augmentation later, the surface grows with it.
|
||||
- **Ship experimental.** The `ext-tasks` schema is labeled experimental with no releases; `fastmcp-tasks` ships labeled experimental initially and revs on its own cadence when the schema moves.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| **Spec churn** (extension is experimental) | Thin wire adapter over a wire-agnostic engine; ship experimental; SEP itself is Final, so the polling model is stable even if field names move. |
|
||||
| **Era gating** — SDK strips `capabilities.extensions` at pre-2026 negotiated versions (sdk-feedback #2) | Advertisement effectively requires the 2026-07-28 era. FastMCP 3 covers legacy tasks. **#2 now gates a flagship feature → escalate upstream.** |
|
||||
| **Co-developing a new abstraction + greenfield feature** | Build and unit-test the extension API in isolation first (step 1) before tasks logic lands on it. |
|
||||
| **Naming confusion** — `[tasks]` extra re-points under the same name | Deliberate changelog note; user code and the extra name are unchanged, only the wire modernizes. |
|
||||
|
||||
## Design decisions (resolved)
|
||||
|
||||
These were the open forks; the maintainer has settled them. Recorded here so the direction is unambiguous going into implementation.
|
||||
|
||||
1. **Wire adapter location — in the `fastmcp-tasks` package.** The engine *and* the SEP-2663 wire adapter live in the package; core carries only the `task=True` declaration. This isolates the experimental schema's churn from core, at the cost of diverging from the Apps precedent (where the `ui` wire glue lives in core today — Apps will converge onto this model when it migrates to the extension API).
|
||||
2. **Extension API shape — a FastMCP-native `mcp.add_extension()`, required to enable tasks.** Chosen over a thin pass-through to the SDK's `MCPServer(extensions=...)` because the FastMCP-native API can hand extensions the `Context`, component registry, and auth scope the SDK's `Extension` withholds. `add_extension` is **required** for `task=True` (not autodetected) — it is the single home for backend config and the honest source of capability advertisement. See [The extension API](#the-extension-api).
|
||||
3. **Client default — transparent completion on the friendly interface.** `call_tool` drives the poll loop and returns the finished result; `call_tool_mcp` exposes the raw `CreateTaskResult`; a "return quickly" flag yields the `Task` handle. See [Client experience](#client-experience).
|
||||
4. **Experimental labeling — yes.** `fastmcp-tasks` ships labeled experimental for at least one minor cycle, tracking the experimental `ext-tasks` schema.
|
||||
5. **Resource/prompt spine — dropped; tools-only.** The rebuild does not lead the SDK on augmentable request types, correcting the SEP-1686-era mistake. See [Scope for v1](#scope-for-v1-non-goals).
|
||||
|
|
@ -1,559 +0,0 @@
|
|||
---
|
||||
title: Change Register
|
||||
---
|
||||
|
||||
This is the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), organized by subsystem. It doubles as a review lens: take one subsystem, read its claimed changes, and verify each against the diff.
|
||||
|
||||
Each entry is tagged **Absorbed** (public surface unchanged), **Bridged** (shim keeps old code working, usually warning), **Breaking** (user code must change), or **Deprecated** (works, warns, slated for removal). See the [overview](/development/v4-notes/index) for what each disposition means.
|
||||
|
||||
**Empirical validation (WS2 upgrade reality-check).** The register's compatibility claims are verified, not predicted. Running unchanged 3.x-era code against this branch, all 11 upgrade scenarios pass or warn — the only failures are the two predicted breaks, user `mcp.types` imports and positional `McpError(ErrorData(...))` construction. Cross-version wire interop between a 3.4.3 peer and this branch is bidirectionally clean across 9 operations (3.4.3 client ↔ v4 server and v4 client ↔ 3.4.3 server over HTTP). All 29 `_ALIASES` bridge entries warn correctly with actionable messages.
|
||||
|
||||
## Environment
|
||||
|
||||
### Dependency floors: pydantic >= 2.12, Starlette >= 1.0 — Breaking (environment)
|
||||
|
||||
The SDK v2 raises FastMCP's dependency floors. Projects pinning an older pydantic (e.g. `2.11.*`) hit an unsatisfiable-resolution error at install time and must bump their pin; unpinned projects get pydantic upgraded silently. The server extra floors Starlette at `>=1.0.1` — modern FastAPI (0.11x+) already runs Starlette 1.x, so coexistence is clean (verified with FastAPI 0.138.2); only very old FastAPI pinned below Starlette 1.0 conflicts. Both are documented in the [upgrade guide's Environment requirements](/getting-started/upgrading/from-fastmcp-3#environment-requirements).
|
||||
|
||||
*Verify:* `fastmcp_slim/pyproject.toml` (`pydantic[email]>=2.12.0` core, `starlette>=1.0.1` server extra); WS2 environment-upgrade scenario.
|
||||
|
||||
## Types and imports
|
||||
|
||||
The SDK v2 split protocol types into a standalone `mcp_types` package and renamed every field from camelCase to snake_case. This is the single largest source of user-facing change, and FastMCP absorbs nearly all of it.
|
||||
|
||||
### `mcp.types` split into `mcp_types` — Breaking (by omission)
|
||||
|
||||
The `mcp.types` module no longer exists. Any `from mcp.types import X` or `import mcp.types` in user code raises `ImportError`. This is the one import change users cannot avoid.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/types.py`, and grep the diff for the doc migration `from mcp.types import` → `from fastmcp.types import` (30 sites).
|
||||
|
||||
### `fastmcp.types` is the stable home — Bridged
|
||||
|
||||
<Note>
|
||||
Superseded before release — see "`fastmcp.types` trimmed to FastMCP-unique types only" below. This section documents the re-export set as it existed mid-migration; none of it ever shipped.
|
||||
</Note>
|
||||
|
||||
FastMCP re-exports the protocol types users are most likely to touch from `fastmcp.types`, sourced from `mcp_types` (the `mcp` root package lacks most of them):
|
||||
|
||||
```python test="skip"
|
||||
from fastmcp.types import TextContent, Tool, ToolAnnotations, ErrorData
|
||||
```
|
||||
|
||||
The re-export set is deliberately limited to names that trace to a documented user import: `TextContent`, `ImageContent`, `AudioContent`, `EmbeddedResource`, `ResourceLink`, `ContentBlock`, `Tool`, `Resource`, `ResourceTemplate`, `Prompt`, `PromptMessage`, `CallToolResult`, `GetPromptResult`, `ReadResourceResult`, `TextResourceContents`, `BlobResourceContents`, `SamplingMessage`, `CreateMessageResult`, `SamplingCapability`, `Root`, `ErrorData`, `Completion`, `Annotations`, `ToolAnnotations`, `Icon`, `ToolResultContent`, plus the pre-existing `Textarea`. Notification and request wrapper types (e.g. `ToolListChangedNotification`) are not re-exported — import those from `mcp_types` directly.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/types.py` `__all__`.
|
||||
|
||||
### `fastmcp.types` trimmed to FastMCP-unique types only — Absorbed (post-review cleanup)
|
||||
|
||||
The re-export set above never shipped in a release, so it was cut before 4.0 rather than deprecated. `fastmcp.types` now holds only types FastMCP defines itself — `Textarea` — and every bare `mcp_types` mirror (`TextContent`, `Tool`, `ToolAnnotations`, `ErrorData`, and the rest of the 29-name list) is gone. Code that imported those from `fastmcp.types` now imports them from `mcp_types` directly:
|
||||
|
||||
```python
|
||||
from mcp_types import TextContent, Tool, ToolAnnotations, ErrorData
|
||||
```
|
||||
|
||||
Because `fastmcp.types.__all__` was `["Textarea"]` as of the last stable release (v3.4.4) and the mirrors were added only in this unreleased migration work, removing them breaks no released user — there is no bridge or deprecation warning to write.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/types.py` `__all__` (back down to `["Textarea"]`).
|
||||
|
||||
### camelCase field reads are bridged — Bridged (deprecated)
|
||||
|
||||
Objects FastMCP hands back — results of `client.list_tools()`, `client.call_tool_mcp()`, `client.read_resource()`, and the parameter objects passed to sampling and elicitation handlers — are SDK v2 objects with snake_case fields. A compatibility bridge installed at import time routes the old camelCase names to their snake_case fields, warning once per read:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
async def read_schema():
|
||||
async with Client("my_mcp_server.py") as client:
|
||||
tools = await client.list_tools()
|
||||
return tools[0].inputSchema # works, warns; prefer .input_schema
|
||||
```
|
||||
|
||||
The bridged fields are exactly those users read, data-driven from an `_ALIASES` table: `inputSchema`/`outputSchema` (Tool); `readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint` (ToolAnnotations); `mimeType` (Resource, ResourceTemplate, TextResourceContents, BlobResourceContents, ImageContent, AudioContent) and `uriTemplate` (ResourceTemplate); `isError`/`structuredContent` (CallToolResult); `hasMore` (Completion); `serverInfo`/`protocolVersion` (InitializeResult); `nextCursor`/`resourceTemplates` (List\*Result); `systemPrompt`/`maxTokens`/`stopSequences`/`modelPreferences`/`toolChoice` (CreateMessageRequestParams); `requestedSchema` (ElicitRequestFormParams). WS2 verified all 29 alias entries warn correctly with actionable messages.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/_compat.py` (the `_ALIASES` table and `install()`).
|
||||
|
||||
### The bridge is a genuine runtime toggle — Absorbed (post-review fix)
|
||||
|
||||
The bridge properties install unconditionally, and each getter reads the live `mcp_camelcase_compat` setting on every access: warn-and-return when enabled, raise `AttributeError` when disabled. An earlier version installed the bridge once at import, so flipping the setting afterward did nothing — commit `d9659453` fixed this so the toggle works at runtime:
|
||||
|
||||
```python
|
||||
import fastmcp
|
||||
|
||||
fastmcp.settings.mcp_camelcase_compat = False # now takes effect immediately
|
||||
```
|
||||
|
||||
The setting is documented in [Settings](/more/settings) as `FASTMCP_MCP_CAMELCASE_COMPAT`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/settings.py` (setting), `fastmcp_slim/fastmcp/_compat.py` (per-read gate), commit `d9659453`.
|
||||
|
||||
### `mcp-types` is now a core slim dependency — Absorbed (post-review fix)
|
||||
|
||||
Bare `import fastmcp` loads `mcp_types` via `_sdk_patches` and `_compat`, so a bare `fastmcp-slim` install (without the `[mcp]` extra) hit `ModuleNotFoundError`. Because `mcp-types` only pulls `pydantic` and `typing-extensions` (already core), it was promoted to a core dependency while the full `mcp` SDK stays in the `[mcp]` extra.
|
||||
|
||||
*Verify:* `fastmcp_slim/pyproject.toml` (`mcp-types==2.0.0b1` in core dependencies), commit `e16ffad4`.
|
||||
|
||||
### `McpError` is an alias; construction changed — Bridged (catch) / Breaking (construct)
|
||||
|
||||
`fastmcp.exceptions.McpError` is a plain alias of the SDK's `MCPError` — a plain alias, not a subclass, so `except McpError` still catches SDK-raised errors and `err.error.code` still reads:
|
||||
|
||||
```python
|
||||
from fastmcp.exceptions import McpError
|
||||
|
||||
try:
|
||||
...
|
||||
except McpError as err:
|
||||
print(err.error.code) # unchanged
|
||||
```
|
||||
|
||||
Construction is the one unavoidable behavior break. The v1 pattern of wrapping an `ErrorData` positionally raises `TypeError` under v2; construct with keywords instead:
|
||||
|
||||
```python
|
||||
from fastmcp.exceptions import McpError
|
||||
|
||||
# Before (raises TypeError under SDK v2):
|
||||
# raise McpError(ErrorData(code=-32000, message="Client not supported"))
|
||||
|
||||
raise McpError(code=-32000, message="Client not supported")
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/exceptions.py` (`McpError = MCPError`).
|
||||
|
||||
## Server core
|
||||
|
||||
The SDK v2 rewrote the server request-handling model. FastMCP's handler layer is the most heavily rewritten part of the migration, but the public server API is unchanged.
|
||||
|
||||
### Handler adapters — Absorbed
|
||||
|
||||
Handlers are now registered by method string via `add_request_handler(method, params_type, handler)`, take a uniform `(ctx, params)` signature, and return the **bare** result model (no `ServerResult` wrapper). FastMCP's `_setup_handlers` builds one thin adapter per method (`tools/list`, `tools/call`, `resources/read`, `prompts/get`, `logging/setLevel`, …) that binds the request context, adapts params to the existing handler body, and returns the bare result. The v1 decorator overrides and `_wrap_list_handler` are deleted.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (462 lines changed), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py`.
|
||||
|
||||
### FastMCP-owned request context — Absorbed
|
||||
|
||||
The SDK's `request_ctx` ContextVar is gone; the SDK passes context to handlers as an argument only. FastMCP owns its own `fastmcp_request_ctx` ContextVar, set at the top of every adapter. It stores a FastMCP-owned `FastMCPRequestContext` wrapper rather than the raw SDK context, because the raw `ServerRequestContext.meta` is a bare `TypedDict` carrying only `progress_token` — the full `_meta` block (which holds `_meta.fastmcp.version` and the distributed-trace parent) has to be lifted out of the raw params dict. `Context.request_context` and its consumers (`report_progress`, `session_id`, telemetry trace extraction, `get_http_request`) all read through the wrapper.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/dependencies.py`, `server/context.py`, `server/telemetry.py`.
|
||||
|
||||
### `ServerMiddleware` bridge for `initialize` — Absorbed
|
||||
|
||||
Server-side middleware is a new first-class SDK concept: `Server.middleware` is a list of `ServerMiddleware` composed around every request and notification, including `initialize`. FastMCP no longer subclasses `ServerSession` (the runner constructs it), so the old `MiddlewareServerSession._received_request` override is gone. A `FastMCPServerMiddleware` is appended to the SDK's middleware list (preserving the SDK's own OpenTelemetry middleware) and intercepts `initialize` to run FastMCP's middleware chain. The v2 interface is cleaner — `call_next(ctx)` returns the serialized result directly, so the old `capturing_respond` machinery is deleted.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (`FastMCPServerMiddleware`).
|
||||
|
||||
### Middleware observes every inbound message — New (coverage)
|
||||
|
||||
FastMCP's `Middleware` chain used to begin *inside* the per-method handlers, so `on_message`/`on_request`/`on_notification` only fired for messages that reached a tool/resource/prompt handler. Notifications, cancellations, and malformed or unroutable requests were invisible to middleware. `FastMCPServerMiddleware` — FastMCP's entry in the SDK's own middleware list — is now the dispatch root: it runs the `on_message`/`on_request`/`on_notification` pass for every message the interior handlers do not dispatch (all notifications including `notifications/cancelled`, `ping`, `logging/setLevel`, unknown methods, and component requests that fail validation before the handler runs). The component methods keep their interior dispatch unchanged, so `on_call_tool` and friends still receive the typed component result and a tool exception still propagates through `on_message`/`on_request` exactly where the built-in error/logging/timing middleware expect it — each hook fires exactly once per message. Multi-round (SEP-2322) calls compose cleanly with this: each round is a complete request→response cycle through the full chain, and an asking round's `call_next` returns the ask as an ordinary `InputRequiredToolResult` value (see the MRTR entry below). All thirteen built-in middleware pass their suites unmodified. See [What middleware sees](/servers/middleware#what-middleware-sees).
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (`FastMCPServerMiddleware` root dispatch, `_INTERIOR_METHODS`), `fastmcp_slim/fastmcp/server/middleware/middleware.py` (`MiddlewarePhase`, `mark_interior_dispatched`), `fastmcp_slim/fastmcp/server/server.py` (`_dispatch_component_middleware`), `tests/server/middleware/test_message_visibility.py`.
|
||||
|
||||
### Per-session state re-homed to the connection — Absorbed
|
||||
|
||||
Because `ServerSession` is now per-request, per-session state can no longer live on the session object. The minimum logging level is re-homed to a FastMCP-side map keyed by session id (via `connection.session_id`), and `client_supports_extension` becomes a free function reading `session.client_params.capabilities`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py`, `server/context.py` (`_log_to_server_and_client`).
|
||||
|
||||
### `extensions` capability read from the real field — Absorbed (post-review fix)
|
||||
|
||||
SDK v2 declares `extensions` as a real field on `ClientCapabilities`, so a client sending `ClientCapabilities(extensions={...})` populates the field, not `model_extra`. `client_supports_extension` now reads `caps.extensions` first and falls back to `model_extra` only for legacy-serialized clients.
|
||||
|
||||
*Verify:* commit `96ca0092`, `server/low_level.py` / `server/context.py`.
|
||||
|
||||
### Task protocol and the `_sdk_patches` shim — Absorbed (with an upstream gap)
|
||||
|
||||
The SEP-1686 task CRUD protocol (`tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`) is entirely FastMCP-owned — the SDK ships no task store. Task detection moves to a params field: `params.task is not None` on `CallToolRequestParams`, with `ttl` from `params.task.ttl`. The four task handlers port to `add_request_handler`.
|
||||
|
||||
The SDK has a real gap here (see [Known Gaps](/development/v4-notes/known-gaps) and sdk-feedback #1): it ships the task result types but omits them from the method registries, so a background-task `tools/call` returning a `CreateTaskResult` fails validation. FastMCP installs a registry-widening shim in `_sdk_patches.py` that adds `CreateTaskResult` to the `tools/call` result union and registers the `tasks/*` rows. It is a temporary patch with a self-documented removal trigger.
|
||||
|
||||
Resources and prompts have **no `task` field** on their params in b1, so task-augmented resource reads and prompt gets are not wire-expressible — a documented capability regression, tracked by xfails, not a bug FastMCP fixes.
|
||||
|
||||
This section records the migration's *handling* of the SEP-1686 wire layer as it stood at merge. That layer is not the end state: it is slated for removal and rebuild on the `io.modelcontextprotocol/tasks` extension (SEP-2663) as the `fastmcp-tasks` package. See [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) for the forward plan; the `_sdk_patches.py` shim and the `server/tasks/*` wire handlers described here go away with it, while the Docket execution engine moves into `fastmcp-tasks`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/_sdk_patches.py`, `server/tasks/*`.
|
||||
|
||||
### Single SERVER span per request — Absorbed (post-migration fix)
|
||||
|
||||
SDK v2 seeds an `OpenTelemetryMiddleware` into every lowlevel `Server`, so each inbound request already emits a SERVER span. FastMCP emits its own richer SERVER span per request (with `fastmcp.*` and auth/session attributes), so a server with an OTel exporter installed would export **two** SERVER spans per request under different attribute conventions. `LowLevelServer.__init__` now drops the SDK's seeded `OpenTelemetryMiddleware` (matched by type, not position, leaving any other seeded middleware intact) and keeps FastMCP's spans. Inbound W3C trace-context extraction is unaffected — FastMCP's telemetry reads `traceparent` from `_meta` itself, so distributed traces still link client to server. Client-side is not double-counted: the SDK's `ClientSession` emits a low-level `MCP send <method>` CLIENT span that nests *under* FastMCP's high-level client span, a legitimate parent/child hierarchy rather than a duplicate.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (the `OpenTelemetryMiddleware` filter); `tests/server/telemetry/test_server_tracing.py::TestSingleServerSpan`.
|
||||
|
||||
### Telemetry on by default, with an explicit off-switch — Absorbed
|
||||
|
||||
FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. The new `FASTMCP_ENABLE_TELEMETRY` setting (`fastmcp.settings.enable_telemetry`, default `true`) is the explicit off-switch: set it to `false` and `get_tracer()` returns a genuine no-op tracer, so no FastMCP spans are created even when an SDK is configured. The off-switch governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send <method>` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/settings.py` (`enable_telemetry`); `fastmcp_slim/fastmcp/telemetry.py` (`get_tracer` off-switch); `fastmcp_slim/fastmcp/server/telemetry.py` (`get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`.
|
||||
|
||||
### Spec-correct error codes via a central translator — Breaking (wire error code)
|
||||
|
||||
Resource-not-found responses from the core `resources/read` handler previously used `-32002`. SEP-2164 (and the SDK's own mcpserver, which maps `ResourceNotFoundError` → `INVALID_PARAMS`) makes this `-32602`. The per-adapter `MCPError(code=..., ...)` literals in `server/mixins/mcp_operations.py` are replaced by a single `fastmcp.exceptions.to_mcp_error()` translator that maps FastMCP's public exceptions to the `mcp_types` code constants (`NotFoundError`/`DisabledError`/`ValidationError` → `INVALID_PARAMS`, else `INTERNAL_ERROR`). Clients that string-matched on the old `-32002` for resource-not-found must switch to `-32602`; the human-readable message ("Resource not found: ...") is unchanged. The opt-in `ErrorHandlingMiddleware`, which has its own documented per-method-prefix code mapping, is intentionally left as-is.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/exceptions.py` (`to_mcp_error`); `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py`; `tests/test_exceptions.py`.
|
||||
|
||||
### `Cachable*` response-cache models renamed to `Cacheable*` — Breaking (rename) <!-- codespell:ignore -->
|
||||
|
||||
The response-caching middleware's Pydantic wrapper models — used to serialize cached tool, resource, and prompt results for `ResponseCachingMiddleware` — carried a spelling typo. `CachableToolResult`, `CachableResourceContent`, `CachableResourceResult`, `CachableMessage`, and `CachablePromptResult` are renamed to `CacheableToolResult`, `CacheableResourceContent`, `CacheableResourceResult`, `CacheableMessage`, and `CacheablePromptResult`. None of these classes are re-exported from `fastmcp` or any package `__init__.py`, so the realistic blast radius is limited to code that imported the old names directly from `fastmcp.server.middleware.caching`:
|
||||
|
||||
```python
|
||||
# Before (now raises ImportError):
|
||||
# from fastmcp.server.middleware.caching import CachableToolResult
|
||||
|
||||
# After
|
||||
from fastmcp.server.middleware.caching import CacheableToolResult
|
||||
```
|
||||
|
||||
There is deliberately no compatibility alias for the old spelling.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/middleware/caching.py`.
|
||||
|
||||
### Server-side argument completion — New (opt-in feature)
|
||||
|
||||
A FastMCP server can now answer `completion/complete` requests, suggesting values for prompt arguments and resource-template parameters as a user types. Previously a FastMCP *client* could call `complete()` but a FastMCP *server* had no way to respond — the method was unregistered, so it returned `-32601` (method-not-found) on both eras. The new `@mcp.completion` decorator registers a single server-level handler that receives the reference (a `PromptReference` or `ResourceTemplateReference`), the `CompletionArgument` being completed, and the optional `CompletionContext` of already-supplied argument values, and returns candidates — a list of strings, a `Completion` (to carry the `total`/`has_more` pagination hints), or `None`/empty for a reference it does not recognize (which yields an empty completion, not an error).
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import PromptReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def write_poem(theme: str) -> str:
|
||||
return f"Write a poem about {theme}"
|
||||
|
||||
|
||||
@mcp.completion
|
||||
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 None
|
||||
```
|
||||
|
||||
The completions capability is declared exactly when a handler exists: `add_completion_handler` registers the low-level `completion/complete` handler, and the SDK derives the capability from that handler's presence — a server with no completion handler does not advertise it. FastMCP does not hand-set the capability. The single-handler shape mirrors the SDK's own `completion/complete` surface and FastMCP's existing client-side `Client.complete()`, and it slots into the `@mcp.tool`/`@mcp.prompt`/`@mcp.resource` decorator lineup as another server-level `@mcp.<verb>` registration rather than inventing a per-argument sub-decorator idiom. It works identically on the handshake and modern (`2026-07-28`) eras, since `completion/complete` is a request/response method that flows on every era. The authoring types — `PromptReference`, `ResourceTemplateReference`, `CompletionArgument`, `CompletionContext`, and `Completion` — are imported from `mcp_types`, not `fastmcp.types`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/completions.py` (handler type + `normalize_completion`), `fastmcp_slim/fastmcp/server/server.py` (`completion` decorator, `add_completion_handler`), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_complete`), `tests/server/test_completions.py`, `docs/servers/completions.mdx`.
|
||||
|
||||
## Client
|
||||
|
||||
The `fastmcp.Client` public API is largely preserved. The client stays a wrapper around `mcp.ClientSession`; the first-class `mcp.client.Client` is deliberately not adopted. Two client-surface changes are called out below: the connection `mode` default flips to `"auto"`, and `extensions=` / `result_claims=` are newly surfaced.
|
||||
|
||||
### Connection `mode` defaults to `"auto"` — Breaking (behavior)
|
||||
|
||||
`Client(mode=...)` now defaults to `"auto"` instead of `"legacy"`. The client probes `server/discover` and adopts the modern (`2026-07-28`) era when the server responds, denylist-falling-back to the initialize handshake for any server that is not positive evidence of a modern peer. Against a FastMCP server (which serves both eras), an ordinary `Client(url)` now negotiates the modern era by default, where the legacy-only Context push features are unavailable per the per-feature era matrix (see the *Protocol eras* section below) — server-initiated sampling/elicitation/roots, `ping`, session ids, and FastMCP task submission all require the legacy era. The one-line revert is `Client(..., mode="legacy")`, which restores byte-identical pre-v4 negotiation.
|
||||
|
||||
The SSE transport is legacy-only (it cannot carry the sessionless modern era), so a client connecting over SSE negotiates the legacy handshake even under `mode="auto"` — expressed by a `ClientTransport.legacy_only` flag set on `SSETransport`. `MCPConfigTransport` reports `legacy_only` as a property: a multi-server config is legacy-only (each backend is mounted behind a legacy-era proxy), while a single-server config mirrors its one backend transport's era so a modern Streamable HTTP backend stays modern-capable. Two internal library clients that are inherently handshake-based are pinned to legacy so the flip does not break them: the `ProxyClient` backend (which forwards the initialize handshake and server-initiated features) defaults to `mode="legacy"`, and the `inspect` utility (which reads the full `server_info` only the handshake carries) connects legacy.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client("https://example.com/mcp") # now negotiates "auto"
|
||||
client = Client("https://example.com/mcp", mode="legacy") # opt back into the handshake
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`mode` default, `_negotiate` `legacy_only` shortcut), `fastmcp_slim/fastmcp/client/transports/{base,sse,config}.py` (`legacy_only`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`ProxyClient` legacy default), `fastmcp_slim/fastmcp/mcp_config.py` and `fastmcp_slim/fastmcp/utilities/inspect.py` (legacy inner clients), `tests/client/client/test_mode_negotiation.py` (default, clean discover-rejection fallback, legacy-only transport), `tests/test_mcp_config.py` (single- vs multi-server `legacy_only`), `docs/clients/client.mdx`.
|
||||
|
||||
### `extensions=` / `result_claims=` surfaced — New (opt-in feature)
|
||||
|
||||
`fastmcp.Client` now accepts `extensions=` (a sequence of SEP-2133 `ClientExtension` instances) and `result_claims=` (extra `ResultClaim`s keyed by an advertised extension's identifier). Each extension's capability advertisement, result claims, and notification bindings are folded into the underlying `ClientSession` on every transport. User-supplied notification bindings **compose** with FastMCP's internal task-status binding rather than clobbering it: the task binding always leads, and a user extension that binds the same method surfaces a clear duplicate-method error at connect time rather than silently winning. Result claims are wired end-to-end: `call_tool()` / `call_tool_mcp()` pass `allow_claimed=True` and resolve a claimed result through the owning claim's resolver (`ClaimContext`), so a server-emitted claimed shape is finished into an ordinary `CallToolResult` instead of raising `UnexpectedClaimedResult`. Claimed shapes are modern-only, so they are inert on a legacy connection.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`_build_extension_kwargs`, `_resolve_claimed_result`, `new()`), `fastmcp_slim/fastmcp/client/mixins/tools.py` (`call_tool_mcp` claim resolution), `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.extensions`/`result_claims`), `tests/client/test_client_extensions.py` (fold, composition, live both-bindings-fire, end-to-end claim resolution).
|
||||
|
||||
### Protocol helpers delegated to the SDK — Absorbed (internal)
|
||||
|
||||
`fastmcp.Client` carried forked copies of three SDK helpers — `_fold_extensions` (with its `_FoldedExtensions` dataclass), `_evicting_message_handler`, and `_synthesize_discover` — written when the SDK had not yet stabilized them. It now imports the SDK's implementations directly. The forks had already drifted: FastMCP's `_fold_extensions` was missing the SEP-2133 `validate_extension_identifier` check, so a non-reverse-DNS extension identifier that the SDK rejects was silently accepted. Adopting the SDK's version closes that gap. No public surface moves; the SDK returns `None` rather than empty collections for the folded claims and bindings, absorbed at the two call sites in `_build_extension_kwargs`.
|
||||
|
||||
Full composition — `fastmcp.Client` holding an `mcp.Client` and delegating the connection lifecycle to it — remains blocked upstream. `mcp.Client._build_session` hardcodes `ClientSession(...)` with no override hook, but FastMCP's `TransportOptions.session_class` is load-bearing: `ProxyClient` supplies a `_ForwardingClientSession` that skips output-schema validation so a backend's schema bug surfaces at the end client rather than as a proxy error. Separately, `mcp.Client.__aenter__` raises on reentry, while FastMCP's refcounted reentrant context manager is depended on by proxy session reuse. Both would need an upstream `session_factory=` hook (the same shape as the `notification_bindings=` ask that unblocked extension composition) before the lifecycle itself can be delegated.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (imports from `mcp.client.client`; no local helper definitions), `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions.session_class`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_ForwardingClientSession`, `PROXY_TRANSPORT_OPTIONS`).
|
||||
|
||||
### Transports yield 2-tuples — Absorbed
|
||||
|
||||
All SDK transports (`streamable_http_client`, `sse_client`, `stdio_client`) now yield a 2-tuple `(read, write)` instead of exposing a third `get_session_id` element. HTTP configuration flows through a caller-supplied `http_client=`. Only the tuple unpack changed on the FastMCP side.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py`, `transports/sse.py`, `transports/stdio.py`.
|
||||
|
||||
### Float timeouts; `timedelta` still accepted — Absorbed
|
||||
|
||||
The SDK session and call timeouts are now plain floats. FastMCP's public `Client(timeout=...)` still accepts a `timedelta`, a plain float, or an int, normalizing through the existing `normalize_timeout_to_seconds` at the `SessionKwargs` chokepoint:
|
||||
|
||||
```python
|
||||
from datetime import timedelta
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client("my_mcp_server.py", timeout=timedelta(seconds=30)) # still works
|
||||
client = Client("my_mcp_server.py", timeout=30.0) # also works
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.read_timeout_seconds: float | None`), `client/client.py`.
|
||||
|
||||
### Connection settings passed to `connect_session` — Breaking (custom transports)
|
||||
|
||||
`ClientTransport.connect_session` takes a new keyword-only `transport_options: TransportOptions | None`, describing how the connecting client wants its session built: which `ClientSession` class to instantiate, and whether to forward the caller's authorization header upstream. Proxies use it to relay backend results without enforcing their output schema (see [Proxy Servers](/servers/providers/proxy#tool-results-are-relayed-not-inspected)).
|
||||
|
||||
These settings previously lived on the transport instance, so a transport shared between clients leaked one client's configuration into another — including credential forwarding, which `create_proxy(some_client)` would silently enable on the caller's own client. They now travel with the client that wants them, and `forward_incoming_headers` is no longer a settable transport attribute.
|
||||
|
||||
A client only passes the argument when it wants non-default settings, so an ordinary `Client` is unaffected and transports that don't accept it keep working. A custom `ClientTransport` used as a *proxy backend* must accept and honor it:
|
||||
|
||||
```python
|
||||
import contextlib
|
||||
|
||||
from fastmcp.client.transports.base import ClientTransport, TransportOptions
|
||||
|
||||
class MyTransport(ClientTransport):
|
||||
@contextlib.asynccontextmanager
|
||||
async def connect_session(self, *, transport_options=None, **session_kwargs):
|
||||
options = transport_options or TransportOptions()
|
||||
async with options.session_class(read, write, **session_kwargs) as session:
|
||||
yield session
|
||||
```
|
||||
|
||||
A transport that wraps others must pass it along; `MCPConfigTransport` forwards it to both its single-server delegate and its composite server.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions`), the four built-in transports, `transports/config.py`, and `tests/server/providers/proxy/test_proxy_server.py`.
|
||||
|
||||
### `get_session_id` via header sniff — Bridged
|
||||
|
||||
The SDK dropped `get_session_id` from the streamable-HTTP transport with no replacement (the SDK source has an author TODO acknowledging it breaks the Transport protocol). FastMCP reconstructs it by registering an httpx2 response event hook on the client it owns, capturing the `mcp-session-id` response header (httpx2 preserves httpx's `event_hooks` API). The removal trigger is the upstream TODO.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py` (`_capture_session_id`, `get_session_id`).
|
||||
|
||||
### Pagination via `params=` — Absorbed
|
||||
|
||||
The SDK's `cursor=` kwarg on `list_*` is gone; pagination now flows through `params=PaginatedRequestParams(cursor=...)`. FastMCP's public `cursor=` on the `list_*_mcp` methods is preserved and translated internally.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/mixins/{tools,resources,prompts}.py`.
|
||||
|
||||
### OAuth `callback_handler` returns `AuthorizationCodeResult` — Breaking (advanced)
|
||||
|
||||
The one OAuth break: a custom `callback_handler` must return an `AuthorizationCodeResult` (fields `code`, `state`, `iss`) instead of the old `tuple[str, str | None]`. Everything else in the OAuth surface — `OAuthClientProvider` kwargs, `TokenStorage`, `async_auth_flow` — is unchanged.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/auth/oauth.py`.
|
||||
|
||||
### Notification dispatch unwrapped — Absorbed
|
||||
|
||||
The client's notification handling was reworked for the v2 message model. Custom server-to-client notifications (like SEP-1686 `notifications/tasks/status`) are no longer tee'd to a user `message_handler` — the SDK routes them only through `NotificationBinding` (see sdk-feedback #8). FastMCP registers a binding so task-status updates reach the Task registry.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/messages.py`, `client/tasks.py`.
|
||||
|
||||
### `SDKServer` alias — Absorbed (post-review rename)
|
||||
|
||||
The in-memory transport resolves the low-level server per server type. The alias for the SDK's own `MCPServer` was renamed from the misleading `FastMCP1Server` / `FastMCP1x` to `SDKServer`, since it names the SDK v2 server, not a FastMCP 1.x object.
|
||||
|
||||
*Verify:* commit `5c3b82e4`; `client/client.py`, `client/transports/memory.py`, `server/providers/proxy.py`, `cli/run.py`.
|
||||
|
||||
### Proxy request-context stash — Absorbed (post-review fix)
|
||||
|
||||
Proxy forwarding handlers stash the request context so a backend that issues a server-initiated request (list_roots/sampling/elicitation) can relay it back to the proxy's own client. This stash was initially applied only on the tool path; commit `1ac166bd` extended it to proxied resources, templates, and prompts.
|
||||
|
||||
*Verify:* commit `1ac166bd`, `server/providers/proxy.py`.
|
||||
|
||||
### Shared response cache via `KeyValueResponseCacheStore` — New
|
||||
|
||||
The SDK's client response cache (SEP-2549) reads and writes through a pluggable `ResponseCacheStore`; the default is a per-client in-memory LRU. FastMCP adds `KeyValueResponseCacheStore`, an adapter over the same `AsyncKeyValue` key-value abstraction the event store and OAuth proxy already use, so a fleet of clients (e.g. proxy replicas) can share one Redis-backed response cache. Pass it via `CacheConfig(store=...)`; a custom store requires an explicit `partition` (SDK) and `target_id` (FastMCP). Results serialize through a type-tagged envelope validated against an allowlist of cacheable result models — an unknown tag is a cache miss, never an import-by-name — and each adapter owns its own collection so `clear()` never touches another tenant.
|
||||
|
||||
```python
|
||||
from fastmcp.client.caching import KeyValueResponseCacheStore
|
||||
from mcp.client.caching import CacheConfig
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
|
||||
store = KeyValueResponseCacheStore(storage=RedisStore(url="redis://localhost"))
|
||||
config = CacheConfig(store=store, partition="tenant-a", target_id="weather-api")
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/caching.py`, `tests/client/client/test_kv_response_cache.py`.
|
||||
|
||||
### Machine-to-machine client auth — New (feature)
|
||||
|
||||
`fastmcp.client.auth` gains two browser-free auth providers for the OAuth 2.0 `client_credentials` grant, closing the most common client-auth gap (previously only interactive `OAuth` and static `BearerAuth` were available). `ClientCredentialsOAuthProvider(client_id=..., client_secret=...)` authenticates with a client ID and secret; `PrivateKeyJWTOAuthProvider(client_id=..., assertion_provider=...)` uses an RFC 7523 `private_key_jwt` assertion (workload identity federation or a locally signed JWT via the re-exported `SignedJWTParameters` / `static_assertion_provider` helpers). Both are thin wrappers over the SDK's `mcp.client.auth.extensions.client_credentials` providers and implement `httpx2.Auth`, so they slot into the same `Client(auth=...)` path as every other provider. Like interactive `OAuth`, they take the MCP server URL (the token endpoint is discovered from OAuth metadata) and bind to it lazily — omit `mcp_url` and the transport supplies it. In-memory token storage is the default with no warning, since a lost M2M token is re-acquired in one non-interactive request.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.auth import ClientCredentialsOAuthProvider
|
||||
|
||||
auth = ClientCredentialsOAuthProvider(client_id="id", client_secret="secret")
|
||||
async with Client("https://example.com/mcp", auth=auth) as client:
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/auth/client_credentials.py`, `fastmcp_slim/fastmcp/client/transports/{http,sse}.py`, `tests/client/auth/test_client_credentials.py`.
|
||||
|
||||
## HTTP
|
||||
|
||||
The maintainer asked whether FastMCP can now delete its custom HTTP app and let the SDK's `Server.streamable_http_app()` handle everything. The answer for this PR is **no** — every override earns its keep. Convergence is a v4 project gated on three upstream additions (see [Feature Program](/development/v4-notes/feature-program)).
|
||||
|
||||
### Kept overrides — Absorbed
|
||||
|
||||
Four overrides survive, each for a concrete reason:
|
||||
|
||||
1. **Event-store session scoping.** The SDK hands every per-session transport the *same* `event_store` object, one stream-ID keyspace shared across sessions. FastMCP's `FastMCPStreamableHTTPSessionManager` returns a fresh `SessionScopedEventStore(shared, session_id=…)` per session, so resumability events don't leak across sessions.
|
||||
2. **Lifespan reconciliation.** The SDK builder enters the bare lowlevel `Server.lifespan` (which yields `{}`). FastMCP drives its own `_lifespan_manager` — ref-counted for mounts, Ctrl-C-shielded, docket-aware. The SDK path silently skips all of it, so FastMCP sets the server lifespan to delegate to `_lifespan_manager` and lets the manager enter it once.
|
||||
3. **Graceful transport termination.** FastMCP's lifespan `finally` drains the manager's server instances via `transport.terminate()` before task-group cancel, fixing the Uvicorn "returned without completing response" edge (#3025). The SDK just cancels.
|
||||
4. **User ASGI middleware hook.** The SDK builder hardcodes an empty middleware list and only appends auth. FastMCP's `http_app(middleware=...)` and `RequestContextMiddleware` have nowhere to go in the SDK path.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/http.py`, `server/event_store.py`, `server/mixins/lifespan.py`.
|
||||
|
||||
### DNS-rebinding ownership — Absorbed (security)
|
||||
|
||||
FastMCP owns DNS-rebinding protection through its `HostOriginGuardMiddleware`, which is more expressive than the SDK's and is the documented surface. To avoid two allowlists double-blocking with confusing errors from two layers, FastMCP **always** disables the SDK's layer by passing `TransportSecuritySettings(enable_dns_rebinding_protection=False)` to the manager — both when FastMCP's protection is on (so they don't double-block) and when it's off (so the SDK's default-on flip can't silently re-enable it).
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/http.py` (`enable_dns_rebinding_protection=False`, `HostOriginGuardMiddleware`).
|
||||
|
||||
### httpx2 replaces httpx — Breaking (custom client/factory, typing) / Absorbed (everything else)
|
||||
|
||||
SDK v2.0.0b2 replaces `httpx` + `httpx-sse` with [httpx2](https://pypi.org/project/httpx2/) (`>=2.5.0`), a next-generation httpx fork with built-in SSE. httpx2 is a near drop-in fork: the public API (`AsyncClient`, `Auth`, `Request`, `Response`, `Timeout`, `MockTransport`, exception hierarchy, `event_hooks`) matches httpx name-for-name. The SDK duck-types the client you hand it — `streamable_http_client(http_client=...)` and `sse_client(httpx_client_factory=...)` are type-hinted `httpx2.AsyncClient` with no `isinstance` gate — but the objects that cross into the SDK must be httpx2.
|
||||
|
||||
FastMCP now uses **httpx2 exclusively** and no longer depends on `httpx`. Every FastMCP-owned HTTP path moves to httpx2: the client transports (`client/transports/{base,http,sse}.py`), client auth (`client/auth/{oauth,bearer}.py` — `BearerAuth`/`OAuth` subclass `httpx2.Auth`), the client-side exception-group handler (`utilities/exceptions.py`), the proxy's upstream client (`server/providers/proxy.py`), the `MCPConfig` client-auth field (`mcp_config.py`), **and** all the server-side code that the earlier migration pass had left on httpx — the ~15 server auth providers' upstream IdP calls, the OpenAPI provider, `from_openapi`/`from_fastapi`, `version_check`, `resources/types.py`, the SSRF download guard, and the `apps_dev` CLI. `httpx` is dropped from the `mcp` extra entirely (it may still arrive transitively via other libraries, but FastMCP never imports it). The ~170 `httpx_mock` calls across the security-critical server-auth test files are ported to a local httpx2-backed `httpx_mock` fixture (`tests/utilities/httpx2_mock.py`) that preserves the `add_response`/`add_exception`/`get_request(s)` API verbatim, so `pytest-httpx` is dropped too.
|
||||
|
||||
User-visible deltas:
|
||||
|
||||
- **Custom client factory / client.** `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, and `OAuth(httpx_client_factory=...)` factories must now return `httpx2.AsyncClient`; a custom `httpx.Auth` passed as `Client(auth=...)` should become `httpx2.Auth`. httpx2 is a drop-in fork, so the change is an import swap (`import httpx` → `import httpx2`). This is a typing break; at runtime a duck-compatible httpx client still satisfies the SDK, but mixing `httpx.Timeout`/`httpx.Auth` with an httpx2 client is unsupported.
|
||||
- **OpenAPI client.** `FastMCP.from_openapi(client=...)` and `OpenAPIProvider(client=...)` are now type-hinted `httpx2.AsyncClient`. There is no `isinstance` gate, so an existing `httpx.AsyncClient` still works at runtime via duck-typing this release; the typing nudges you to httpx2.
|
||||
- **TLS trust store.** httpx2 verifies TLS against the OS trust store via `truststore` (honoring `SSL_CERT_FILE`/`SSL_CERT_DIR` first) instead of the bundled certifi CA set. This now applies to **all** FastMCP HTTP, including server-auth upstream IdP calls — not just the client path. Corporate-CA and certifi-pinned setups may see different trust behavior.
|
||||
- **Logger renames.** FastMCP HTTP now logs under `httpx2` and `httpcore2.*` (was `httpx`/`httpcore.*`). Anyone filtering FastMCP HTTP logs by logger name must update the names.
|
||||
|
||||
The session-id header hook (below) works unchanged: httpx2 keeps httpx's `event_hooks` API. FastMCP's tool/resource/prompt handlers still map upstream 429/timeout errors to actionable `ToolError`/`ResourceError`; because a user's own tool may raise from either library, `server/server.py` catches both `httpx2` and (if installed) legacy `httpx` `HTTPStatusError`/`TimeoutException` via a defensive `try: import httpx` shim.
|
||||
|
||||
*Verify:* `fastmcp_slim/pyproject.toml` (`mcp` extra lists only `httpx2`); no FastMCP source imports `httpx` except the documented defensive shim in `server/server.py`.
|
||||
|
||||
## Protocol eras
|
||||
|
||||
The SDK v2 serves multiple protocol eras from one server, and FastMCP formally embraces this.
|
||||
|
||||
### Dual-era serving — Absorbed (supersedes "latest only")
|
||||
|
||||
A single FastMCP server now handles clients across the protocol transition: the session-based handshake eras (through 2025-11-25) and the sessionless `2026-07-28` era (capability discovery via `server/discover`) simultaneously. This supersedes FastMCP's earlier "latest protocol only" stance.
|
||||
|
||||
### Per-feature era matrix — Breaking (feature availability by era)
|
||||
|
||||
The push-style Context features that require the server to call back into the client are unavailable on the sessionless `2026-07-28` era, because that era removes server-initiated requests (SEP-2577). The request/response features flow on every era.
|
||||
|
||||
| Context feature | Session-based eras | `2026-07-28` (sessionless) |
|
||||
| --- | --- | --- |
|
||||
| `ctx.info` / logging notifications | Supported | Supported |
|
||||
| Tools, resources, prompts, completions | Supported | Supported |
|
||||
| `ctx.elicit` (imperative) | Supported | Not on the back-channel — use [elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol) |
|
||||
| `ctx.sample` / `ctx.sample_step` | Supported (deprecated) | Removed — call an LLM server-side |
|
||||
| `ctx.list_roots` | Supported | Via the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) |
|
||||
| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension |
|
||||
|
||||
Tools that rely on `ctx.elicit` or `ctx.list_roots` continue to work against clients on the session-based eras. On the modern era, elicitation is reachable through the multi-round "guard" pattern instead (a tool returns an `InputRequiredResult`; see the New entry below). Sampling is the exception: it is deprecated on every era and will not return on modern connections (see the Deprecated entry below).
|
||||
|
||||
Ordinary `ctx.info` usage emits an SDK-level `MCPDeprecationWarning` ("The logging capability is deprecated as of 2026-07-28 (SEP-2577)"). That warning comes from the SDK, not FastMCP, and is benign — logging keeps working on session-based connections per the matrix. `ctx.sample`/`ctx.sample_step` additionally emit a FastMCP-owned `FastMCPDeprecationWarning` (see below). The upgrade guide calls both out explicitly.
|
||||
|
||||
Wire interop across the transition is verified: a 3.4.3 client against a v4 server and a v4 client against a 3.4.3 server are bidirectionally clean across 9 operations over HTTP (WS2).
|
||||
|
||||
*Verify:* `docs/getting-started/upgrading/from-fastmcp-3.mdx` (the published matrix and SDK-warning note), `tests/server/test_protocol_eras.py`.
|
||||
|
||||
### Sampling deprecated, era-gated — Deprecated
|
||||
|
||||
`ctx.sample()` and `ctx.sample_step()` are deprecated and slated for removal in a future FastMCP release. Server-initiated sampling relies on the `createMessage` back-channel that SEP-2577 removed from the wire as of `2026-07-28`, and unlike elicitation it has no multi-round-trip replacement (the agentic loop would exhaust the round-trip budget). Both methods now emit a `FastMCPDeprecationWarning` once per process (gated on `settings.deprecation_warnings`), and on a `2026-07-28` connection they raise a clear `ToolError` before touching the wire. The client-side sampling handler infrastructure (anthropic/openai/google_genai) is retained for future MRTR work and is not deprecated. The migration is to call an LLM directly from your server rather than borrowing the client's model.
|
||||
|
||||
The dead TODO at `server/context.py` (a background-task sampling relay that was never built) is removed: that relay is not being built, so the note is gone rather than left as a promise.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/context.py` (`_warn_sampling_deprecated`, `_is_modern_protocol`, the `sample`/`sample_step` gates), `docs/servers/sampling.mdx` (deprecation banner), `tests/server/test_protocol_eras.py` (warning + era-gate tests).
|
||||
|
||||
### Push-feature degradation quality — Resolved (was sdk-feedback #10)
|
||||
|
||||
On a `2026-07-28` connection the degradation error used to differ by feature: `ctx.list_roots` raised a clear `NoBackChannelError`, while `ctx.elicit` / `ctx.sample` surfaced a bare "Method not found" because those methods attach a `related_request_id` and reach client dispatch before failing. FastMCP now era-gates `ctx.elicit` and `ctx.sample`/`ctx.sample_step` to raise a clear, era-aware `ToolError` before the wire ("server-initiated sampling is not available on MCP 2026-07-28 connections…" and "elicitation via server-initiated requests is unavailable on 2026-07-28 connections."). The strict xfail that captured #10 is flipped to a passing test.
|
||||
|
||||
*Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_sample_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gates).
|
||||
|
||||
### Server-level cache hints (SEP-2549) — New (opt-in feature)
|
||||
|
||||
A FastMCP server can emit SEP-2549 freshness hints so a caching client (`fastmcp.Client(cache=...)`) may reuse a response without a wire round-trip. Two constructor params carry it: `FastMCP(cache_ttl=300, cache_scope="public")`, where `cache_ttl` is in seconds and `cache_scope` is `"public"` or `"private"` (default `"private"` when a TTL is set). The hint is uniform by construction — one server-level value applies to every SDK-cacheable method (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, and `server/discover`) with no per-component surface and no aggregation. FastMCP does not hand-set the wire fields: it passes the hint through to the SDK low-level `Server(cache_hints=...)`, whose runner fills `ttlMs`/`cacheScope` on every cacheable result via `apply_cache_hint`, leaving any field a handler set explicitly untouched. `cache_ttl` must be positive, and a `cache_scope` without a `cache_ttl` is rejected at construction (a scope alone does not enable caching, since the client gates on the TTL's presence). Absent both params, no hint is emitted. Honoring is modern-only (the SDK client reads hints only at `2026-07-28`) and opt-in on the client, so a hinted server is inert unless the client passes `cache=`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/caching.py` (`build_cache_hints`), `fastmcp_slim/fastmcp/server/server.py` (constructor params passed to `LowLevelServer(cache_hints=...)`), `tests/server/test_cache_hints.py` (unit validation + end-to-end interop with `fastmcp.Client(cache=True)`).
|
||||
|
||||
### Elicitation on the modern protocol (SEP-2322), guard form — New (opt-in feature)
|
||||
|
||||
A tool can gather client input across rounds on a `2026-07-28` call by returning an `InputRequiredResult` (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle: the tool re-runs per round and reads the client's answers off two new `Context` properties, `ctx.input_responses` (`None` on the first round) and `ctx.request_state` (the echoed opaque state) — thin passthroughs matching the SDK's mcpserver semantics. This is the modern-era elicitation path the earlier per-feature matrix flagged as "MRTR rewrite pending"; it mirrors the SDK's base guard model exactly (tool re-runs, checks whether answers are present, returns to ask for more), with no FastMCP-invented resolver or annotation layer. For authoring these requests, `InputRequiredResult`, `ElicitRequest`, and `ElicitRequestFormParams` import from `mcp_types`. The `request_state` channel is sealed by the framework, not the author: FastMCP installs the SDK's `RequestStateBoundary` middleware on its low-level server, which seals every outgoing `request_state` and unseals and verifies every inbound echo before a tool runs — so a tool only ever sees plaintext and a tampered, expired, or foreign token is rejected with a frozen wire error. `FastMCP(request_state_security=RequestStateSecurity(keys=[...]))` supplies shared keys for multi-replica deployments; omitted, each process seals under an ephemeral key (correct single-process). Returning this result on a handshake-era (≤ 2025-11-25) connection raises a clear era error naming the mismatch rather than failing as a generic invalid result. The client half (`fastmcp.Client` at `mode="auto"`) drives the loop through its existing elicitation/sampling/roots handlers, capped by `input_required_max_rounds`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/context.py` (`input_responses`/`request_state` properties), `fastmcp_slim/fastmcp/server/low_level.py` (`RequestStateBoundary` install), `fastmcp_slim/fastmcp/server/server.py` (`request_state_security` param), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_call_tool` input-required passthrough + era gate), `fastmcp_slim/fastmcp/tools/base.py` (`InputRequiredToolResult`), `tests/server/test_mrtr_guards.py`.
|
||||
|
||||
### Proxy era mirroring — New (behavior)
|
||||
|
||||
A proxy is a server on its front and a client on its back, and the two eras have mutually exclusive interaction models on a single session: the handshake era pushes server-initiated requests (sampling/elicitation/roots) that the proxy forwards to its client, while the modern era forbids those and round-trips a guard tool's `InputRequiredResult` as a result instead. A proxy created from a non-Client target with no explicit `mode` now MIRRORS the front connection's negotiated era onto its backend session per request, so the whole chain speaks one era end-to-end — a modern client reaches a modern backend (guard round-trips work), a handshake client reaches a handshake backend (push-forwarding works), and the same proxy serves both without a backend session ever crossing eras. Because the default factory builds a fresh backend client per request and derives its `mode` from the front era at call time, only the metadata-only component caches are shared across eras. An explicit `create_proxy(target, mode=...)` still pins the backend era regardless of the front, overriding mirroring for a backend that only speaks one era; the resulting cross-era feature mismatches surface through the existing era gates. `ProxyInitializeMiddleware` no longer force-calls the handshake-only `client.initialize()` when the backend negotiated the modern era, so an explicit modern pin behind a handshake front no longer crashes on connect. The mirrored era carries through a multi-server `MCPConfig` target as well: that form mounts one proxy per configured server onto a composite router, and `TransportOptions.backend_mode` hands the era down to those mounted legs so every real backend negotiates it, not just the router in front of them. That router is also now sealed under a policy held on the transport rather than a fresh per-router ephemeral key, so a guard tool's `request_state` survives the router being rebuilt between rounds.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_mirror_front_era_mode`, the `_create_client_factory` non-Client branch, the era guard in `ProxyInitializeMiddleware.on_initialize`), `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions.backend_mode`), `fastmcp_slim/fastmcp/client/transports/config.py` (`MCPConfigTransport.connect_session` / `_create_proxy`), `fastmcp_slim/fastmcp/server/server.py` (`create_proxy` docstring), `tests/server/test_mrtr_guards.py` (`TestProxyEraMirroring`, `TestMultiServerConfigEraMirroring`).
|
||||
|
||||
### Resource and prompt errors survive the modern era — Absorbed (defect fix)
|
||||
|
||||
`_on_call_tool` returns a `ResourceError`-equivalent as an error result, but `_on_read_resource` and `_on_get_prompt` caught only `DisabledError`/`NotFoundError`, so a `ResourceError`, `PromptError`, or an argument-conversion failure on a resource template escaped as a raw handler exception. On the handshake eras that reached the wire as `str(exc)`, which is survivable; on `2026-07-28` the runner masks anything that is not an `MCPError` or `ValidationError` as a generic `"Internal server error"`, so a legitimate client-input error became indistinguishable from a server bug. Both handlers now translate a `FastMCPError` through `to_mcp_error` the way tools already do. Masking is unchanged — `mask_error_details` is still applied inside `read_resource`/`render_prompt`, so these paths leak no more than tools do.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_read_resource`, `_on_get_prompt`), `tests/server/test_protocol_eras.py`.
|
||||
|
||||
### Proxies forward upstream instructions on the modern era — Absorbed (defect fix)
|
||||
|
||||
`ProxyInitializeMiddleware` forwards an upstream server's `instructions` by patching the `InitializeResult`, but `on_initialize` only fires for the handshake era. A modern client negotiates via `server/discover`, which the SDK builds from the low-level server's own `instructions`, so a proxy silently dropped its upstream's instructions for every modern client. `FastMCPProxy` now registers a `server/discover` handler (the same `add_request_handler` hook it already uses for `ping`, and a replacement the SDK explicitly sanctions) that delegates to the SDK's own implementation and fills in only the instructions that would otherwise be lost. The proxy's lazy-connect contract is unchanged: the backend is contacted when a client asks, never at construction. Because era mirroring pins a modern backend to an exact version — and a pinned version adopts a synthesized `DiscoverResult` rather than probing the wire — this read negotiates with `mode="auto"`; instructions are metadata with no back-channel, so they do not need the era consistency mirroring exists to protect.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`FastMCPProxy._setup_proxy_discover_handler`), `tests/server/providers/proxy/test_proxy_server.py` (`TestProxyModernEraInstructions`).
|
||||
|
||||
### Proxy list methods raise `MCPError` on backend failure — Breaking (in-process error type)
|
||||
|
||||
`ProxyProvider`'s four `_list_*` methods caught only `MCPError`, so a failed backend connection escaped as the `RuntimeError` the client wraps it in (or a raw `httpx2.ConnectError`). On the handshake eras that reached the wire as `str(exc)` and named the real failure; on `2026-07-28` it was masked as `"Internal server error"`, leaving a modern client unable to tell a dead backend from a server bug. The list methods now normalize transport failures through `_proxy_upstream_error`, matching `ProxyInitializeMiddleware.on_initialize`. Code calling a proxy's `list_tools()` (and friends) in-process must now catch `MCPError` rather than `RuntimeError`; the over-the-wire error type is unchanged.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_PROXY_TRANSPORT_ERRORS` and the four `_list_*` methods), `tests/server/providers/proxy/test_proxy_server.py` (`TestProxyProviderTransportErrors`).
|
||||
|
||||
### The xfail register — Known gap
|
||||
|
||||
Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](/development/v4-notes/known-gaps) page.
|
||||
|
||||
## Security
|
||||
|
||||
FastMCP retains hardening that is not yet upstream and does not remove it during the migration.
|
||||
|
||||
### Retained OAuth / DCR hardening — Absorbed
|
||||
|
||||
FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless of the SDK's validation, which still accepts unsafe `javascript:`/`data:` redirect schemes at the model level (sdk-feedback #4). The streamable-HTTP DNS-rebinding protection above is a second retained security surface.
|
||||
|
||||
*Verify:* recent commits `67527c1f` (block unsafe OAuth redirect schemes), `57a27992` (DNS rebinding), `cccb529f` (DCR redirect URI validation) on `main`.
|
||||
|
||||
### Identity assertion (SEP-990 ID-JAG) — Added (beta)
|
||||
|
||||
`OAuthProxy` (and `OIDCProxy`, which inherits it) accepts an optional `identity_assertion=IdentityAssertion(trusted_issuers=[...])`. When configured, the token endpoint accepts the RFC 7523 `urn:ietf:params:oauth:grant-type:jwt-bearer` grant carrying an enterprise IdP-issued ID-JAG, validates it (signature against the trusted issuer's JWKS, `iss`/`aud`/`exp`, `typ` of `oauth-id-jag+jwt`, mandatory `sub`, signed `client_id`/`resource` binding, and `jti` replay rejection), and mints a short-lived FastMCP access token carrying the asserted subject with no refresh token. Authorization server metadata advertises the `jwt-bearer` grant type and the `urn:ietf:params:oauth:grant-profile:id-jag` profile when enabled. This is server-side only; the client-side wrapper ships separately. See [Identity Assertion](/servers/auth/oauth-proxy#identity-assertion-sep-990).
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/auth/identity_assertion.py`, the `exchange_identity_assertion` and `get_routes` changes in `fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py`, and the jwt-bearer dispatch in `fastmcp_slim/fastmcp/server/auth/auth.py` (`TokenHandler._maybe_handle_id_jag`).
|
||||
|
||||
### Templated resource parameters are path-screened by default — Breaking (behavior)
|
||||
|
||||
Every templated resource now has its extracted parameter values screened for path-traversal (`..` segments), absolute paths, and null bytes **before the handler runs** — on by default, at the server's read chokepoint, covering local and provider-sourced (mounted/proxied) templates alike. Previously these payloads reached handlers raw; a template whose parameter flowed into a filesystem path or upstream URL was exposed unless the author added their own check. A rejected read now surfaces a non-leaky "resource not found" error (`-32602`) and a debug log.
|
||||
|
||||
The check is component-based, matching the SDK's `contains_path_traversal`: only a standalone `..` segment is traversal, so values that merely contain dots (`HEAD~3..HEAD`, `file.tar.gz`) and dotfiles (`.env`) still pass. This can break a template that legitimately accepts `..`-bearing or absolute values — exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security).
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/resources/security.py` (`ResourceSecurity`), the screening block in `FastMCP.read_resource` (`fastmcp_slim/fastmcp/server/server.py`), and `tests/resources/test_resource_security.py`.
|
||||
|
||||
## Removed in 4.0
|
||||
|
||||
Deprecations that warned in 3.x are removed in 4.0. Each entry below is a hard removal — the old surface raises `TypeError` / `AttributeError` rather than warning, unless noted otherwise.
|
||||
|
||||
### Module and class shims
|
||||
|
||||
- **`fastmcp.server.proxy`** (deprecated 3.0) — Breaking. Import proxy classes (`FastMCPProxy`, `ProxyClient`, etc.) from `fastmcp.server.providers.proxy` instead.
|
||||
- **`fastmcp.server.openapi`** and its submodules (`server`, `components`, `routing`), including the **`FastMCPOpenAPI`** class (deprecated 3.0) — Breaking. Use `FastMCP` with an `OpenAPIProvider` from `fastmcp.server.providers.openapi` instead.
|
||||
- **`fastmcp.experimental.server.openapi`** and **`fastmcp.experimental.utilities.openapi`** shims (deprecated 2.14) — Breaking. Import from `fastmcp.server.providers.openapi` and `fastmcp.utilities.openapi` respectively.
|
||||
- **`fastmcp.server.apps`** and **`fastmcp.server.app`** shims (deprecated 3.2) — Breaking. Import from `fastmcp.apps` (e.g. `AppConfig`) or `fastmcp` (`FastMCPApp`) instead.
|
||||
- **`PromptToolMiddleware`** and **`ResourceToolMiddleware`** (deprecated 3.1) — Breaking. Use the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` instead. The non-deprecated `ToolInjectionMiddleware` base class is retained.
|
||||
- **`StreamableHttpTransport(sse_read_timeout=...)`** (deprecated no-op) — Breaking. The parameter had no effect under the SDK v2 client; configure timeouts via `read_timeout_seconds` in `session_kwargs` or on the httpx2 client via `httpx_client_factory`. `SSETransport` still accepts `sse_read_timeout`.
|
||||
|
||||
### `FastMCP` server methods and `mount()` kwargs
|
||||
|
||||
The following `FastMCP` methods and parameters, deprecated since 3.0, are removed:
|
||||
|
||||
- `FastMCP.as_proxy(...)` → `create_proxy(...)` (`from fastmcp.server import create_proxy`)
|
||||
- `FastMCP.import_server(sub)` → `mount(sub)`
|
||||
- `mount(prefix=...)` → `mount(namespace=...)`
|
||||
- `mount(as_proxy=...)` — removed; mounts always invoke the child's lifespan and middleware, so the flag was already meaningless. To proxy a server, wrap it with `create_proxy()` before mounting.
|
||||
- `FastMCP.add_tool_transformation(name, config)` → `add_transform(ToolTransform({name: config}))`
|
||||
- `FastMCP.remove_tool_transformation(name)` — removed; it was a no-op that only warned (transforms are immutable once added). Use `server.disable(keys=[...])` to hide tools.
|
||||
- `FastMCP.remove_tool(name)` → `mcp.local_provider.remove_tool(name)`
|
||||
|
||||
The `_REMOVED_KWARGS` constructor shim (which raises helpful `TypeError`s for kwargs removed in 3.0) is retained through 4.0.
|
||||
|
||||
### Tool and component parameters
|
||||
|
||||
- **Tool-level `serializer` parameter** — removed from `@tool` / `mcp.tool()`, `Tool.from_function`, `Tool.from_tool`, `TransformedTool.from_tool`, the OpenAPI `OpenAPITool`, and the `mcp_mixin` tool decorator. Return a `ToolResult` from your tool for full control over serialization instead (see [Custom Serialization](/servers/tools#custom-serialization)). The server-level `tool_serializer` constructor kwarg was already removed in 3.0.
|
||||
- **Tool `exclude_args` parameter** — removed from the tool decorator and its plumbing (`ParsedFunction.from_function`, `Tool.from_function`, `mcp.tool()`). Use dependency injection with `Depends()` to hide parameters from the tool schema instead.
|
||||
- **`decorator_mode` setting** (`FASTMCP_DECORATOR_MODE`) and its `"object"` mode — removed. Decorators always return the original function with metadata attached; the object-returning machinery is gone. Access component objects through the server (e.g. `await mcp.get_tool("name")`) rather than the decorated function.
|
||||
- **Component-import compatibility shims** — the `__getattr__` shims that re-exported `FunctionTool` / `ParsedFunction` / `tool` from `fastmcp.tools.tool`, `FunctionResource` / `resource` from `fastmcp.resources.resource`, and `FunctionPrompt` / `prompt` from `fastmcp.prompts.prompt` are removed. Import these from their canonical modules (`fastmcp.tools.function_tool`, `fastmcp.resources.function_resource`, `fastmcp.prompts.function_prompt`) instead.
|
||||
|
||||
*Verify:* deletions of `fastmcp_slim/fastmcp/server/proxy.py`, `fastmcp_slim/fastmcp/server/openapi/`, `fastmcp_slim/fastmcp/experimental/server/openapi/`, `fastmcp_slim/fastmcp/experimental/utilities/openapi/`, `fastmcp_slim/fastmcp/server/apps.py`, `fastmcp_slim/fastmcp/server/app.py`; the removed classes in `fastmcp_slim/fastmcp/server/middleware/tool_injection.py`; the removed parameter in `fastmcp_slim/fastmcp/client/transports/http.py`; `fastmcp_slim/fastmcp/server/server.py`; `fastmcp_slim/fastmcp/tools/base.py`, `tools/function_tool.py`, `tools/tool_transform.py`, `tools/function_parsing.py`; `fastmcp_slim/fastmcp/settings.py`, `resources/function_resource.py`, `prompts/function_prompt.py`, and the local-provider decorators; `resources/base.py`, `prompts/base.py`.
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
---
|
||||
title: Feature Program
|
||||
---
|
||||
|
||||
The migration is the foundation. The forward v4 program is a sequence of post-merge PRs that build on it. Several have now merged. Each feature below carries an explicit status:
|
||||
|
||||
- **Shipped** — merged to `main`, with the PR cited.
|
||||
- **Designed** — the approach is settled and an API sketch exists; implementation has not started.
|
||||
- **Planned** — the shape is agreed but design details remain open.
|
||||
- **Not started** — identified as v4 scope, not yet designed.
|
||||
|
||||
Code blocks marked as sketches show the *intended* API and do not resolve against the current tree.
|
||||
|
||||
## Sampling removal
|
||||
|
||||
**Status: Deprecation and era-gating shipped (#4448); removal slated for 4.0.**
|
||||
|
||||
Sampling is the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so this API cannot work on modern connections. Background-task sampling is dead under v2 — a worker's back-channel is gone once the submitting request returns, and no sampling relay was ever built (sdk-feedback #9).
|
||||
|
||||
The plan is Option A: **deprecate the push-sampling API now and remove it in the 4.0 release.** The first two steps shipped in #4448:
|
||||
|
||||
- **Done:** `ctx.sample` / `ctx.sample_step` emit a `FastMCPDeprecationWarning` (once per process, gated on `settings.deprecation_warnings`).
|
||||
- **Done:** both are era-gated to raise a clear, era-aware `ToolError` on `2026-07-28` before the wire, which also fixed the opaque "Method not found" of sdk-feedback #10.
|
||||
- **Pending 4.0:** remove `ctx.sample`, `ctx.sample_step`, `server/sampling/`, `SamplingTool`, and structured-result sampling.
|
||||
|
||||
The migration story is honest: there is **no drop-in** on modern connections. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. That shift is the real answer, and it is why the removal justifies a major version.
|
||||
|
||||
The client-side provider handlers (Anthropic, OpenAI, Google GenAI) are **retained** regardless: MRTR needs them to answer sampling input-requests from the client side. What is removed is the server-side push emitter, which the SDK never built for the modern era.
|
||||
|
||||
Sampling still functions on the legacy eras. Users also see an SDK-level `MCPDeprecationWarning` on ordinary `ctx.sample` usage (the SDK deprecated the capability wire-side per SEP-2577). FastMCP's own deprecation — the warning with migration guidance, plus the era-gating — shipped in #4448; only the final removal remains for 4.0.
|
||||
|
||||
## MRTR elicitation
|
||||
|
||||
**Status: Guard form shipped (4.0). Declarative `Resolve` layer designed.**
|
||||
|
||||
Elicitation survives the modern era through multi-round-trip (MRTR). The 2026 wire envelope carries elicitation as a multi-round input-request: a tool returns an `InputRequiredResult` and re-runs per round, each round a complete request→response cycle. Imperative `ctx.elicit` relies on the session back-channel, which is gone on `2026-07-28` foreground calls; on the modern era, elicitation is reachable through MRTR instead.
|
||||
|
||||
The **guard form** of this is shipped in 4.0 (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)): a tool returns an `InputRequiredResult` and reads the client's answers off `ctx.input_responses` / `ctx.request_state`, re-running each round. It mirrors the SDK's base guard model exactly — no FastMCP-invented DX, the framework owns `request_state` sealing, and returning this result on a handshake-era connection produces a clear era error.
|
||||
|
||||
What remains is the declarative `Resolve(...)` layer that sits *on top of* that shipped primitive. It is designed, not built: a new `fastmcp.elicitation` module — `Resolve`, `Elicit`, and `ElicitationResult` — thin wrappers over the SDK's resolver, wired into FastMCP's own tool layer (FastMCP tools do not inherit the SDK's auto-resolver wiring). It would detect `Annotated[_, Resolve(...)]` parameters, build resolver plans, and return the SDK's `InputRequiredResult` instead of the tool body on the first round.
|
||||
|
||||
Imperative `ctx.elicit` is **not** re-plumbed to survive the modern era. It works on the legacy eras through the session back-channel, and on `2026-07-28` foreground calls it is era-gated to raise a clear error (shipped in #4448) pointing at the guard form. The earlier plan to keep imperative `ctx.elicit` alive on modern connections through a background-task relay is dead twice over: the guard model shipped in its place, and the 2025 task machinery the relay depended on is slated for removal (see [Known Gaps](/development/v4-notes/known-gaps#the-xfail-register)).
|
||||
|
||||
The intended declarative DX (sketch — the module does not exist yet):
|
||||
|
||||
```python test="skip"
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastmcp import FastMCP, Context
|
||||
from fastmcp.elicitation import Resolve, Elicit, ElicitationResult
|
||||
|
||||
mcp = FastMCP("shipping")
|
||||
|
||||
|
||||
class Address(BaseModel):
|
||||
street: str
|
||||
city: str
|
||||
zip: str
|
||||
|
||||
|
||||
async def ask_address(ctx: Context) -> Elicit[Address]:
|
||||
return Elicit("Where should we ship this order?", Address)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def create_shipment(
|
||||
order_id: str,
|
||||
address: Annotated[Address, Resolve(ask_address)], # unwrapped; decline -> ToolError
|
||||
) -> str:
|
||||
return f"Shipping {order_id} to {address.city}"
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def maybe_ship(
|
||||
order_id: str,
|
||||
address: Annotated[ElicitationResult[Address], Resolve(ask_address)], # full outcome
|
||||
) -> str:
|
||||
if address.action != "accept":
|
||||
return "cancelled"
|
||||
return f"Shipping {order_id} to {address.data.city}"
|
||||
```
|
||||
|
||||
The FastMCP client already dispatches input-requests through its elicitation callback; the remaining declarative work confirms the FastMCP client drives the input-required driver the way the SDK's own client does.
|
||||
|
||||
The divergence between elicitation and sampling on 2026 comes down to one fact: the SDK built the server-side emitter for elicitation (`Elicit`/`Resolve`) and not for sampling. The wire carries all three input-request types and the client dispatches all three; only elicitation can produce one server-side. That is why elicitation survives 4.0 via MRTR and push-sampling does not.
|
||||
|
||||
## Middleware root dispatch
|
||||
|
||||
**Status: Shipped (#4553).**
|
||||
|
||||
The migration already routed `initialize` interception through the SDK's `ServerMiddleware` list via `FastMCPServerMiddleware`. #4553 made that entry the root of middleware dispatch: FastMCP's method-agnostic hooks (`on_message`, `on_request`, `on_notification`) now fire for every inbound message — client cancellations, progress notifications, and requests that fail routing or validation — not only the ones that reach a component handler. The component methods keep running their own chain interior, and a method set plus a dispatch flag keep the two passes disjoint so each hook fires exactly once per message.
|
||||
|
||||
## First-class 2026 client
|
||||
|
||||
**Status: Partly shipped (#4572, #4574); full composition blocked upstream.**
|
||||
|
||||
`fastmcp.Client` now defaults to `mode="auto"` (#4572): it probes `server/discover`, falls back to the classic handshake, and answers multi-round-trip `input_required` requests through its existing handlers. The same PR surfaced `extensions=` and `result_claims=` (SEP-2133). The client also dropped its forked protocol helpers — extension folding, the evicting message handler, discover synthesis — in favor of the SDK's own (#4574).
|
||||
|
||||
The decision here was **compose, not wrap** (D16): rebuild `fastmcp.Client` on the SDK's high-level `mcp.Client` rather than wrapping `mcp.ClientSession`. The parts that compose cleanly have shipped. The rest is **blocked upstream on two counts**. First, `mcp.Client` constructs its `ClientSession` at a single hardcoded site with no injection hook, while FastMCP's `session_class` is load-bearing (`ProxyClient` substitutes a session that skips result validation so a backend's schema violation surfaces at the end client rather than becoming a proxy error) — a `session_factory=` hook on `mcp.Client`, the same shape as the `notification_bindings=` parameter added earlier, would solve this. Second, `mcp.Client.__aenter__` refuses reentry, but FastMCP's client is deliberately reentrant (its refcounted context manager exists to fix a proxy session-reuse deadlock), so the rebuild also needs the SDK client to tolerate reentrant entry. Both must land upstream before the full rebuild is possible; `session_factory=` alone is necessary but not sufficient.
|
||||
|
||||
This workstream also owns the server-side statelessness design holes — `ctx.session_id` / `set_state` round-tripping and stateful-proxy affinity — since they turn on the same "what is a session without a session?" question. See [Statelessness on 2026-07-28](/development/v4-notes/known-gaps#statelessness-on-2026-07-28) for the full accounting.
|
||||
|
||||
## Subscriptions, cache hints, extensions, OTel
|
||||
|
||||
**Status: Mixed — cache hints and OTel shipped; subscriptions not started.**
|
||||
|
||||
A cluster of protocol features tracked for v4. Their statuses have diverged:
|
||||
|
||||
- **Cache hints — shipped (#4464).** Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`, SEP-2549) stamps every cacheable result, and the FastMCP client honors hints with an opt-in response cache.
|
||||
- **OpenTelemetry — shipped (#4481).** Spans are on by default (a no-op without an exporter), with SDK-aligned attributes and a `FASTMCP_ENABLE_TELEMETRY=false` off-switch.
|
||||
- **Extensions — client side shipped (#4572).** `Client(extensions=..., result_claims=...)` advertises opt-in client extensions (SEP-2133). The server side is a Designed workstream in its own right (see [FastMCP-native extension API](#fastmcp-native-extension-api)). The cross-era reconciliation of the `extensions` / MCP Apps capability advertisement is still open (the capability is stripped at pre-2026 negotiated versions — sdk-feedback #2).
|
||||
- **Subscriptions — not started.** A `subscriptions/listen` surface backed by a subscription bus.
|
||||
|
||||
## FastMCP-native extension API
|
||||
|
||||
**Status: Shipped (#4602).**
|
||||
|
||||
MCP extensions (SEP-2133) are optional, capability-negotiated protocol features identified by a reverse-DNS string — `io.modelcontextprotocol/ui` (MCP Apps), `io.modelcontextprotocol/tasks` (SEP-2663). They are a genuinely new abstraction in SDK v2; they did not exist in v1. The SDK exposes them through an `Extension` server class that contributes a capability, additive request methods, and a `tools/call` interceptor, plus a symmetric `ClientExtension` with result claims and notification bindings.
|
||||
|
||||
FastMCP already forwards `ClientExtension` natively (`Client(extensions=...)`, #4572). The **server** side does not use the SDK's `Extension` class at all: MCP Apps predates the abstraction, so FastMCP hand-splices the `ui` capability into `get_capabilities()` on the low-level server and walks tool metadata directly. That worked for one extension, but every new protocol extension currently means bespoke surgery on core.
|
||||
|
||||
The Designed work is a FastMCP-native server extension API — a single registration point (`mcp.add_extension(...)`) that contributes a negotiated capability, request methods, and a `tools/call` interceptor, with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is designed against the SEP-2663 tasks extension because tasks exercises the full surface — capability *and* methods *and* interception *and* client claims/notifications — where MCP Apps exercises only a subset. Tasks is the pathfinder; MCP Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices, and confirms the design generalizes. The discriminator that keeps the extension API distinct from [middleware](/servers/middleware): an extension is a *negotiated contract change* the client must understand, where middleware is unilateral server behavior the client never sees. Delete a capability advertisement and nothing about the client changes — that is middleware, not an extension.
|
||||
|
||||
## Background tasks (SEP-2663)
|
||||
|
||||
**Status: Shipped (#4603).**
|
||||
|
||||
Background tasks return to the modern era as `fastmcp-tasks`, an in-repo optional package rebuilt on the `io.modelcontextprotocol/tasks` extension (SEP-2663, Final, merged upstream 2026-05-15). SEP-2663 supersedes SEP-1686 but keeps its polling core: a client that advertises the tasks capability issues an augmented `tools/call`; the server decides whether to run it as a task and returns a `CreateTaskResult` carrying a server-generated task id; the client polls `tasks/get` until terminal and reads the result inlined there. FastMCP's existing SEP-1686 wire layer is removed while the Docket/Redis execution engine underneath moves into `fastmcp-tasks` intact — the spec moved toward what FastMCP already built, so the rebuild is mostly deletion plus a thin wire adapter. `task=True` stays the authoring surface (gated by the `fastmcp[tasks]` extra and an explicit `mcp.add_extension(TasksExtension(...))`, the first consumer of the [extension API](#fastmcp-native-extension-api) above), so a server that already uses tasks needs no code change. Scope for v1 is polling-only and `tools/call`-only.
|
||||
|
||||
The full design — wire delta, the engine/wire split, packaging, client experience, sequencing, risks, and the five resolved decisions — is on the dedicated [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) page.
|
||||
|
||||
## SDK delegation, round two
|
||||
|
||||
**Status: Planned (gated on upstream).**
|
||||
|
||||
The real HTTP simplification is a v4 project, not this PR. FastMCP can collapse its `create_streamable_http_app` onto the SDK's `Server.streamable_http_app()` once upstream adds three things:
|
||||
|
||||
1. per-session event-store scoping,
|
||||
2. a user-middleware injection hook,
|
||||
3. a lifespan hook.
|
||||
|
||||
The payoff is not only less code — FastMCP would also inherit the SDK's session-owner credential enforcement, a security gain it lacks today. These are the three upstream feature requests to file (alongside the advisory dossier described in [Known Gaps](/development/v4-notes/known-gaps)). Until they land, the four HTTP overrides in the [Change Register](/development/v4-notes/change-register#http) stay.
|
||||
|
||||
One latent capability worth surfacing on FastMCP's side: `session_idle_timeout` is accepted by the manager but never set by `create_streamable_http_app` — a one-line plumb if FastMCP wants to expose it.
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
title: v4.0 Development Notes
|
||||
---
|
||||
|
||||
This directory is the working map of FastMCP v4.0: the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), plus the forward v4 feature program. It plays three roles at once.
|
||||
|
||||
1. **A change register.** Every user-visible change from the migration, organized by subsystem, with a note on how FastMCP handles it (absorbed, bridged, breaking, or deprecated) and where to find it in the diff. This is the [Change Register](/development/v4-notes/change-register).
|
||||
2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, a FastMCP-native extension API, the SEP-2663 background-tasks rebuild, and the SDK-delegation round-two convergence — now a mix of shipped, designed, and pending. Multi-round-trip guard tools (#4544), the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream), the extension API (#4602), and background tasks on SEP-2663 (#4603) have shipped; sampling removal and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](/development/v4-notes/feature-program). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](/development/v4-notes/protocol-2026).
|
||||
3. **A review lens.** Because the migration PR is too large to review line by line, the change register is organized so a reviewer can take one subsystem, read its claimed changes, and verify each against the diff. The [Known Gaps](/development/v4-notes/known-gaps) page collects the deliberate xfails and the upstream dependencies that gate the follow-up work.
|
||||
|
||||
## Why v4 exists
|
||||
|
||||
FastMCP v4.0 is an engine swap. Three forces drive the major version:
|
||||
|
||||
**The MCP Python SDK v2 rebuild.** The SDK v2 makes two sweeping changes to the protocol layer: it splits the protocol types out of `mcp.types` into a standalone `mcp_types` package, and it renames every protocol field from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`). It also rewrites the server request-handling model — handlers are now registered by method string and return bare result models, there is no `request_ctx` ContextVar, and server-side middleware is a first-class SDK concept. FastMCP absorbs almost all of this so that a typical server needs zero code changes.
|
||||
|
||||
**Protocol version 2026-07-28.** The SDK v2 serves multiple protocol eras from one server. Alongside the session-based handshake eras, it introduces the sessionless `2026-07-28` era, which discovers capabilities through `server/discover` and removes server-initiated requests (SEP-2577). This formally supersedes FastMCP's earlier "latest protocol only" stance: a single server now works with clients across the protocol transition.
|
||||
|
||||
**Sampling removal.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call. That takes the push-shaped sampling API (`ctx.sample`, `ctx.sample_step`) off the table on modern connections. Rather than leave it half-working, v4 deprecates it now and removes it in the 4.0 release — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump.
|
||||
|
||||
## Release strategy
|
||||
|
||||
The migration merges to `main` and development continues there with subsequent PRs. Releases follow the SDK's own beta timeline:
|
||||
|
||||
- **`main` carries the beta pins.** While the SDK is on `mcp==2.0.0b1` / `mcp-types==2.0.0b1`, `main` cuts **pre-releases** (`4.0.0b1`, `4.0.0b2`, …). No stable PyPI release goes out until `mcp 2.0.0` reaches GA — at which point the pins swap to the stable SDK and `4.0.0` ships. The pin-swap is a tracked checklist item on the [Known Gaps](/development/v4-notes/known-gaps) page.
|
||||
- **`release/3.x` is the maintenance line.** A `release/3.x` branch is cut from pre-merge `main`. It stays on the SDK v1 line, receives upstream security patches, and serves users who cannot move to the SDK v2 beta yet.
|
||||
|
||||
### Release codenames
|
||||
|
||||
Following the pun-title convention (`v<version>: <pun>`), the v4 line runs a single "four" motif across the whole cycle, holding the headline name for the stable release the way v3 did ("Three at Last" for `3.0.0`, stage puns for its betas):
|
||||
|
||||
| Release | Codename | The nod |
|
||||
| --- | --- | --- |
|
||||
| `4.0.0a1` (alpha) | **Fourst Contact** | _first contact_ — the first, cautious look at the new engine |
|
||||
| `4.0.0a2` (alpha) | **Back and Fourth** | _back and forth_ — the second pass, where background tasks and stateless state land |
|
||||
| `4.0.0b1` (beta) | **Fourgone Conclusion** | _foregone conclusion_ — once the MCP SDK went v2, v4 was inevitable |
|
||||
| `4.0.0b2` (beta) | **Fourmidable** | _formidable_ — held in reserve for a second beta if one is needed |
|
||||
| `4.0.0` (stable) | **Fast Fourward** | _fast forward_ — full speed onto the new foundation |
|
||||
|
||||
## How to read the register
|
||||
|
||||
Each subsystem section in the [Change Register](/development/v4-notes/change-register) tags its changes with one of four dispositions:
|
||||
|
||||
- **Absorbed** — the SDK changed underneath, but FastMCP's public surface is identical. Nothing for users to do.
|
||||
- **Bridged** — a compatibility shim keeps old code working, usually with a `FastMCPDeprecationWarning`. Users should migrate but are not forced to.
|
||||
- **Breaking** — user code must change. These are the headline migration items.
|
||||
- **Deprecated** — still works, warns now, slated for removal in a later release.
|
||||
|
||||
The user-facing summary of the migration lives in the published [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) guide. These development notes are the exhaustive version behind it.
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
---
|
||||
title: Known Gaps and Upstream Dependencies
|
||||
---
|
||||
|
||||
The migration ships with a set of deliberate gaps: temporary shims, xfailed tests, and pins that depend on the MCP Python SDK v2 reaching GA. Each is tracked here with its removal trigger. This page is the checklist for the beta-to-stable transition and the advisory relationship with the SDK team.
|
||||
|
||||
## The xfail register
|
||||
|
||||
Roughly forty `xfail` markers across the test tree name the SDK gaps and removed protocol surfaces they wait on. Re-running the suite against a new SDK beta surfaces which have closed (a strict xfail that starts passing fails the suite, prompting removal of the marker). They cluster in three areas — but the largest cluster is no longer a set of gaps to close.
|
||||
|
||||
**Task suite (`tests/server/tasks/`, `tests/client/tasks/`) — SEP-1686 wire layer being removed; engine rebuilt on SEP-2663.** The large majority. These cover the 2025 task protocol (SEP-1686), which left the core MCP spec and was reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP's SEP-1686 *wire* machinery (capability advertisement, the `tasks/get|result|list|cancel` handlers, the push notification/elicitation relay) is slated for removal, so the wire-protocol xfails disappear with the code they cover — they are not waiting on an SDK fix. The Docket/Redis *execution engine* underneath is not discarded: it is extracted into the planned `fastmcp-tasks` package and re-adapted to the SEP-2663 polling shape (see [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks)). The two SDK gaps these were originally filed against — **sdk-feedback #1** (SEP-1686 task result types omitted from the method registries) and **sdk-feedback #3** (no `task` field on `ReadResourceRequestParams` / `GetPromptRequestParams`) — are moot: they patched the SEP-1686 wire shape, which SEP-2663 replaces with a `CreateTaskResult` claimed on `tools/call`. The gap that matters for the rebuild is **sdk-feedback #2** (extensions capability stripped at pre-2026 negotiated versions) — it now gates a flagship feature and is escalated accordingly.
|
||||
|
||||
**Protocol eras (`tests/server/test_protocol_eras.py`).** One remaining strict xfail, and it too is task-related: the v2 SDK high-level client exposes no `task=` parameter on `call_tool`, so a SEP-1686 task-augmented `tools/call` cannot be submitted through it. It resolves with the SEP-1686 wire-layer removal above; the SEP-2663 rebuild submits tasks by advertising the extension capability and claiming a `CreateTaskResult`, not through a `task=` params field. The earlier strict xfail for the `ctx.elicit` / `ctx.sample` "Method not found" degradation (sdk-feedback #10) is **gone** — the era-gating shipped in #4448 flipped it to a passing test.
|
||||
|
||||
**MCP Apps (`tests/test_apps.py`).** Two xfails tied to **sdk-feedback #2** — the `extensions` capability is stripped by the pre-2026 version sieve, so the UI extension can't be advertised to legacy-era clients.
|
||||
|
||||
## Shims and their removal triggers
|
||||
|
||||
Every shim in the migration is temporary and carries a documented removal trigger.
|
||||
|
||||
| Shim | Location | Removal trigger |
|
||||
| --- | --- | --- |
|
||||
| `_sdk_patches.py` — task registry widening | `fastmcp_slim/fastmcp/_sdk_patches.py` | Removed with FastMCP's SEP-1686 wire machinery (`server/tasks/`), which is slated for removal now that the 2025 task protocol left the spec. The SEP-2663 rebuild does not need it — `CreateTaskResult` is claimed on `tools/call` through the extensions mechanism, which the SDK registries already admit. |
|
||||
| `_compat.py` — camelCase field bridge | `fastmcp_slim/fastmcp/_compat.py` | User-migration aid; removed in a future release after users migrate reads to snake_case. Users can preview removal with `mcp_camelcase_compat = False`. |
|
||||
| `FastMCPRequestContext` ContextVar | `fastmcp_slim/fastmcp/server/dependencies.py` | The SDK deliberately passes context as an argument with no ContextVar; FastMCP's public `get_context()` needs ambient access, and the shim also lifts `_meta`, which the SDK's `TypedDict` drops. No planned removal — this is a permanent boundary, not a beta gap. |
|
||||
| `FastMCPServerMiddleware` | `fastmcp_slim/fastmcp/server/low_level.py` | Already the native SDK `ServerMiddleware` path; no cleaner hook exists. Permanent. |
|
||||
| Client `get_session_id` header sniff | `fastmcp_slim/fastmcp/client/transports/http.py` | SDK exposes session id (or an `on_session_created` callback) from `streamable_http_client`, at parity with `sse_client` (sdk-feedback #5). |
|
||||
| `_sdk_context_shim.py` — generic handler aliases | `fastmcp_slim/fastmcp/client/_sdk_context_shim.py` | The SDK's `ClientRequestContext` is not subscriptable, so FastMCP keeps the public generic `SamplingHandler`/`RootsHandler`/`ElicitationHandler` aliases. Permanent unless the SDK makes the context subscriptable (sdk-feedback #7). |
|
||||
|
||||
The `TaskNotificationHandler` binding (sdk-feedback #8) is the client-side equivalent: it registers a `NotificationBinding` for the SEP-1686 `notifications/tasks/status` because the SDK no longer tees custom server notifications to the message handler. It goes away with the SEP-1686 wire machinery it serves; the `fastmcp-tasks` client half registers its own binding for the SEP-2663 `notifications/tasks` shape when it ships (push notifications are deferred to a later `fastmcp-tasks` version — v1 is polling-only).
|
||||
|
||||
## Statelessness on 2026-07-28
|
||||
|
||||
The `2026-07-28` era is stateless by protocol construction, and the recurring maintainer question is whether that statelessness has to be woven through FastMCP everywhere. It does not — but the honest accounting has three parts: features that are legacy-only because the protocol removed the mechanism, features that already work because they never relied on a session, and a short list of design holes where the current code *doesn't error* but also *doesn't work*. Everything below concerns `2026-07-28` connections only. Every client in the field today negotiates a handshake era, where all of this behaves exactly as it always has.
|
||||
|
||||
**The SDK ground truth.** On the modern paths the SDK's `Connection` is strictly per-request: a fresh `Connection` is built from each POST's envelope, its `exit_stack` unwinds when the request returns, `connection.session_id` is always `None`, and `connection.state` is a fresh dict per request. The manager's `stateless` flag never enters the picture — modern routing short-circuits ahead of it. There is no standing server→client stream: notifications emitted *during* a request ride that POST's own SSE sink, and anything emitted after the POST returns is dropped (`_NO_CHANNEL`); server→client *requests* raise `NoBackChannelError`. The only replacement is `subscriptions/listen`, which carries four list-changed / resource-updated event kinds and nothing else — no logging, progress, or task-status events, no resumability, and it is not yet wired into FastMCP. There is no `EventStore` or `Last-Event-ID` on modern paths at all; both belong to the legacy transport.
|
||||
|
||||
### Legacy-only by construction — document, don't build
|
||||
|
||||
These are not bugs. The protocol removed the mechanism they depend on, so they are simply out of scope on `2026-07-28`:
|
||||
|
||||
- **Per-session log levels.** `logging/setLevel` is absent from the 2026 method registry, so the `_client_log_levels` handler is unreachable. There is no per-session log-level state because there is no session.
|
||||
- **`EventStore` / resumability.** `EventStore`, `SessionScopedEventStore`, and Last-Event-ID resumption are never constructed on the modern paths. Resumability presupposes a durable stream, which the era does not have.
|
||||
- **Ping keepalive.** Server-initiated ping is a server→client request and is therefore structurally a no-op on modern connections; the SDK owns SSE-level pings on this transport.
|
||||
|
||||
### Already stateless by construction — works on 2026
|
||||
|
||||
These work on `2026-07-28` today because they never leaned on a protocol session:
|
||||
|
||||
- **`tasks/get` polling.** Task result retrieval is keyed by `task_id` and backed by Docket/Redis, so a client polls across independent requests without any session affinity. This session-free polling is exactly why the execution engine survives the SEP-1686-to-SEP-2663 rework: the SEP-2663 wire shape (poll `tasks/get`, resolve in-task input via `tasks/update`) maps onto the same durable store, and SEP-2663's `Mcp-Name: <taskId>` routing header is moot for a shared-Redis deployment where any replica can serve the poll. See [the xfail register](#the-xfail-register).
|
||||
- **OAuth bearer validation.** Auth is per-request bearer validation — every POST carries and re-validates its own credential.
|
||||
- **In-request progress and logging notifications.** Notifications emitted while a request is still streaming ride that POST's SSE sink and are delivered normally.
|
||||
|
||||
### Design holes deferred to the multi-protocol workstream
|
||||
|
||||
The remaining items are real holes, deferred to the [first-class 2026 client](/development/v4-notes/feature-program#first-class-2026-client) workstream because they all reduce to one unanswered question — *what is a session when the protocol has none?* The danger in each is that the code currently returns without erroring, which reads as "works" but is actually silent degradation. Again: these affect `2026-07-28` connections only; on the handshake eras every one of them behaves correctly.
|
||||
|
||||
- **`ctx.session_id` and `ctx.set_state` / `ctx.get_state` (broken even single-replica).** On a modern request `ctx.session_id` mints a fresh `uuid4`, cached on the per-request `connection.state` that is discarded when the request returns. So `ctx.set_state` and `ctx.get_state` silently never round-trip across requests — no error, just lost data. The open design decision is whether `session_id` should become `None` with `set_state` documented as session-era-only, or be re-based on an app-level key (the auth subject, or a client-supplied header).
|
||||
- **Task push and in-task input — resolved by the SEP-2663 design, not a statelessness hole.** This was previously framed as a hole because SEP-1686 leaned on a push back-channel (the notification/elicitation relay) that dies once the submitting request returns. SEP-2663 removes the dependency: in-task input is *poll-based* — the task enters `input_required`, surfaces its outstanding elicit/sample/roots requests in an `inputRequests` map on `tasks/get`, and the client answers via `tasks/update`. That round-trips through the durable store with no session affinity, so it is stateless-safe by construction. The SEP-1686 push relay (`server/tasks/elicitation.py`, `notifications.py`) is removed; the `fastmcp-tasks` rebuild implements the poll-based channel instead. Foreground (non-task) elicitation on 2026 remains the guard-mode `InputRequiredResult`.
|
||||
- **Stateful proxy affinity (degraded).** The stateful proxy's `_caches` are keyed by the per-request `Connection`, so on modern connections the proxy collapses to stateless proxying: results stay correct, but the per-session affinity guarantee is lost. This is decided alongside the `session_id` question — same root — or gated to the legacy/stdio transports.
|
||||
|
||||
Multi-replica concerns (per-process rate-limiter buckets, shared Redis backends for state and tasks, a Redis `SubscriptionBus`) are deployment configuration rather than protocol gaps and are out of scope for this section.
|
||||
|
||||
## Upstream advisory dossier
|
||||
|
||||
FastMCP acts as an advisor to the SDK team. The migration produced a dossier of ten findings (`sdk-feedback.md`) — verified bugs and hard edges to report upstream, plus questions to bundle into a feedback thread. The highest-priority items:
|
||||
|
||||
- **#1 (bug)** — SEP-1686 task result types ship but the method registries omit them. *Moot: the SEP-1686 wire shape was removed from the spec; the SEP-2663 rebuild claims `CreateTaskResult` on `tools/call` through the extensions mechanism, which the registries already admit.*
|
||||
- **#2 (bug/question)** — `capabilities.extensions` stripped at pre-2026 negotiated versions. **Elevated:** this now gates the `io.modelcontextprotocol/tasks` extension (and MCP Apps) on the modern era, so it blocks a flagship v4 feature rather than an edge case. Worth prioritizing in the upstream thread.
|
||||
- **#4 (security)** — DCR redirect-URI validation accepts `javascript:`/`data:` schemes.
|
||||
- **#5 (hard edge)** — `streamable_http_client` drops session-id access with no replacement.
|
||||
- **#8 (hard edge)** — custom server notifications are dropped, not tee'd to `message_handler`.
|
||||
- **#10 (hard edge)** — 2026 push-feature degradation error quality is inconsistent. *Resolved on the FastMCP side: `ctx.elicit` / `ctx.sample` are era-gated to raise a clear error on modern connections (#4448).*
|
||||
|
||||
Filing is gated on maintainer approval of each issue text.
|
||||
|
||||
Separately, the [SDK delegation round two](/development/v4-notes/feature-program#sdk-delegation-round-two) work depends on **three upstream feature requests** — per-session event-store scoping, a user-middleware injection hook, and a lifespan hook — that would let FastMCP collapse its HTTP builders onto the SDK's and inherit the SDK's session-owner credential enforcement.
|
||||
|
||||
## GA transition checklist
|
||||
|
||||
The beta-to-stable transition is a small set of tracked steps:
|
||||
|
||||
- **Swap the pins.** When `mcp 2.0.0` reaches GA, change `mcp-types==2.0.0b1` (core) and the `mcp` pin (the `[mcp]` extra) in `fastmcp_slim/pyproject.toml` from the beta to the stable release, and cut `4.0.0` instead of another pre-release.
|
||||
- **Re-run the xfail suite against the GA SDK.** Any strict xfail that starts passing means a gap closed — remove the marker and, where applicable, the corresponding shim.
|
||||
- **Confirm `release/3.x`** is cut from pre-merge `main` and receiving upstream security patches for users who stay on the SDK v1 line.
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
---
|
||||
title: 2026-07-28 Protocol Support
|
||||
---
|
||||
|
||||
FastMCP v4 serves the sessionless `2026-07-28` protocol era and the session-based handshake eras from a single server, with per-connection auto-detection. This page catalogs what FastMCP provides for the modern era — both the protocol machinery it inherits from the MCP Python SDK and the capabilities FastMCP implements itself on top of that layer. It is the reference for what a v4 deployment can actually do on the modern protocol today.
|
||||
|
||||
## Identity assertion (SEP-990)
|
||||
|
||||
SEP-990 defines enterprise "on-behalf-of" access: a corporate identity provider (Okta, Microsoft Entra, etc.) issues a signed *ID-JAG* asserting an employee's identity, the employee's agent presents it at the MCP authorization server's token endpoint via the RFC 7523 `jwt-bearer` grant, and receives a short-lived access token — no browser login, no per-user consent screen, and revocation lives at the IdP.
|
||||
|
||||
The protocol layer for this flow — grant parsing, the `exchange_identity_assertion` provider hook, and metadata advertisement — comes from the SDK. The validation and issuance logic that makes the flow actually work is FastMCP's implementation, and enabling it is one parameter on the existing auth providers:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import OAuthProxy, IdentityAssertion
|
||||
|
||||
auth = OAuthProxy(
|
||||
..., # existing upstream configuration unchanged
|
||||
identity_assertion=IdentityAssertion(
|
||||
trusted_issuers=["https://login.acme-corp.com"],
|
||||
),
|
||||
)
|
||||
mcp = FastMCP("Internal API", auth=auth)
|
||||
```
|
||||
|
||||
Behind that one parameter, FastMCP performs the full SEP-990 §5.1 / RFC 7523 §3 processing: JWKS-based signature verification with automatic OIDC discovery of issuer keys, `typ`/`iss`/`aud`/`sub` validation, temporal checks (`exp`, `iat`, `nbf`, maximum assertion lifetime), enforcement of the assertion's signed `client_id` and `resource` bindings, `jti` replay rejection, scope derivation from the signed assertion (client requests can narrow but never widen), short-lived token issuance with no refresh token, and revocation tracking for the issued tokens. The asserted subject flows into the normal FastMCP 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) for the full documentation.
|
||||
|
||||
This slots into FastMCP's existing authorization-server stack — the OAuth proxy's dynamic client registration, the consent flow, and self-issued JWTs — which is what makes a one-parameter enterprise deployment possible.
|
||||
|
||||
## Modern-era capability inventory
|
||||
|
||||
The complete picture of what a FastMCP v4 server and client provide on the `2026-07-28` era:
|
||||
|
||||
| Capability | What FastMCP provides |
|
||||
| --- | --- |
|
||||
| **Dual-era serving** | One server answers both `server/discover` (modern, sessionless) and `initialize` (handshake) connections, auto-detected per connection. Any replica behind a plain load balancer can answer a modern request. |
|
||||
| **Identity assertion (SEP-990)** | Complete server-side implementation, one parameter to enable (above). |
|
||||
| **Authorization server** | Full AS stack: `OAuthProxy` bridges DCR-expecting MCP clients to non-DCR enterprise IdPs, ~18 built-in providers, consent UI, self-issued JWTs, protected-resource metadata (RFC 9728). |
|
||||
| **Cache hints (SEP-2549)** | Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`) stamps every cacheable result; the FastMCP client honors hints with an opt-in response cache. |
|
||||
| **Distributed response caching** | `KeyValueResponseCacheStore` backs the client cache with any key-value store (Redis, memory, filetree), so a fleet of clients or proxy replicas shares cache fills across processes. |
|
||||
| **Resource path security** | Templated resource parameters are screened for traversal, absolute paths, and null bytes before handlers run — on by default, including provider-sourced and mounted templates. |
|
||||
| **Client protocol negotiation** | `Client(mode="auto")` — the default as of v4 — probes `server/discover` and falls back to the classic handshake; the client answers multi-round-trip `input_required` requests through its existing handlers. Pin `mode="legacy"` to force the handshake. |
|
||||
| **Elicitation on the modern protocol (SEP-2322)** | Tools request user input via multi-round trips: a tool returns an `InputRequiredResult` and re-runs per round, reading the client's answers off `ctx.input_responses` / `ctx.request_state` (the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle; the framework seals `request_state` on the wire and unseals it before the tool runs, and a shared-key `request_state_security` policy carries state across replicas. On handshake-era connections returning this result produces a clear era error. |
|
||||
| **Spec-standard errors (SEP-2164)** | Missing-resource reads return `-32602`; push-feature calls on modern connections fail with clear era-specific errors rather than generic method-not-found. |
|
||||
| **Middleware** | Typed per-method hooks (`on_call_tool`, `on_list_tools`, …) and a suite of built-ins (auth, rate limiting, caching, error handling, logging, timing, and more). |
|
||||
| **Composition** | `mount()`, providers, proxying, and tool transforms compose servers dynamically at runtime, with lifespans and middleware driven through the SDK session manager. |
|
||||
| **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. |
|
||||
| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_ENABLE_TELEMETRY=false` disables cleanly. |
|
||||
| **Background tasks (SEP-2663)** | `fastmcp-tasks` implements the `io.modelcontextprotocol/tasks` extension end to end: `mcp.add_extension(TasksExtension())` plus `task=True` runs a tool as a background task, driven by the same Docket engine FastMCP 3 used. A client transparently completes a tasked call; gathering input mid-task uses the same guard pattern as foreground multi-round-trip tools, so a tool is written once and works either way. Modern-protocol only — the `task=True` runtime this replaced (SEP-1686) is gone entirely, not bridged. See [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) for the design and [servers/tasks](/servers/tasks) for usage. |
|
||||
|
||||
## Still in the program
|
||||
|
||||
Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](/development/v4-notes/feature-program), along with the unified `subscriptions/listen` stream. The [Known Gaps](/development/v4-notes/known-gaps) page tracks the upstream dependencies that gate them.
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
# Stateless session state (2026-07-28)
|
||||
|
||||
> Design spec. Status: building.
|
||||
|
||||
## Problem
|
||||
|
||||
The `2026-07-28` era is stateless by protocol construction: each request builds a
|
||||
fresh `Connection`, `connection.session_id` is always `None`, and
|
||||
`connection.state` is a new dict discarded when the request returns. So
|
||||
`ctx.session_id` mints a throwaway `uuid4` per request and `ctx.set_state` /
|
||||
`ctx.get_state` **silently never round-trip** — no error, just lost data. A user
|
||||
who wants cross-call state (a cart, a conversation, accumulated context) has no
|
||||
safe mechanism, and the failure is invisible.
|
||||
|
||||
The one identifier every modern request carries that is stable and
|
||||
**non-spoofable** is the authenticated principal — `get_access_token().claims["sub"]`,
|
||||
or the `(client_id, issuer, subject)` triple. Everything else on the wire is
|
||||
client-declared and forgeable.
|
||||
|
||||
## The model
|
||||
|
||||
State lives **server-side** in the one `AsyncKeyValue` (py-key-value) store the
|
||||
server already holds (`session_state_store`). The framework calls `get`/`put`/
|
||||
`delete` and **never imposes a TTL** — retention is entirely the store's
|
||||
(configure it on the store you pass: a Redis TTL, a py-key-value TTL wrapper,
|
||||
whatever). There is no second store and no framework-owned TTL knob.
|
||||
|
||||
Isolation comes from the **authenticated principal, not from the session id.**
|
||||
State is keyed by `(principal, session_id)`. A request under principal B keys
|
||||
into B's own namespace — it can never address A's keys no matter what
|
||||
`session_id` it passes. The id only organizes sessions *within* a principal. The
|
||||
handle is a bare `uuid4` string; it is **not sealed** — the principal prefix is
|
||||
the wall. Sessions are also create-then-validate (below): an id that was never
|
||||
minted by `create_session` under this principal is rejected outright, not
|
||||
resolved to an empty session.
|
||||
|
||||
## Two explicit patterns
|
||||
|
||||
A tool opts into exactly one, on purpose. There is deliberately **no** optional
|
||||
"id if given, else default" parameter — that would silently misroute a call
|
||||
whose id the agent forgot to pass into the shared per-user bucket, which is the
|
||||
invisible-degradation failure this whole feature exists to remove.
|
||||
|
||||
### Per-user state — injected
|
||||
|
||||
```python
|
||||
from fastmcp.server.sessions import UserSession
|
||||
|
||||
@mcp.tool
|
||||
async def remember(fact: str, session: UserSession) -> str:
|
||||
await session.set("fact", fact)
|
||||
return "noted"
|
||||
```
|
||||
|
||||
`session: UserSession` is **dependency-injected** (like `ctx: Context`): keyed by
|
||||
the request's authenticated principal, not present in the input schema, nothing
|
||||
for the agent to pass. Requires auth — with no principal it raises a clear error.
|
||||
Use it when one bucket per user is what you want. `UserSession` is only the
|
||||
injection annotation — the value the handler receives is an ordinary `Session`,
|
||||
so its `get`/`set`/`delete`/`clear` accessors work as usual.
|
||||
|
||||
### Distinct sessions — an argument
|
||||
|
||||
```python
|
||||
from fastmcp.server.sessions import SessionId
|
||||
from fastmcp.server.dependencies import get_session
|
||||
|
||||
@mcp.tool
|
||||
async def add_to_cart(item: str, session_id: SessionId) -> str:
|
||||
session = await get_session(session_id)
|
||||
cart = await session.get("cart", default=[])
|
||||
cart.append(item)
|
||||
await session.set("cart", cart)
|
||||
return f"{len(cart)} items"
|
||||
```
|
||||
|
||||
`session_id: SessionId` is a **required string argument** — it *is* in the schema,
|
||||
the agent supplies it. `SessionId` is a marker type so the framework
|
||||
auto-populates the argument's description with the protocol:
|
||||
|
||||
> "Session identifier. Use a tool to create a session, then pass the resulting id
|
||||
> here to persist state across calls in the same session."
|
||||
|
||||
The tool becomes self-teaching — an agent reads the schema and learns the
|
||||
create-then-pass contract with no hand-prompting. The description names no
|
||||
specific tool: composition can rename the lifecycle tool (mounting under a
|
||||
namespace exposes it as `child_create_session`), so it points at the
|
||||
*capability* rather than a name that may not exist under that mount.
|
||||
|
||||
The standalone `await get_session(session_id)` resolves the id to a `Session`
|
||||
keyed by `(principal, session_id)`, **validating** that it was created under this
|
||||
principal — an unknown or foreign id raises `InvalidSession` rather than opening a
|
||||
fresh bucket. It is a plain function, not a `Context` method, so it needs no
|
||||
foreground context and works from a `task=True` tool's worker. Use this pattern
|
||||
when a user needs more than one session.
|
||||
|
||||
## The `Session` object
|
||||
|
||||
Async accessors over the server store, scoped to one `(principal, session_id)`:
|
||||
|
||||
- `session.id` — the session's id (set for a `session_id`-resolved session; `None`
|
||||
for an injected `UserSession`, which has no distinct id).
|
||||
- `await session.get(key, default=None)`
|
||||
- `await session.set(key, value)`
|
||||
- `await session.delete(key)`
|
||||
- `await session.clear()` — empties user state but **keeps the session valid**.
|
||||
- `await session.end()` — deletes the session (what `end_session` calls).
|
||||
|
||||
A session's state is stored as a **single dict under one key**
|
||||
(`session:{sha256(principal)}:{session_id}`, and `session:anon:{session_id}` when
|
||||
unauthenticated — the principal is hashed into a fixed-length, delimiter-safe
|
||||
segment, never embedded raw). That dict holds user state in a `state` sub-dict
|
||||
alongside a small `_created` marker, so a created-but-empty session is
|
||||
distinguishable from a missing one even if the store collapses empty dicts.
|
||||
`get`/`set`/`delete` read-modify-write the sub-dict and never touch the marker;
|
||||
`clear` resets the sub-dict but leaves the marker (the session still resolves);
|
||||
`end` deletes the key. Namespacing user state under `state` is what keeps a user
|
||||
key named `_created` from colliding with the marker. One key per session means
|
||||
one TTL per session (the store's), refreshed on write — no key index to maintain,
|
||||
and `end` is a single delete. (Trade-off: concurrent writes to one session race
|
||||
on the read-modify-write; session state is small and typically driven serially by
|
||||
one agent, so this is acceptable — noted, not hidden.)
|
||||
|
||||
## `SessionProvider`
|
||||
|
||||
Session ids are minted by `SessionProvider`, which contributes two tools:
|
||||
|
||||
- `create_session()` → mints an unguessable `uuid4`, **records** the session
|
||||
under the current principal, and returns the id as a string.
|
||||
- `end_session(session_id: SessionId)` → validates the id, then deletes the
|
||||
session so it no longer resolves.
|
||||
|
||||
Register it whenever your tools take a `session_id` — providers are the idiomatic
|
||||
way to add functionality like this:
|
||||
|
||||
```python
|
||||
from fastmcp.server.sessions import SessionProvider
|
||||
|
||||
mcp.add_provider(SessionProvider())
|
||||
```
|
||||
|
||||
There is **no enforcement** that a provider is registered, and there was: an
|
||||
earlier version scanned the tool set at list/resolve time and raised if a
|
||||
`session_id` tool had no provider. That check had to reason about the whole
|
||||
composition pipeline — `isinstance` on providers, unwrapping namespaced ones,
|
||||
tool transforms, session visibility, enabled state — and produced false
|
||||
positives that broke valid servers (a namespaced provider, a session-disabled
|
||||
tool). It was deleted. The guarantee never needed it: `get_session` validates
|
||||
that an id was recorded (create-then-validate), so a server with no provider
|
||||
simply cannot mint ids, and every `get_session` rejects — a misconfiguration
|
||||
caught the first time the tools run, not a security hole.
|
||||
|
||||
`SessionProvider` subclasses `Provider`, takes **no store** (uses the server's)
|
||||
and **no ttl** (the store's). It exists to mint and end owned ids.
|
||||
`create_session` matters most without auth, where an unguessable id is the only
|
||||
defense against a caller *guessing* onto another session.
|
||||
|
||||
When an application already mints its own identifiers — conversation ids, workflow
|
||||
ids — take them as ordinary string arguments rather than `SessionId`, and register
|
||||
no provider; `SessionId` is specifically the create-then-pass contract backed by
|
||||
`create_session`.
|
||||
|
||||
## Security
|
||||
|
||||
Keyed by `(principal, session_id)`:
|
||||
|
||||
- **Authenticated → strong isolation.** `principal` is the validated token
|
||||
subject, unforgeable. B keys into B's namespace; A's data is unreachable no
|
||||
matter what id B passes. Guessing is pointless; a session id appearing in agent
|
||||
context or logs is harmless (it is not a capability without the principal).
|
||||
Caller-chosen ids are safe here.
|
||||
- **Unauthenticated → single-tenant-safe only.** No principal, so the key is just
|
||||
the id in a shared namespace: the id becomes a bearer capability, and exposure
|
||||
in logs/conversation leaks the session. `create_session`'s `uuid4` gives
|
||||
guess-*resistance*, not isolation. Documented in bold: not a tenant boundary;
|
||||
without auth, force minted ids and never treat sessions as a wall between
|
||||
clients.
|
||||
- **Isolation is auth; the id is organization.** No id scheme substitutes for a
|
||||
principal, which is why sealing the handle buys nothing load-bearing and is
|
||||
dropped.
|
||||
- **Not FastMCP's job:** transport (use TLS), encryption at rest (the store's), a
|
||||
malicious *authorized* client acting within its rights.
|
||||
|
||||
## Rework plan (from the current prototype)
|
||||
|
||||
The prototype (`sessions.py`, `context.py`, `function_tool.py`, `server.py`) built
|
||||
a `Scope` enum, a sealed `SessionCodec`, and `ctx.get_state(scope=...)`. Rework to
|
||||
the above:
|
||||
|
||||
1. **Remove `Scope`** and the `scope=` parameter; revert `ctx.get_state`/
|
||||
`set_state` to their original request-scoped behavior.
|
||||
2. **Remove the `SessionCodec`/sealing** — ids are bare `uuid4`.
|
||||
3. **`Session` object** with async `get`/`set`/`delete`/`clear` over the server
|
||||
store, single-dict-per-session key scheme.
|
||||
4. **`session: UserSession`** injection (principal-keyed; error without auth) —
|
||||
wire into the same parameter-detection path as `Context`. `UserSession` is the
|
||||
injection marker; the injected value is a `Session`.
|
||||
5. **`session_id: SessionId`** marker type: string in the schema, auto-filled
|
||||
description, standalone `await get_session(id)` resolver that validates the id
|
||||
(works from a task worker — no foreground context needed).
|
||||
6. **`SessionProvider(Provider)`** with `create_session` (records the session) /
|
||||
`end_session` (deletes it), registered explicitly via `add_provider`. No
|
||||
enforcement that it is present — `get_session`'s validation is the guarantee.
|
||||
7. Rewrite the tests to cover both patterns, principal isolation, no-auth
|
||||
behavior, and `end_session`.
|
||||
|
||||
## Docs plan
|
||||
|
||||
Written against the final API once the rework verifies:
|
||||
|
||||
- A concept guide — why stateless removes the session, the two patterns, when to
|
||||
reach for each. Why before how.
|
||||
- A security page — the two tiers, "isolation is auth, the id is organization,"
|
||||
the bold no-multitenant-without-auth warning.
|
||||
- Fully runnable examples for both patterns (pass the doc-import guard, register
|
||||
in `docs.json`).
|
||||
- A migration note from the old `ctx.session_id` / `set_state`.
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
"dark": "#475569",
|
||||
"light": "#1e3a5f"
|
||||
},
|
||||
"content": "FastMCP 4 is in alpha — 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",
|
||||
|
|
@ -358,10 +358,12 @@
|
|||
"group": "Upgrading",
|
||||
"icon": "up",
|
||||
"pages": [
|
||||
"getting-started/upgrading/from-fastmcp-2",
|
||||
"getting-started/upgrading/from-fastmcp-3",
|
||||
"getting-started/upgrading/from-mcp-sdk",
|
||||
"getting-started/upgrading/from-low-level-sdk"
|
||||
"getting-started/upgrading/from-fastmcp-2",
|
||||
"getting-started/upgrading/from-mcp-sdk-v1",
|
||||
"getting-started/upgrading/from-mcp-sdk-v2",
|
||||
"getting-started/upgrading/from-low-level-sdk-v1",
|
||||
"getting-started/upgrading/from-low-level-sdk-v2"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -372,19 +374,7 @@
|
|||
"development/contributing",
|
||||
"development/tests",
|
||||
"development/releases",
|
||||
"patterns/contrib",
|
||||
{
|
||||
"collapsed": true,
|
||||
"group": "v4 Notes",
|
||||
"pages": [
|
||||
"development/v4-notes/index",
|
||||
"development/v4-notes/change-register",
|
||||
"development/v4-notes/feature-program",
|
||||
"development/v4-notes/background-tasks",
|
||||
"development/v4-notes/protocol-2026",
|
||||
"development/v4-notes/known-gaps"
|
||||
]
|
||||
}
|
||||
"patterns/contrib"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -413,7 +403,7 @@
|
|||
"icon": "code"
|
||||
}
|
||||
],
|
||||
"version": "v4.0.0 (alpha 1)"
|
||||
"version": "v4.0.0 (beta 1)"
|
||||
},
|
||||
{
|
||||
"$ref": "./v3-navigation.json"
|
||||
|
|
@ -424,6 +414,30 @@
|
|||
]
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"destination": "/getting-started/whats-new",
|
||||
"source": "/development/v4-notes/index"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-fastmcp-3",
|
||||
"source": "/development/v4-notes/change-register"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/whats-new",
|
||||
"source": "/development/v4-notes/feature-program"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/whats-new",
|
||||
"source": "/development/v4-notes/protocol-2026"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-fastmcp-3",
|
||||
"source": "/development/v4-notes/known-gaps"
|
||||
},
|
||||
{
|
||||
"destination": "/servers/tasks",
|
||||
"source": "/development/v4-notes/background-tasks"
|
||||
},
|
||||
{
|
||||
"destination": "/apps/fastmcp-app",
|
||||
"source": "/apps/interactive-apps"
|
||||
|
|
@ -497,13 +511,21 @@
|
|||
"source": "/development/upgrade-guide"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-mcp-sdk",
|
||||
"destination": "/getting-started/upgrading/from-mcp-sdk-v1",
|
||||
"source": "/getting-started/upgrading-from-sdk"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-low-level-sdk",
|
||||
"destination": "/getting-started/upgrading/from-mcp-sdk-v1",
|
||||
"source": "/getting-started/upgrading/from-mcp-sdk"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-low-level-sdk-v1",
|
||||
"source": "/getting-started/low-level-sdk"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-low-level-sdk-v1",
|
||||
"source": "/getting-started/upgrading/from-low-level-sdk"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-fastmcp-3",
|
||||
"source": "/getting-started/upgrading/to-mcp-sdk-v2"
|
||||
|
|
|
|||
|
|
@ -68,13 +68,17 @@ See the [Upgrade Guide](/getting-started/upgrading/from-fastmcp-2) for a complet
|
|||
|
||||
### From the MCP SDK
|
||||
|
||||
#### From FastMCP 1.0
|
||||
Which guide you want depends on which `mcp` version you're on and which of its two server APIs you used.
|
||||
|
||||
If you're using FastMCP 1.0 via the `mcp` package (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward — for most servers, it's a single import change. See the [full upgrade guide](/getting-started/upgrading/from-mcp-sdk) for details.
|
||||
#### From the high-level server
|
||||
|
||||
#### From the Low-Level Server API
|
||||
If you're using FastMCP 1.0 via SDK v1 (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward — for most servers it's a single import change. See [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1), which also explains why that route is usually easier than moving to MCP SDK v2.
|
||||
|
||||
If you built your server directly on the `mcp` package's `Server` class — with `list_tools()`/`call_tool()` handlers and hand-written JSON Schema — see the [migration guide](/getting-started/upgrading/from-low-level-sdk) for a full walkthrough.
|
||||
If you already moved to SDK v2 and write against `MCPServer`, see [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2) — that migration is mostly renaming.
|
||||
|
||||
#### From the low-level server
|
||||
|
||||
If you built your server directly on the `mcp` package's `Server` class, the guide you want depends on how its handlers are registered. Decorators like `@server.list_tools()` mean SDK v1 — see [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1). Handlers passed to the constructor as `on_list_tools=` mean SDK v2 — see [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
---
|
||||
title: Upgrading from FastMCP 2
|
||||
sidebarTitle: "From FastMCP 2"
|
||||
description: Migration instructions for upgrading between FastMCP versions
|
||||
description: What changed in FastMCP 3 for servers written against FastMCP 2
|
||||
icon: up
|
||||
---
|
||||
|
||||
This guide covers breaking changes and migration steps when upgrading FastMCP.
|
||||
This guide covers the breaking changes a FastMCP 2 server meets on its way to FastMCP 3, newest release first.
|
||||
|
||||
<Note>
|
||||
**Going all the way to FastMCP 4?** You need this page and [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3), in that order. The two describe different transitions: this one covers the v3 API changes, while the FastMCP 3 guide covers the MCP Python SDK v2 rebuild underneath v4. Where a v3 deprecation was later removed outright, this page marks it **Removed in v4**.
|
||||
</Note>
|
||||
|
||||
## v3.0.0
|
||||
|
||||
|
|
@ -101,7 +105,7 @@ For each issue found, show the original line, explain why it breaks, and provide
|
|||
|
||||
In v2, you could configure transport settings directly in the `FastMCP()` constructor. In v3, `FastMCP()` is purely about your server's identity and behavior — transport configuration happens when you actually start serving. Passing any of the old kwargs now raises `TypeError` with a migration hint.
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
mcp = FastMCP("server", host="0.0.0.0", port=8080)
|
||||
mcp.run()
|
||||
|
|
@ -140,7 +144,7 @@ Keeping `DiskStore` requires `pip install 'py-key-value-aio[disk]'`, which re-in
|
|||
|
||||
In v2, you could enable or disable individual components by calling methods on the component object itself. In v3, visibility is controlled through the server (or provider), which lets you target components by name, tag, or type without needing a reference to the object:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
tool = await server.get_tool("my_tool")
|
||||
tool.disable()
|
||||
|
|
@ -155,7 +159,7 @@ Calling `.enable()` or `.disable()` on a component object now raises `NotImpleme
|
|||
|
||||
The `get_tools()`, `get_resources()`, `get_prompts()`, and `get_resource_templates()` methods have been renamed to `list_tools()`, `list_resources()`, `list_prompts()`, and `list_resource_templates()`. More importantly, they now return lists instead of dicts — so code that indexes by name needs to change:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
tools = await server.get_tools()
|
||||
tool = tools["my_tool"]
|
||||
|
|
@ -169,7 +173,7 @@ tool = next((t for t in tools if t.name == "my_tool"), None)
|
|||
|
||||
Prompt functions now use FastMCP's `Message` class instead of `mcp.types.PromptMessage`. The new class is simpler — it accepts a plain string and defaults to `role="user"`, so most prompts become one-liners:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
from mcp.types import PromptMessage, TextContent
|
||||
|
||||
|
|
@ -187,7 +191,7 @@ def my_prompt() -> Message:
|
|||
|
||||
If your prompt functions return raw dicts with `role` and `content` keys, those also need to change. v2 silently coerced dicts into prompt messages, but v3 requires typed `Message` objects (or plain strings for single user messages):
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before (v2 accepted this)
|
||||
@mcp.prompt
|
||||
def my_prompt():
|
||||
|
|
@ -211,7 +215,7 @@ def my_prompt() -> list[Message]:
|
|||
|
||||
`ctx.set_state()` and `ctx.get_state()` are now async because state in v3 is session-scoped and backed by a pluggable storage backend (rather than a simple dict). This means state persists across multiple tool calls within the same session:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
ctx.set_state("key", "value")
|
||||
value = ctx.get_state("key")
|
||||
|
|
@ -223,7 +227,7 @@ value = await ctx.get_state("key")
|
|||
|
||||
State values must also be JSON-serializable by default (dicts, lists, strings, numbers, etc.). If you need to store non-serializable values like an HTTP client, pass `serializable=False` — these values are request-scoped and only available during the current tool call:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
await ctx.set_state("client", my_http_client, serializable=False)
|
||||
```
|
||||
|
||||
|
|
@ -245,7 +249,7 @@ parent.mount(child, namespace="child")
|
|||
|
||||
In v2, auth providers like `GitHubProvider` could auto-load configuration from environment variables with a `FASTMCP_SERVER_AUTH_*` prefix. This magic has been removed — pass values explicitly:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before (v2) — client_id and client_secret loaded automatically
|
||||
# from FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID, etc.
|
||||
auth = GitHubProvider()
|
||||
|
|
@ -278,7 +282,7 @@ transport = StreamableHttpTransport("http://localhost:8000/mcp")
|
|||
|
||||
`OpenAPIProvider` no longer accepts a `timeout` parameter. Configure timeout on the httpx2 client directly. The `client` parameter is also now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
provider = OpenAPIProvider(spec, client, timeout=60)
|
||||
|
||||
|
|
@ -291,7 +295,7 @@ provider = OpenAPIProvider(spec, client)
|
|||
|
||||
The FastMCP metadata key in component `meta` dicts changed from `_fastmcp` to `fastmcp`. If you read metadata from tool or resource objects, update the key:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
tags = tool.meta.get("_fastmcp", {}).get("tags", [])
|
||||
|
||||
|
|
@ -309,7 +313,7 @@ Metadata is now always included — the `include_fastmcp_meta` parameter has bee
|
|||
|
||||
In v2, `@mcp.tool` transformed your function into a `FunctionTool` object. In v3, decorators return your original function unchanged — which means decorated functions stay callable for testing, reuse, and composition:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
|
@ -335,7 +339,7 @@ These were deprecated in v3. Items marked **Removed in v4** no longer work at al
|
|||
|
||||
**mount() prefix → namespace** (Removed in v4)
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Removed in v4
|
||||
main.mount(subserver, prefix="api")
|
||||
|
||||
|
|
@ -345,7 +349,7 @@ main.mount(subserver, namespace="api")
|
|||
|
||||
**import_server() → mount()** (Removed in v4)
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Removed in v4
|
||||
main.import_server(subserver)
|
||||
|
||||
|
|
@ -382,7 +386,7 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)])
|
|||
|
||||
**add_tool_transformation() → add_transform()** (Removed in v4)
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Removed in v4
|
||||
mcp.add_tool_transformation("name", config)
|
||||
|
||||
|
|
@ -395,7 +399,7 @@ mcp.add_transform(ToolTransform({"name": config}))
|
|||
|
||||
The proxy target is passed positionally in both APIs, so most calls migrate unchanged. If you passed the target by keyword, note that the parameter was renamed from `backend=` to `target=`.
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Removed in v4
|
||||
proxy = FastMCP.as_proxy("http://example.com/mcp")
|
||||
proxy = FastMCP.as_proxy(backend="http://example.com/mcp") # keyword form
|
||||
|
|
@ -424,12 +428,18 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)])
|
|||
|
||||
### Removed Deprecated Features
|
||||
|
||||
- `BearerAuthProvider` → use `JWTVerifier`
|
||||
- `Context.get_http_request()` → use `get_http_request()` from dependencies
|
||||
- `from fastmcp import Image` → use `from fastmcp.utilities.types import Image`
|
||||
- `FastMCP(dependencies=[...])` → use `fastmcp.json` configuration
|
||||
- `FastMCPProxy(client=...)` → use `client_factory=lambda: ...`
|
||||
- `output_schema=False` → use `output_schema=None`
|
||||
A batch of long-deprecated surfaces came out in 2.14. Each fails loudly at import or call time, and each has a direct replacement:
|
||||
|
||||
| Removed | Replacement |
|
||||
|---|---|
|
||||
| `BearerAuthProvider` | `JWTVerifier` — the same JWT validation under a name that says what it does |
|
||||
| `Context.get_http_request()` | `get_http_request()` from [dependency injection](/servers/dependency-injection) |
|
||||
| `from fastmcp import Image` | `from fastmcp.utilities.types import Image` |
|
||||
| `FastMCP(dependencies=[...])` | a [`fastmcp.json`](/deployment/server-configuration) configuration file |
|
||||
| `FastMCPProxy(client=...)` | `client_factory=lambda: ...` |
|
||||
| `output_schema=False` | `output_schema=None` |
|
||||
|
||||
Two of these are worth understanding rather than just swapping. `FastMCPProxy` takes a factory instead of a client because a single shared client cannot serve concurrent proxied sessions safely — the factory gives each session its own backend connection. And `output_schema=False` became `output_schema=None` because `False` read as "this tool has a schema, and it is false"; `None` says plainly that there is no schema.
|
||||
|
||||
## v2.13.0
|
||||
|
||||
|
|
@ -437,7 +447,7 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)])
|
|||
|
||||
The OAuth proxy now issues its own JWT tokens. For production, provide explicit keys:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
auth = GitHubProvider(
|
||||
client_id=os.environ["GITHUB_CLIENT_ID"],
|
||||
client_secret=os.environ["GITHUB_CLIENT_SECRET"],
|
||||
|
|
|
|||
|
|
@ -1,52 +1,121 @@
|
|||
---
|
||||
title: Upgrading from FastMCP 3
|
||||
sidebarTitle: "From FastMCP 3.x"
|
||||
sidebarTitle: "From FastMCP 3"
|
||||
description: What changes when you upgrade to FastMCP 4, which builds on the MCP Python SDK v2
|
||||
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 splits the protocol types out of `mcp.types` into a standalone `mcp_types` package, 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. The sections below describe what FastMCP handles for you, the small number of changes you must make in your own code, and the deprecation timeline for the compatibility shims.
|
||||
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.
|
||||
|
||||
## Install the v4 prerelease
|
||||
The sections below cover what FastMCP handles for you, the changes you must make in your own code, the surfaces removed outright in 4.0, the behavior shifts that compile fine but act differently, and the deprecation timeline for the compatibility shims.
|
||||
|
||||
While FastMCP 4 is in prerelease, pin the alpha and its prerelease protocol dependencies explicitly. For a uv project, add the following to `pyproject.toml`:
|
||||
## Install the v4 Prerelease
|
||||
|
||||
While FastMCP 4 is in prerelease, pin the beta explicitly. The `fastmcp` package is a thin wrapper that depends on `fastmcp-slim` at the same version, so asking for a prerelease of one means asking for a prerelease of the other. pip infers that on its own:
|
||||
|
||||
```bash
|
||||
pip install "fastmcp==4.0.0b1"
|
||||
```
|
||||
|
||||
uv is stricter: it allows prereleases only for packages you name, and `fastmcp-slim` arrives transitively. Constrain it alongside the requirement in `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
dependencies = ["fastmcp==4.0.0a1"]
|
||||
dependencies = ["fastmcp==4.0.0b1"]
|
||||
|
||||
[tool.uv]
|
||||
constraint-dependencies = [
|
||||
"fastmcp-slim==4.0.0a1",
|
||||
"mcp==2.0.0b2",
|
||||
"mcp-types==2.0.0b2",
|
||||
]
|
||||
constraint-dependencies = ["fastmcp-slim==4.0.0b1"]
|
||||
```
|
||||
|
||||
Then run `uv lock` or `uv sync` normally. The constraints opt only these transitive packages into their prerelease versions; you do not need `--prerelease allow`, which permits prereleases throughout the dependency graph.
|
||||
Then run `uv lock` or `uv sync` normally. Naming the one package keeps the rest of your graph on stable releases, where `--prerelease allow` would opt every dependency into prereleases. The MCP SDK needs no constraint at all now that it ships stable releases — pinning `mcp==2.0.0b2` here would in fact break the resolution, since a prerelease does not satisfy FastMCP's own `mcp>=2.0.0` requirement.
|
||||
|
||||
## Environment requirements
|
||||
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
|
||||
You are upgrading an MCP server or client from FastMCP 3.x to FastMCP 4, which is built on the MCP Python SDK v2.
|
||||
|
||||
FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3 — it explains every item below, with the replacement code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs.
|
||||
|
||||
Then search the provided code for each signal below. Most FastMCP 3 servers upgrade untouched, so report only what you actually find.
|
||||
|
||||
ENVIRONMENT
|
||||
- a pydantic pin below 2.12
|
||||
- a FastAPI pin below 0.133.0, the first release admitting Starlette 1.x (earlier ones cap it, e.g. 0.115.12 requires `starlette<0.47.0`), or any direct Starlette pin below 1.0.1
|
||||
|
||||
IMPORTS THAT NO LONGER RESOLVE
|
||||
- `fastmcp.server.proxy`, `fastmcp.server.openapi`, `FastMCPOpenAPI`
|
||||
- `fastmcp.experimental.server.openapi`, `fastmcp.experimental.utilities.openapi`
|
||||
- `fastmcp.experimental.sampling.handlers`
|
||||
- `fastmcp.server.apps`, `fastmcp.server.app`
|
||||
- `fastmcp.tools.tool`, `fastmcp.resources.resource`, `fastmcp.prompts.prompt`
|
||||
- `fastmcp.server.tasks`, `fastmcp.server.sampling`
|
||||
- `fastmcp.server.auth.authorization`
|
||||
- `CurrentDocket` or `CurrentWorker` from `fastmcp.dependencies`
|
||||
- `SkillsProvider`
|
||||
- `CachableToolResult`, `CachablePromptResult`, and their siblings (the misspelling was corrected with no alias)
|
||||
- `PromptToolMiddleware`, `ResourceToolMiddleware`
|
||||
|
||||
REMOVED SERVER METHODS AND KEYWORDS
|
||||
- `FastMCP.as_proxy(...)`
|
||||
- `import_server(...)` ← flag this one loudly: `mount()` is the replacement but NOT an equivalent. `import_server` took a static snapshot and skipped the child's lifespan and middleware; `mount` is a live composition that runs both.
|
||||
- `mount(prefix=...)`, `mount(as_proxy=...)`
|
||||
- `add_tool_transformation(...)`, `remove_tool_transformation(...)`
|
||||
- `remove_tool(...)` ← its replacement raises KeyError where this raised NotFoundError, so check surrounding except clauses
|
||||
- tool `serializer=`, tool `exclude_args=`
|
||||
- `StreamableHttpTransport(sse_read_timeout=...)`
|
||||
- `FASTMCP_DECORATOR_MODE` / `settings.decorator_mode`
|
||||
- `FastMCP(sampling_handler=...)`, `sampling_handler_behavior=`
|
||||
|
||||
REMOVED CONTEXT METHODS
|
||||
- `ctx.sample(...)`, `ctx.sample_step(...)`, `ctx.list_roots(...)`
|
||||
- Note for the user: if borrowing the CALLER's model is the whole point of the server, the guide's recommendation is to stay on FastMCP 3.x rather than migrate.
|
||||
- The client side is NOT affected — `Client(sampling_handler=...)` and `Client(roots=...)` still mean what they meant.
|
||||
|
||||
RUNTIME BREAKS THAT STILL COMPILE — the ones most likely to reach production
|
||||
- `ctx.elicit(...)` anywhere. It is era-gated in 4.0 and raises on modern connections, which is what `Client` now negotiates by default. This is the single most likely runtime failure.
|
||||
- `ctx.elicit(...)` called without `response_type`
|
||||
- `except httpx.` around any FastMCP call. FastMCP raises httpx2 exceptions now, but httpx is usually still installed transitively, so the handler imports, type-checks, and silently never matches.
|
||||
- a custom `httpx.AsyncClient`, `httpx_client_factory=`, or `httpx.Auth` handed to a FastMCP transport, `OAuth`, or `from_openapi`
|
||||
- `Middleware.on_initialize` hooks, and `ctx.set_state` values read back in a later call — neither survives a modern connection
|
||||
- middleware assuming `on_message` only sees routable requests
|
||||
- camelCase field reads (`inputSchema`, `isError`, `mimeType`, `nextCursor`, `structuredContent`, `serverInfo`, and the rest) — these still work but warn, and are scheduled for removal
|
||||
- clients matching on the resource-not-found error code -32002
|
||||
- templated resources whose parameters legitimately carry `..` or absolute paths
|
||||
- an OAuth server (`OAuthProxy` or anything built on it) with `issuer_url` set to something other than `base_url` — this forces a one-time re-authorization of every client
|
||||
|
||||
BACKGROUND TASKS
|
||||
- `@mcp.tool(task=True)` or `TaskConfig` without `mcp.add_extension(TasksExtension())`
|
||||
- `task=` on a `@mcp.resource` or `@mcp.prompt` decorator (tools only now)
|
||||
- `client.call_tool(..., task=True)`, `read_resource(task=True)`, `get_prompt(task=True)`
|
||||
|
||||
ERRORS
|
||||
- `McpError(ErrorData(...))` positional construction. Catching and `err.error.code` are unchanged; only construction moved.
|
||||
|
||||
For each item found, show the original line, name what changed, and give the corrected code from the guide. Where you could not confirm a replacement in the docs, say so instead of guessing.
|
||||
</Prompt>
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
The SDK v2 raises FastMCP's dependency floors, which matters before any of your code runs.
|
||||
|
||||
**pydantic >= 2.12 is now the floor.** If your project pins an older pydantic (for example `pydantic==2.11.*`), installing this FastMCP release fails with an unsatisfiable-resolution error from your installer — bump your pin to `>=2.12` first. If you don't pin pydantic at all, installers upgrade it silently as part of the FastMCP upgrade.
|
||||
|
||||
**The server extra floors Starlette >= 1.0.1.** Modern FastAPI (0.11x and later) already runs on Starlette 1.x, so mounting a FastMCP server inside a FastAPI app coexists cleanly — verified with FastAPI 0.138.2. Only very old FastAPI versions pinned below Starlette 1.0.1 conflict; upgrade FastAPI if your resolver complains about Starlette.
|
||||
**The server extra floors Starlette >= 1.0.1.** This is the requirement most likely to force an unrelated upgrade, because FastAPI pinned Starlette to a sub-1.0 range for a long time — FastAPI 0.115.12, for example, requires `starlette<0.47.0`. **FastAPI 0.133.0 is the first release that admits Starlette 1.x**, so a project pinned below that gets an unsatisfiable resolution rather than a version bump. Raise your FastAPI pin to `>=0.133.0` before upgrading FastMCP. Mounting a FastMCP server inside a FastAPI app is otherwise unaffected — verified against FastAPI 0.135.2 on Starlette 1.3.1.
|
||||
|
||||
## What FastMCP absorbs
|
||||
## What FastMCP Absorbs
|
||||
|
||||
### Legacy camelCase field access keeps working
|
||||
### camelCase Field Access
|
||||
|
||||
Objects that FastMCP hands back to you — the results of `client.list_tools()`, `client.call_tool_mcp()`, `client.read_resource()`, and the parameter objects passed to your sampling and elicitation handlers — are SDK v2 objects with snake_case fields. FastMCP installs a compatibility bridge at import time that routes the old camelCase names to their new snake_case fields, so code written against FastMCP 2.x still reads correctly:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
async with Client("my_mcp_server.py") as client:
|
||||
tools = await client.list_tools()
|
||||
schema = tools[0].inputSchema # still works, warns once
|
||||
|
||||
async def read_schema():
|
||||
async with Client("my_mcp_server.py") as client:
|
||||
tools = await client.list_tools()
|
||||
return tools[0].inputSchema # still works, warns once
|
||||
```
|
||||
|
||||
Each bridged read emits a `FastMCPDeprecationWarning` pointing you at the snake_case name (`tools[0].input_schema` here). The bridge covers the fields users actually read: `inputSchema`/`outputSchema` on tools; `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` on tool annotations; `mimeType` on resources and content; `isError`/`structuredContent` on tool results; `nextCursor` on paginated results; `serverInfo`/`protocolVersion` on the initialize result; the sampling parameter fields (`systemPrompt`, `maxTokens`, `stopSequences`, `modelPreferences`, `toolChoice`); and `requestedSchema` on elicitation parameters.
|
||||
|
|
@ -61,17 +130,19 @@ fastmcp.settings.mcp_camelcase_compat = False
|
|||
|
||||
See [Settings](/more/settings) for the full reference.
|
||||
|
||||
### Protocol types moved to `mcp_types`
|
||||
### Protocol Types
|
||||
|
||||
The `mcp.types` module no longer exists. Every protocol type — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Icon`, `PromptMessage`, `SamplingMessage`, `ToolAnnotations`, notification and request wrapper types like `ToolListChangedNotification`, and everything else — now lives in the standalone `mcp_types` package. Update your imports to point there:
|
||||
Every protocol type — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Icon`, `PromptMessage`, `SamplingMessage`, `ToolAnnotations`, notification and request wrapper types like `ToolListChangedNotification`, and everything else — now lives in a standalone `mcp_types` package. The SDK re-exports that package as `mcp.types`, so existing imports keep working and stay the preferred spelling:
|
||||
|
||||
```python
|
||||
from mcp_types import TextContent, Tool, ToolAnnotations
|
||||
from mcp.types import TextContent, Tool, ToolAnnotations
|
||||
```
|
||||
|
||||
Both names resolve to the same objects, so `from mcp_types import X` is equally valid — useful if you depend on the types without the rest of the SDK. What did change is the fields on those types: they are snake_case now (`input_schema`, not `inputSchema`), which the [compatibility bridge](#legacy-camelcase-field-access-keeps-working) covers for the objects FastMCP hands you.
|
||||
|
||||
`fastmcp.types` still exists, but holds only types FastMCP defines itself (currently just `Textarea`, used to render a multiline textarea in form-based UIs) — it does not re-export protocol types.
|
||||
|
||||
### `McpError` has an alias
|
||||
### The `McpError` Alias
|
||||
|
||||
`fastmcp.exceptions.McpError` is an alias of the SDK's `MCPError`. Catching errors is unchanged — `except McpError` still catches SDK-raised errors, and reading `err.error.code` still works:
|
||||
|
||||
|
|
@ -84,7 +155,7 @@ except McpError as err:
|
|||
print(err.error.code)
|
||||
```
|
||||
|
||||
### Behavior preserved across the SDK boundary
|
||||
### Preserved Behavior
|
||||
|
||||
A few client behaviors that touch the SDK are preserved so you don't have to change anything:
|
||||
|
||||
|
|
@ -92,17 +163,9 @@ A few client behaviors that touch the SDK are preserved so you don't have to cha
|
|||
- `client.ping()` returns a `bool`.
|
||||
- `client.transport.get_session_id()` returns `None` on protocol eras that have no session, rather than raising. (The SDK v2 removed session-id access from its streamable HTTP transport; FastMCP reconstructs it on the transport object.)
|
||||
|
||||
## What you must change
|
||||
## What You Must Change
|
||||
|
||||
Everything above, FastMCP handled for you. What remains lives in your own code, where FastMCP can't reach it — your imports, how you construct errors, the custom HTTP clients you hand to a transport, and any place you reach past FastMCP's surfaces into the raw SDK objects. Each surfaces as a clear failure at import or call time, and each is a mechanical fix.
|
||||
|
||||
**Your own `mcp.types` imports.** FastMCP can re-export types, but it can't rewrite imports in your code. Any `from mcp.types import X` or `import mcp.types` in your server or client fails at import time with:
|
||||
|
||||
```
|
||||
ModuleNotFoundError: No module named 'mcp.types'
|
||||
```
|
||||
|
||||
The raw message gives no hint toward the fix, so if you see it after upgrading, this is why. Switch to `from mcp_types import X`.
|
||||
Everything above, FastMCP handled for you. What remains lives in your own code, where FastMCP can't reach it — how you construct errors, the custom HTTP clients you hand to a transport, and any place you reach past FastMCP's surfaces into the raw SDK objects. Each surfaces as a clear failure at import or call time, and each is a mechanical fix.
|
||||
|
||||
**`McpError` construction.** The v1 pattern of wrapping an `ErrorData` and passing it positionally fails under SDK v2 with:
|
||||
|
||||
|
|
@ -112,7 +175,7 @@ TypeError: MCPError.__init__() missing 1 required positional argument: 'message'
|
|||
|
||||
Note the message prints the class as `MCPError` (uppercase) even though your code wrote `McpError` — the old name is an alias for the SDK's renamed class. Construct the error with keyword arguments instead:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
from fastmcp.exceptions import McpError
|
||||
|
||||
# Before (raises TypeError under SDK v2):
|
||||
|
|
@ -128,7 +191,7 @@ Catching and `err.error.code` are unchanged — only construction moved.
|
|||
|
||||
**FastMCP now uses httpx2 exclusively.** FastMCP has replaced `httpx` with [httpx2](https://pypi.org/project/httpx2/), a next-generation httpx fork, across its entire HTTP stack — client transports and every server-side path (auth providers, the OpenAPI integration, the version check). `httpx` is no longer a FastMCP dependency. If you pass a custom client or factory into a FastMCP client transport — `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, `OAuth(httpx_client_factory=...)`, or a custom `httpx.Auth` as `Client(auth=...)` — those objects must now be httpx2. httpx2 is a drop-in fork with the same public API, so the change is an import swap:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
import httpx
|
||||
|
||||
|
|
@ -153,10 +216,12 @@ The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvide
|
|||
```python
|
||||
import httpx # still installed transitively — this import works
|
||||
|
||||
try:
|
||||
result = await client.call_tool("fetch", {"url": url})
|
||||
except httpx.ConnectError: # dead code: FastMCP now raises httpx2.ConnectError
|
||||
return fallback()
|
||||
|
||||
async def fetch(client, url):
|
||||
try:
|
||||
return await client.call_tool("fetch", {"url": url})
|
||||
except httpx.ConnectError: # dead code: FastMCP now raises httpx2.ConnectError
|
||||
return fallback()
|
||||
```
|
||||
|
||||
Grep your codebase for `except httpx.` and move those handlers to `httpx2`. The exception hierarchies match name-for-name, so the fix is an import swap — the hard part is remembering to look. One place you are covered automatically: exceptions raised *inside your tools and resources* (for example, a tool whose own old-httpx call gets a 429) are still mapped to `ToolError`/`ResourceError` by FastMCP's error boundary, which recognizes both libraries' exceptions during the transition.
|
||||
|
|
@ -167,7 +232,7 @@ Two runtime behaviors shift with httpx2, and because the switch is now wholesale
|
|||
|
||||
Deprecations that warned throughout the 3.x line are removed in 4.0. Unlike the bridged changes above, these fail immediately at the call site — a `ModuleNotFoundError`, `ImportError`, `AttributeError`, or `TypeError` — so nothing degrades silently. Every one has a direct replacement, and the fix is mechanical.
|
||||
|
||||
### Moved imports
|
||||
### Moved Imports
|
||||
|
||||
The proxy, OpenAPI, and app integrations moved to their permanent homes, and the internal component classes are no longer re-exported from their old aliases:
|
||||
|
||||
|
|
@ -178,13 +243,23 @@ The proxy, OpenAPI, and app integrations moved to their permanent homes, and the
|
|||
| `fastmcp.experimental.server.openapi` | `fastmcp.server.providers.openapi` |
|
||||
| `fastmcp.experimental.utilities.openapi` | `fastmcp.utilities.openapi` |
|
||||
| `fastmcp.server.apps`, `fastmcp.server.app` | `fastmcp.apps` (e.g. `AppConfig`) or `fastmcp` (`FastMCPApp`) |
|
||||
| `Tool` / `ToolResult` from `fastmcp.tools.tool` | `fastmcp.tools` |
|
||||
| `Resource` from `fastmcp.resources.resource` | `fastmcp.resources` |
|
||||
| `Prompt` / `Message` from `fastmcp.prompts.prompt` | `fastmcp.prompts` |
|
||||
| `FunctionTool` / `ParsedFunction` / `tool` from `fastmcp.tools.tool` | `fastmcp.tools.function_tool` |
|
||||
| `FunctionResource` / `resource` from `fastmcp.resources.resource` | `fastmcp.resources.function_resource` |
|
||||
| `FunctionPrompt` / `prompt` from `fastmcp.prompts.prompt` | `fastmcp.prompts.function_prompt` |
|
||||
| `OpenAISamplingHandler` from `fastmcp.experimental.sampling.handlers` | `fastmcp.client.sampling.handlers.openai` |
|
||||
| `AuthCheck` / `AuthContext` / `require_scopes` / `require_roles` / `restrict_tag` / `run_auth_checks` from `fastmcp.server.auth.authorization` | `fastmcp.server.auth` |
|
||||
| `run_auth_checks_with_shortfall` / `scope_requirements` from `fastmcp.server.auth.authorization` | `fastmcp.utilities.authorization` |
|
||||
| `SkillsProvider` | `SkillsDirectoryProvider` from `fastmcp.server.providers.skills` |
|
||||
| `TaskConfig` from `fastmcp.server.tasks` | `fastmcp.utilities.tasks` |
|
||||
| `CurrentDocket` / `CurrentWorker` from `fastmcp.dependencies` | `fastmcp_tasks.dependencies` |
|
||||
| `fastmcp.server.sampling` (and `SamplingTool`) | removed with [server-side sampling](#protocol-version-support) |
|
||||
|
||||
Two renames in the same family are worth calling out because they have no compatibility alias. The response-caching wrapper models lost a spelling typo — `CachableToolResult`, `CachablePromptResult`, and their siblings became `CacheableToolResult`, `CacheablePromptResult`, etc. — so an import of the old spelling from `fastmcp.server.middleware.caching` raises `ImportError`. And `PromptToolMiddleware` / `ResourceToolMiddleware` are gone in favor of the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` (the `ToolInjectionMiddleware` base class is retained).
|
||||
|
||||
### Removed server methods and `mount()` keywords
|
||||
### Removed Server Methods
|
||||
|
||||
These `FastMCP` methods and keywords have warned since 3.0 and are now removed:
|
||||
|
||||
|
|
@ -204,72 +279,177 @@ Two of these replacements are not exact behavioral swaps. `create_proxy` takes i
|
|||
|
||||
`import_server` → `mount` is the one row here that is not a mechanical swap, because the two never had the same semantics. `import_server` took a **one-time static snapshot** — it copied the child's tools, resources, and prompts at call time, with no live link, and did not run the child's lifespan or middleware. `mount` is a **live composition** — it holds a live link to the child and runs the child's lifespan and middleware. After switching, later changes to the child become visible through the parent, the child's lifespan runs with the parent's (entered when the server starts, held until it stops — not per request), and the child's middleware runs on the operations delegated to it. If you depended on the frozen-copy behavior (a stable snapshot, no child lifecycle), there is no drop-in replacement: register the child's components on the parent directly instead of composing the two servers.
|
||||
|
||||
### Removed tool and decorator parameters
|
||||
### Removed Parameters
|
||||
|
||||
Two `@tool` parameters and two settings are gone:
|
||||
Several parameters and settings that warned in 3.x are gone:
|
||||
|
||||
- **Tool `serializer=`** is removed from `@tool` / `mcp.tool()`, `Tool.from_function`, `Tool.from_tool`, and the OpenAPI tool. Return a `ToolResult` from your tool for full control over serialization instead.
|
||||
- **Tool `exclude_args=`** is removed. Hide a parameter from the tool schema by injecting it instead: give it a `Depends(factory)` default (from `fastmcp.dependencies`), where `factory` is a callable returning the value the argument used to carry. An injected parameter never appears in the tool's schema, which is what `exclude_args` was for.
|
||||
- **The `decorator_mode` setting** (`FASTMCP_DECORATOR_MODE`) and its `"object"` mode are removed. Decorators always return your original function with metadata attached; reach the component object through the server (`await mcp.get_tool("name")`) rather than off the decorated function.
|
||||
- **`StreamableHttpTransport(sse_read_timeout=...)`** is removed — it was a no-op under the SDK v2 client. Set the read timeout through the public `Client(transport, timeout=...)` (a `timedelta` or float seconds), or reach for a custom `httpx_client_factory` when you need finer control. (`SSETransport` still accepts `sse_read_timeout`.)
|
||||
- **`ctx.elicit()` now requires `response_type`.** Omitting it (or passing `None`) has warned since 3.2 and now raises `TypeError`. The empty-object schema it produced gave clients nothing to render, and some showed an empty, non-functional form. Pass a type describing what you expect back — `bool` is the right answer for a confirmation:
|
||||
|
||||
## Behavior changes to verify
|
||||
```python test="skip"
|
||||
# Before
|
||||
result = await ctx.elicit("Approve this action?")
|
||||
|
||||
Two server-side behaviors changed in ways that compile fine but can surface at runtime.
|
||||
# After
|
||||
result = await ctx.elicit("Approve this action?", response_type=bool)
|
||||
```
|
||||
|
||||
This is the server-authoring API only. Client elicitation handlers still receive `response_type=None` for URL requests and for empty schemas sent by other servers — that contract is unchanged.
|
||||
|
||||
### Background Tasks
|
||||
|
||||
Background tasks left the core MCP spec during the SDK v2 rebuild and came back as the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP follows the protocol: what was a built-in server feature in 3.x is now a registered extension, and the authoring surface changed on both sides of the connection.
|
||||
|
||||
The extension ships in a separate package, so the pin from [Install the v4 Prerelease](#install-the-v4-prerelease) needs one more entry before any of this imports:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
dependencies = ["fastmcp[tasks]==4.0.0b1"]
|
||||
|
||||
[tool.uv]
|
||||
constraint-dependencies = [
|
||||
"fastmcp-slim==4.0.0b1",
|
||||
"fastmcp-tasks==4.0.0b1",
|
||||
"mcp==2.0.0b2",
|
||||
"mcp-types==2.0.0b2",
|
||||
]
|
||||
```
|
||||
|
||||
On the server, `task=True` still marks a tool as capable of running in the background, but it no longer runs anything by itself — the extension does. Register it, or the server refuses to start:
|
||||
|
||||
```python
|
||||
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."""
|
||||
return "done"
|
||||
```
|
||||
|
||||
Without the registration, a `task=True` tool raises at startup rather than the first time a client calls the tool:
|
||||
|
||||
```
|
||||
RuntimeError: Task-enabled tools (slow_computation) require the tasks extension,
|
||||
but no extension with identifier 'io.modelcontextprotocol/tasks' is registered.
|
||||
```
|
||||
|
||||
`TaskConfig` moved from `fastmcp.server.tasks` to `fastmcp.utilities.tasks`, and the `CurrentDocket` and `CurrentWorker` dependencies moved to `fastmcp_tasks.dependencies`.
|
||||
|
||||
`task=` is now a tool-only keyword. FastMCP 3 accepted it on resource, resource-template, and prompt decorators as well; passing it to `@mcp.resource` or `@mcp.prompt` now raises `TypeError`, and there is no replacement — the extension tasks tool calls only.
|
||||
|
||||
The client API changed shape entirely. In 3.x you opted a single call into background execution with `task=True` and got a handle back. In 4.0 `call_tool` handles a tasked call transparently: if the server runs the call in the background, the client polls it to completion and returns the same result a synchronous call would have produced.
|
||||
|
||||
```python
|
||||
import fastmcp_tasks # noqa: F401 — importing anywhere enables client task support
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
async def run(server):
|
||||
async with Client(server) as client:
|
||||
return await client.call_tool("slow_computation", {"duration": 10})
|
||||
```
|
||||
|
||||
When you want the handle — to do other work while the task runs, check on it, or cancel it — `call_tool_task` returns one immediately:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp_tasks import call_tool_task
|
||||
|
||||
|
||||
async def run(server):
|
||||
async with Client(server) as client:
|
||||
task = await call_tool_task(client, "slow_computation", {"duration": 10})
|
||||
return await task.result()
|
||||
```
|
||||
|
||||
Three things follow from this. `client.call_tool(name, args, task=True)` raises `TypeError`, as do `read_resource(task=True)` and `get_prompt(task=True)` — and those last two have no replacement. Client task support requires `fastmcp_tasks` to be imported somewhere in the process, since that import is what makes a `Client` advertise the capability. And tasks are negotiated only on modern connections, so a `mode="legacy"` client never gets them. See [Background Tasks](/servers/tasks) for the full picture.
|
||||
|
||||
## Behavior Changes
|
||||
|
||||
These changes compile fine and can surface at runtime. The first is the one most likely to bite a working 3.x server.
|
||||
|
||||
**`ctx.elicit()` no longer reaches a default client.** Elicitation is era-gated in 4.0: `ctx.elicit()` works on handshake-era connections (≤ 2025-11-25) and raises on the modern `2026-07-28` protocol, which has no back-channel for a running tool to push a request down. Because `fastmcp.Client` now defaults to `mode="auto"`, an ordinary client negotiates the modern era against a FastMCP server — so a tool that elicited happily in 3.x now fails the call:
|
||||
|
||||
```
|
||||
ToolError: elicitation via server-initiated requests is unavailable on 2026-07-28 connections.
|
||||
```
|
||||
|
||||
The gate is strict in both directions, which is what makes it debuggable: a guard tool that returns an input request on a handshake connection raises the mirror-image error rather than misbehaving quietly. You have three ways forward. Rewrite the tool as a guard tool that *returns* a description of the input it needs, which is the form that works on modern connections. Branch on `ctx.request_context.protocol_version` and keep both paths if you serve both eras. Or keep this server's clients on the handshake era with `Client(server, mode="legacy")`, which leaves `ctx.elicit()` working as written. See [Elicitation](/servers/elicitation#which-approach-to-use) for the two shapes side by side.
|
||||
|
||||
**Middleware sees traffic it never saw before.** Dispatch now begins in the SDK's middleware layer, the single point every inbound message passes through, so `on_message`, `on_request`, and `on_notification` observe *every* message a client sends — including `notifications/cancelled`, `notifications/initialized`, and `notifications/progress`, and including requests that fail before reaching a handler, such as an unknown method or a `tools/call` whose params fail validation. In 3.x those never reached your hooks. Middleware that assumed every message it saw was a routable request, or that counted messages to measure tool traffic, needs a guard on the message type. The operation hooks (`on_call_tool`, `on_list_tools`, and the rest) are unaffected: they still fire exactly once per request and still receive typed component results. See [What middleware sees](/servers/middleware#what-middleware-sees).
|
||||
|
||||
**Templated resources are path-screened by default.** Every templated resource now has its extracted parameter values checked for path-traversal (`..` segments), absolute paths, and null bytes *before your handler runs*, at the server's read chokepoint. A rejected read returns a non-leaky "resource not found" error. Only a standalone `..` segment counts as traversal, so values that merely contain dots (`file.tar.gz`, `HEAD~3..HEAD`) and dotfiles (`.env`) still pass. If a template legitimately accepts `..`-bearing or absolute values, exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable the check per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security).
|
||||
|
||||
**Resource-not-found now returns `-32602`.** The wire error code for a missing resource from the core `resources/read` handler changed from `-32002` to `-32602` (`INVALID_PARAMS`, per SEP-2164). The human-readable message ("Resource not found: ...") is unchanged, so this only affects clients that matched on the numeric code — update those to expect `-32602`. (The opt-in `ErrorHandlingMiddleware` keeps its own per-method-prefix code mapping; if you run it with `transform_errors=True` it can still map not-found to a different code, so it is unaffected by this change.)
|
||||
|
||||
## Deprecation timeline
|
||||
**An OAuth server whose `issuer_url` differs from its `base_url` re-authorizes its clients once.** `issuer_url` exists so a server's OAuth identity can differ from the URL its endpoints are mounted at — the usual case being a server under `/api` whose discovery lives at the host root. It now supplies the `issuer` in the authorization server metadata, the `iss` claim on every token the server mints, and the RFC 9207 `iss` on authorization responses; `base_url` still supplies `authorization_endpoint`, `token_endpoint`, and the rest, because that is where the routes are actually mounted. Both values previously came from `base_url`, which published an `issuer` contradicting the URL the client had just performed discovery at — a document RFC 8414 §3.3 requires a strict client to reject.
|
||||
|
||||
The cost of the correction is the `iss` on tokens already in the wild, so it falls on the providers that mint their own tokens — `OAuthProxy` and everything built on it. Access *and* refresh tokens carry the claim, and the verifier compares it exactly, so clients cannot refresh their way across the upgrade; it is a one-time full re-authorization. Interactive clients re-prompt and recover on their own, while a headless deployment holding a long-lived refresh token needs someone to re-authorize it. Plan the upgrade for a window where that is acceptable. If an identity provider mints SEP-990 ID-JAG assertions for this server, repoint their `aud` at the new issuer too — unless you pin the expected value with `IdentityAssertion(audience=...)`, which overrides the issuer and keeps working untouched.
|
||||
|
||||
Servers that leave `issuer_url` unset, or set it to the same value as `base_url`, are unaffected. It defaults to `base_url`, and the metadata and minted `iss` are byte-identical to what 3.x produced.
|
||||
|
||||
## Deprecation Timeline
|
||||
|
||||
The camelCase bridge is a migration aid, not a permanent fixture. It works today and warns on every bridged read so you can find and update the affected call sites. Plan to migrate your reads to snake_case: the shims will be removed in a future release, after which only the snake_case names resolve — the same state you get today by setting `mcp_camelcase_compat = False`. Turning the setting off is a good way to surface every remaining camelCase read in your code as a hard `AttributeError` before the shims go away.
|
||||
|
||||
## SDK deprecation warnings you may see
|
||||
## SDK Deprecation Warnings
|
||||
|
||||
Ordinary use of `ctx.info` (client logging) and `ctx.sample` now emits an SDK-level `MCPDeprecationWarning`:
|
||||
Ordinary use of `ctx.info` (client logging) emits an SDK-level `MCPDeprecationWarning`:
|
||||
|
||||
```
|
||||
The logging/sampling capability is deprecated as of 2026-07-28 (SEP-2577)
|
||||
The logging capability is deprecated as of 2026-07-28 (SEP-2577)
|
||||
```
|
||||
|
||||
These warnings come from the MCP SDK, not from FastMCP. For logging they are benign: `ctx.info` keeps working on session-based connections exactly as the protocol table below describes, and the SDK is only signaling the protocol's direction. For sampling, FastMCP additionally emits its own `FastMCPDeprecationWarning`: `ctx.sample` and `ctx.sample_step` are deprecated and slated for removal, so treat that warning as a prompt to migrate to server-side LLM calls rather than as informational.
|
||||
The warning comes from the MCP SDK, not from FastMCP, and it is benign. `ctx.info` and the rest of the logging methods keep working on every era, including the modern one — a log message is a *notification*, which rides the response stream the caller already opened. The SDK is signaling the protocol's direction for the capability declaration, not the notification itself.
|
||||
|
||||
## Protocol version support
|
||||
## Protocol Version Support
|
||||
|
||||
FastMCP servers built on the SDK v2 serve multiple protocol eras from the same server. The SDK negotiates the era each client speaks: the sessionless `2026-07-28` era (which discovers capabilities through `server/discover`) and earlier session-based handshake versions are all handled simultaneously. This formally supersedes FastMCP's earlier "latest protocol only" stance — a single server now works with clients across the protocol transition.
|
||||
|
||||
Not every Context feature is available on every era yet. The imperative push APIs that call back into the client mid-execution — `ctx.elicit`, `ctx.sample`, and `ctx.list_roots` — depend on the session-based back-channel of the earlier eras, so on a `2026-07-28` connection they raise a clear, era-aware error rather than reaching the client. Elicitation itself still reaches the user on the modern era, through the guard pattern: a tool *returns* an `InputRequiredResult` describing what it needs, and the client answers with a fresh call (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Logging notifications and the request/response features flow on every era.
|
||||
**`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are gone from `Context`**, along with the `sampling_handler=` and `sampling_handler_behavior=` arguments to `FastMCP()`. Touching a removed method raises `AttributeError` on every era, and `FastMCP(sampling_handler=...)` raises a `TypeError` naming the migration, so the break surfaces when you upgrade rather than in production against whichever client happens to negotiate the modern era.
|
||||
|
||||
Sampling is the exception that does not come back, and the reason is the protocol rather than an unfinished FastMCP feature. SEP-2577 deprecated server-initiated sampling, so `ctx.sample` and `ctx.sample_step` are **deprecated** and will be removed in a future FastMCP release. Elicitation moved to the guard pattern because the modern protocol still carries elicitation requests; sampling has no equivalent path because the protocol deprecated the pattern itself. The migration is to call an LLM directly from your server rather than borrowing the client's model. See [Sampling](/servers/sampling) for details.
|
||||
All three *pushed*: the server sent a request down a live back-channel and blocked for the answer, and the sessionless protocol has no such channel. Since `fastmcp.Client` now negotiates the modern protocol by default, a method like that would fail against a default client. What the protocol removed is the pushing, not the asking — sampling, elicitation, and roots all still reach the client through the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), where a tool *returns* an `InputRequiredResult` describing what it needs, the client answers, and it calls again with the answer attached.
|
||||
|
||||
Migrating differs by capability. For **roots**, the guard pattern is the direct replacement: a server asks once and has what it needs, so the extra round buys the whole answer, and taking the paths as tool arguments is simpler still when the caller can just supply them. For **sampling**, the guard route works the same way, but generation usually belongs in your server, because every round is a full request-response cycle and a generation loop pays that cost repeatedly. [Call an LLM from your server](/servers/sampling) with your own API key and your tool behaves the same for every client, including the many that never implemented sampling; reach for the guard route when the point is specifically to use the caller's model. If borrowing the caller's model *is* your server — you hold no key of your own, and the token bill was never yours to pay — staying on FastMCP 3.x is the honest answer until that changes.
|
||||
|
||||
| Context feature | Earlier eras (session-based) | `2026-07-28` (sessionless) |
|
||||
| --- | --- | --- |
|
||||
| `ctx.info` / logging notifications | Supported | Supported |
|
||||
| Tools, resources, prompts, completions | Supported | Supported |
|
||||
| `ctx.elicit` | Supported | Use the guard pattern (return `InputRequiredResult`) |
|
||||
| `ctx.sample` / `ctx.sample_step` | Supported (deprecated) | Removed — call an LLM server-side |
|
||||
| `ctx.list_roots` | Supported | Via the guard pattern (`input_requests` carries roots requests) |
|
||||
| `ctx.elicit` | Supported | Raises — use the guard pattern (return `InputRequiredResult`) |
|
||||
| `ctx.sample` / `ctx.sample_step` | Method removed — call an LLM server-side | Method removed — call an LLM server-side, or ask via the guard pattern |
|
||||
| `ctx.list_roots` | Method removed — take paths as tool arguments | Method removed — ask via the guard pattern, or take paths as tool arguments |
|
||||
| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` needs session state the era lacks |
|
||||
| `Middleware.on_initialize` | Runs on connect | Never runs — there is no `initialize` handshake |
|
||||
| Session state (`ctx.set_state` across calls) | Persists for the session | Does not persist — every request is a fresh connection |
|
||||
| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension |
|
||||
|
||||
If your tools rely on `ctx.elicit` or `ctx.list_roots`, they continue to work against clients on the earlier eras; on the modern era, reach for the guard pattern instead (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Sampling is deprecated on every era and will not return on modern connections — migrate those tools to server-side LLM calls.
|
||||
Several of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next; and a tool that calls [`ctx.elicit()`](#behavior-changes) raises. A server that gates access in `on_initialize`, relies on per-session state, or elicits mid-tool must keep its clients on the session-based era. The control is per-client: `Client(server, mode="legacy")`. There is no server-side setting that restricts which protocol versions a server offers, so a server whose behavior depends on the handshake era depends on its callers opting into it — which is only practical when you control them. If you don't, port the behavior instead: a guard tool for elicitation, [session state](/servers/sessions) for what `ctx.set_state` held, and per-request auth checks for what `on_initialize` gated.
|
||||
|
||||
Two of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; and each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next. A server that gates access in `on_initialize` or relies on per-session state must keep its clients on the session-based era. The narrow escape is per-client: `Client(server, mode="legacy")`. The durable, server-side answer is to declare the versions the server actually serves so a modern client is refused at connect time rather than silently losing those features — see the server's protocol-version restriction (added alongside this change).
|
||||
The client side is unaffected. `sampling_handler=` and `roots=` mean what they always did — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — and one registration serves both routes, since a handshake-era server's pushed request and a modern server's returned one dispatch to the same handler.
|
||||
|
||||
## Upgrade checklist
|
||||
## Upgrade Checklist
|
||||
|
||||
Most servers upgrade untouched. Work down this list to find the ones that don't:
|
||||
|
||||
1. **Bump your environment.** Raise any pin below `pydantic>=2.12`; upgrade FastAPI if your resolver complains about Starlette `<1.0.1`.
|
||||
2. **Fix imports that moved out.** Replace `from mcp.types import X` with `from mcp_types import X`, and update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims).
|
||||
3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods-and-mount-keywords).
|
||||
4. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
|
||||
5. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
|
||||
6. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
|
||||
7. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize` or per-session state, keep its clients on `mode="legacy"` or restrict the server's served protocol versions.
|
||||
8. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, and update any client that matched the old `-32002` resource-not-found code.
|
||||
9. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed.
|
||||
2. **Fix imports that moved out.** `from mcp.types import X` still works, but update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims).
|
||||
3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods).
|
||||
4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; ask for roots through the guard pattern, or take file paths as tool arguments. A server whose purpose is to use the caller's model should stay on FastMCP 3.x rather than migrate.
|
||||
5. **Find every `ctx.elicit()` call.** It raises on modern connections, which is what a default client now negotiates. Rewrite the tool as a guard tool, branch on `ctx.request_context.protocol_version`, or keep its clients on `mode="legacy"` — see [the era gate](#behavior-changes).
|
||||
6. **Register the tasks extension.** A `task=True` tool needs `mcp.add_extension(TasksExtension())` or the server won't start. Drop `task=` from resource and prompt decorators, move `TaskConfig` to `fastmcp.utilities.tasks`, and replace client-side `call_tool(..., task=True)` with plain `call_tool` or `call_tool_task`.
|
||||
7. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
|
||||
8. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
|
||||
9. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
|
||||
10. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize`, per-session state, or `ctx.elicit()`, keep its clients on `mode="legacy"`, or port the behavior forward — there is no server-side protocol-version restriction.
|
||||
11. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, guard any middleware that now sees notifications and unroutable requests, update any client that matched the old `-32002` resource-not-found code, and if your server mints its own OAuth tokens (`OAuthProxy` and the providers built on it) under an `issuer_url` that differs from its `base_url`, schedule the [one-time re-authorization](#behavior-changes) its clients now need.
|
||||
12. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed.
|
||||
|
||||
The executable version of this checklist lives in [`tests/test_upgrade_from_v3.py`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/test_upgrade_from_v3.py): it builds representative 3.x-style servers and asserts they run unchanged, and pins every removed surface to the exact error it now raises.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Upgrading from the MCP Low-Level SDK
|
||||
sidebarTitle: "From MCP Low-Level SDK"
|
||||
description: Upgrade your MCP server from the low-level Python SDK's Server class to FastMCP
|
||||
title: Upgrading from the Low-Level SDK v1
|
||||
sidebarTitle: "From Low-Level SDK v1"
|
||||
description: Upgrade your MCP server from v1 of the low-level Python SDK's Server class to FastMCP
|
||||
icon: up
|
||||
---
|
||||
|
||||
|
|
@ -9,78 +9,89 @@ If you've been building MCP servers directly on the `mcp` package's `Server` cla
|
|||
|
||||
The core idea: instead of telling the SDK what your tools look like and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The plumbing you wrote to satisfy the protocol just disappears.
|
||||
|
||||
## Why now is the moment to switch
|
||||
## The SDK v2 Transition
|
||||
|
||||
MCP SDK v2 landed sweeping breaking changes on the low-level `Server`: the protocol types moved out of `mcp.types` into a separate `mcp_types` package, every field was renamed from camelCase to snake_case, the `Server` class was rebuilt, `McpError` was renamed, and sessions were removed on the new sessionless protocol era. If you build directly on the low-level SDK, all of that lands on you — you have to rewrite your imports, your handler signatures, and your error construction to match the new surface.
|
||||
MCP SDK v2 is a substantial, deliberate modernization of the protocol layer. Protocol types moved into a standalone `mcp_types` package, wire fields moved from camelCase to snake_case, and the low-level `Server` was rebuilt so handlers are passed to the constructor as `on_*` callables taking `(ctx, params)` rather than registered with decorators. A v1 server meets that change the moment its environment resolves `mcp` to v2:
|
||||
|
||||
Adopting FastMCP is the easier path. FastMCP 4 runs on SDK v2 and hides that entire surface behind a high-level API that did not change. You write `@mcp.tool` and never touch the renamed internals — FastMCP derives the protocol layer from your function signatures, so the SDK v2 rename simply isn't something your code has to know about. Migrating low-level-SDK-v1 code to FastMCP is less work than migrating it to raw SDK v2, and you come out the other side with the whole framework: composition, middleware, proxies, authentication, and testing. The SDK v2 break is the natural moment to make the jump.
|
||||
```
|
||||
AttributeError: 'Server' object has no attribute 'list_tools'
|
||||
```
|
||||
|
||||
Often nobody chose that moment. An unpinned `mcp` dependency, a fresh lockfile, or a rebuilt container picks up the new major version. Nothing is wrong with your code, and nothing is wrong with the SDK — major versions are exactly where a change like this belongs. Your build just crossed it earlier than you planned to.
|
||||
|
||||
Pinning the SDK back restores the decorator API immediately, with no code changes, and buys you time to choose deliberately:
|
||||
|
||||
```bash
|
||||
pip install "mcp<2"
|
||||
```
|
||||
|
||||
## Two Upgrade Paths
|
||||
|
||||
Both directions are reasonable, and the choice is about which code you'd rather maintain.
|
||||
|
||||
**Porting the low-level `Server` to SDK v2** keeps you in direct control of the protocol surface, which is the point of the low-level API and the right call for some servers. The work is real: your imports, every handler signature, every handler's return type, and your error construction all move.
|
||||
|
||||
**Adopting FastMCP** is what the rest of this page walks through. What makes it less work is not that FastMCP is better — it's that the code most affected by the SDK v2 changes is precisely the code FastMCP doesn't ask you to write. Your `list_tools`/`call_tool` pair, hand-written JSON Schema, and content-block wrappers aren't ported to new signatures; they're deleted, and FastMCP derives all of it from your function signatures instead. FastMCP 4 runs on MCP SDK v2 underneath, so both paths land you on the same modern protocol layer.
|
||||
|
||||
<Note>
|
||||
Already using FastMCP 1.0 via `from mcp.server.fastmcp import FastMCP`? Your upgrade is simpler — see the [FastMCP 1.0 upgrade guide](/getting-started/upgrading/from-mcp-sdk) instead.
|
||||
Already on SDK v2's rebuilt `Server` class, with constructor-registered `on_*` handlers? See [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2) instead — the before-and-after code is different enough to warrant its own guide.
|
||||
|
||||
Using FastMCP 1.0 via `from mcp.server.fastmcp import FastMCP`? Your upgrade is a single import — see [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1).
|
||||
</Note>
|
||||
|
||||
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
|
||||
You are upgrading an MCP server from the `mcp` package's low-level Server class (v1) to FastMCP 4. The server currently uses `mcp.server.Server` (or `mcp.server.lowlevel.server.Server`) with manual handler registration. Analyze the provided code and rewrite it using FastMCP's high-level API. The full guide is at https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context.
|
||||
You are rewriting an MCP server built on v1 of the `mcp` package's low-level `Server` class (`mcp.server.Server` or `mcp.server.lowlevel.server.Server`, with decorator-registered handlers) using FastMCP 4's high-level API.
|
||||
|
||||
UPGRADE RULES:
|
||||
FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1 — it explains every item below, with before-and-after code for each handler group. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs.
|
||||
|
||||
1. IMPORTS: Replace all `mcp.*` imports with FastMCP equivalents.
|
||||
- `from mcp.server import Server` or `from mcp.server.lowlevel.server import Server` → `from fastmcp import FastMCP`
|
||||
- `import mcp.types as types` → remove (not needed for most code)
|
||||
- `from mcp.server.stdio import stdio_server` → remove (handled by mcp.run())
|
||||
- `from mcp.server.sse import SseServerTransport` → remove (handled by mcp.run())
|
||||
Then work through the provided code. This is a rewrite, not a patch: most of what you find gets deleted rather than translated.
|
||||
|
||||
2. SERVER: Replace `Server("name")` with `FastMCP("name")`.
|
||||
CONSTRUCTION AND TRANSPORT
|
||||
- `Server("name")`
|
||||
- `async with stdio_server() as (r, w): await server.run(r, w, server.create_initialization_options())`
|
||||
- `SseServerTransport` / `StreamableHTTPSessionManager` and any Starlette wiring around them
|
||||
- `asyncio.run(main())` boilerplate
|
||||
- `lifespan=` — carries over directly: pass the same async context manager to `FastMCP(lifespan=...)`, and read what it yields from `ctx.lifespan_context` in any tool. Do not drop it — the tools that depended on it (a DB connection, a client pool) lose their dependency silently if you do.
|
||||
|
||||
3. TOOLS: Replace the list_tools + call_tool handler pair with individual @mcp.tool decorators.
|
||||
- Delete the `@server.list_tools()` handler entirely
|
||||
- Delete the `@server.call_tool()` handler entirely
|
||||
- For each tool that was listed in list_tools and dispatched in call_tool, create a new function:
|
||||
- Decorate it with `@mcp.tool`
|
||||
- Use the tool name as the function name (or pass name= to the decorator)
|
||||
- Use the docstring for the description (or pass description= to the decorator)
|
||||
- Convert the inputSchema JSON Schema into typed Python parameters (e.g., `{"type": "integer"}` → `int`, `{"type": "string"}` → `str`, `{"type": "array", "items": {"type": "string"}}` → `list[str]`)
|
||||
- Return plain Python values (`str`, `int`, `dict`, etc.) instead of `list[types.TextContent(...)]`
|
||||
- If the tool returned `types.ImageContent` or `types.EmbeddedResource`, use `from fastmcp.utilities.types import Image` or return the appropriate type
|
||||
HANDLERS TO DELETE (each becomes one or more decorated functions)
|
||||
- `@server.list_tools()` + `@server.call_tool()` — note the `if name == ...` dispatch chain inside call_tool; each branch becomes its own `@mcp.tool`
|
||||
- `@server.list_resources()` + `@server.list_resource_templates()` + `@server.read_resource()` — note any manual URI parsing, which the `{placeholder}` syntax replaces
|
||||
- `@server.list_prompts()` + `@server.get_prompt()`
|
||||
- any other `@server.*()` handler in the file — completion, resource subscribe/unsubscribe, logging level, progress. Look these up in the FastMCP docs rather than assuming a decorator name maps one-to-one.
|
||||
|
||||
4. RESOURCES: Replace the list_resources + list_resource_templates + read_resource handler trio with individual @mcp.resource decorators.
|
||||
- Delete all three handlers
|
||||
- For each static resource, create a function decorated with `@mcp.resource("uri://...")`
|
||||
- For each resource template, use `@mcp.resource("uri://{param}/path")` with `{param}` in the URI and a matching function parameter
|
||||
- Return str for text content, bytes for binary content
|
||||
- Set `mime_type=` in the decorator if needed
|
||||
TYPES THAT DISAPPEAR FROM YOUR CODE
|
||||
- hand-written `inputSchema` JSON Schema dicts — these come from type hints now
|
||||
- `types.Tool`, `types.Resource`, `types.ResourceTemplate`, `types.Prompt`, `types.PromptArgument`
|
||||
- `types.TextContent` wrappers around return values — return plain Python values instead
|
||||
- `types.ImageContent`, `types.EmbeddedResource`
|
||||
- `types.PromptMessage`, `types.GetPromptResult`
|
||||
- Note that in the SDK v2 that FastMCP 4 builds on, `mcp.types` aliases the standalone `mcp_types` package; the import path still works, but the fields are snake_case now.
|
||||
|
||||
5. PROMPTS: Replace the list_prompts + get_prompt handler pair with individual @mcp.prompt decorators.
|
||||
- Delete both handlers
|
||||
- For each prompt, create a function decorated with `@mcp.prompt`
|
||||
- Convert PromptArgument definitions into typed function parameters
|
||||
- Return str for simple single-message prompts (auto-wrapped as user message)
|
||||
- Return `list[Message]` for multi-message prompts: `from fastmcp.prompts import Message`
|
||||
- `Message("text")` defaults to `role="user"`; use `Message("text", role="assistant")` for assistant messages
|
||||
CONTEXT AND SIDE CHANNELS
|
||||
- `server.request_context`
|
||||
- `session.send_log_message(...)`, `session.send_progress_notification(...)`
|
||||
- direct session use for anything else — a FastMCP `Context` has a `ctx.session` property returning the underlying SDK session, so this still works; prefer a `Context` method where one exists, and note the remaining uses as SDK-coupled
|
||||
|
||||
6. TRANSPORT: Replace all transport boilerplate with mcp.run().
|
||||
- `async with stdio_server() as (r, w): await server.run(r, w, ...)` → `mcp.run()` (`stdio` is the default)
|
||||
- SSE/Starlette setup → `mcp.run(transport="sse", host="...", port=...)`
|
||||
- Streamable HTTP setup → `mcp.run(transport="http", host="...", port=...)`
|
||||
- Delete asyncio.run(main()) boilerplate — use `if __name__ == "__main__": mcp.run()`
|
||||
ERRORS
|
||||
- `raise ValueError(f"Unknown tool: ...")` and other dispatch fallbacks — these become unnecessary
|
||||
- `McpError` construction and any error-code mapping
|
||||
|
||||
7. CONTEXT: Replace `server.request_context` with FastMCP's Context parameter.
|
||||
- Add `from fastmcp import Context` and add a `ctx: Context` parameter to any tool that needs it
|
||||
- `server.request_context.session.send_log_message(...)` → `await ctx.info("message")` or `await ctx.warning("message")`
|
||||
- Progress reporting → `await ctx.report_progress(current, total)`
|
||||
|
||||
For each change, show the original code, explain what it did, and provide the FastMCP equivalent.
|
||||
For each item found, show the original code, say what it did, and give the FastMCP equivalent. Where several handlers collapse into one decorated function, show the collapse rather than a line-by-line mapping. Call out anything you could not find a documented FastMCP replacement for instead of inventing one.
|
||||
</Prompt>
|
||||
|
||||
## Install
|
||||
|
||||
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
|
||||
|
||||
```bash
|
||||
pip install --upgrade fastmcp
|
||||
pip install "fastmcp==4.0.0b1"
|
||||
# or
|
||||
uv add fastmcp
|
||||
uv add "fastmcp==4.0.0b1"
|
||||
```
|
||||
|
||||
FastMCP includes the `mcp` package as a transitive dependency, so you don't lose access to anything.
|
||||
An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
|
||||
|
||||
FastMCP depends on the `mcp` package, so the SDK stays installed. FastMCP 4 builds on SDK v2, where the protocol types live in a standalone `mcp_types` package that stays importable as `mcp.types`. Most of your `mcp.types` imports disappear entirely in the rewrite below, since FastMCP derives the protocol types from your function signatures.
|
||||
|
||||
## Server and Transport
|
||||
|
||||
|
|
@ -88,7 +99,7 @@ The `Server` class requires you to choose a transport, connect streams, build in
|
|||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
```python Before test="skip"
|
||||
import asyncio
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
|
@ -124,7 +135,12 @@ if __name__ == "__main__":
|
|||
Need HTTP instead of stdio? With the `Server` class, you'd wire up Starlette routes and `SseServerTransport` or `StreamableHTTPSessionManager`. With FastMCP:
|
||||
|
||||
```python
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8000)
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8000)
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
|
@ -133,7 +149,7 @@ This is where the difference is most dramatic. The `Server` class requires two h
|
|||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
```python Before test="skip"
|
||||
import mcp.types as types
|
||||
from mcp.server import Server
|
||||
|
||||
|
|
@ -240,7 +256,7 @@ The `Server` class uses three handlers for resources: `list_resources()` to enum
|
|||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
```python Before test="skip"
|
||||
import json
|
||||
import mcp.types as types
|
||||
from mcp.server import Server
|
||||
|
|
@ -333,7 +349,7 @@ Same pattern: the `Server` class uses `list_prompts()` and `get_prompt()` with m
|
|||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
```python Before test="skip"
|
||||
import mcp.types as types
|
||||
from mcp.server import Server
|
||||
|
||||
|
|
@ -420,7 +436,7 @@ The `Server` class exposes request context through `server.request_context`, whi
|
|||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
```python Before test="skip"
|
||||
import mcp.types as types
|
||||
from mcp.server import Server
|
||||
|
||||
|
|
@ -458,13 +474,31 @@ async def process_data(ctx: Context) -> str:
|
|||
|
||||
The `Context` object provides logging (`ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`), progress reporting (`ctx.report_progress()`), resource subscriptions, session state, and more. See [Context](/servers/context) for the full API.
|
||||
|
||||
## Errors
|
||||
|
||||
Most of the errors a low-level server raises disappear with the dispatch that raised them: the `ValueError(f"Unknown tool: {name}")` fallback is unnecessary once FastMCP routes calls, and an exception from your function body is converted to a tool error for you.
|
||||
|
||||
Deliberate protocol errors are the exception, and they need a small rewrite. The v1 pattern wrapped an `ErrorData` and passed it positionally; FastMCP's `McpError` takes the fields directly:
|
||||
|
||||
```python test="skip"
|
||||
from fastmcp.exceptions import McpError
|
||||
|
||||
# Before (SDK v1):
|
||||
# raise McpError(ErrorData(code=-32000, message="Upstream unavailable"))
|
||||
|
||||
# After:
|
||||
raise McpError(code=-32000, message="Upstream unavailable")
|
||||
```
|
||||
|
||||
An optional third argument, `data=`, carries the structured payload `ErrorData` used to hold. Catching is unchanged — `except McpError` still works, and `err.error.code` still reads the code — so only construction sites need touching.
|
||||
|
||||
## Complete Example
|
||||
|
||||
A full server upgrade, showing how all the pieces fit together:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before expandable
|
||||
```python Before expandable test="skip"
|
||||
import asyncio
|
||||
import json
|
||||
import mcp.types as types
|
||||
|
|
@ -580,15 +614,10 @@ if __name__ == "__main__":
|
|||
|
||||
</CodeGroup>
|
||||
|
||||
## What's Next
|
||||
## What You Gain
|
||||
|
||||
Once you've upgraded, you have access to everything FastMCP provides beyond the basics:
|
||||
Deleting the handler machinery is the immediate payoff, but the reason to make this move is what becomes available once your server is a FastMCP server.
|
||||
|
||||
- **[Server composition](/servers/composition)** — Mount sub-servers to build modular applications
|
||||
- **[Middleware](/servers/middleware)** — Add logging, rate limiting, error handling, and caching
|
||||
- **[Proxy servers](/servers/providers/proxy)** — Create a proxy to any existing MCP server
|
||||
- **[OpenAPI integration](/integrations/openapi)** — Generate an MCP server from an OpenAPI spec
|
||||
- **[Authentication](/servers/auth/authentication)** — Built-in OAuth and token verification
|
||||
- **[Testing](/servers/testing)** — Test your server directly in Python without running a subprocess
|
||||
[Server composition](/servers/composition) mounts one server inside another, so a surface that grew unwieldy as a single `call_tool` dispatch splits into modules developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching — the cross-cutting concerns that, on the low-level `Server`, meant threading the same code through every handler. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control, and the [OpenAPI integration](/integrations/openapi) generates an entire server from an API specification you already have. [Authentication](/servers/auth/authentication) arrives as a single `auth=` provider covering token verification, OAuth, and named providers for GitHub, Google, Auth0, and others.
|
||||
|
||||
Explore the full documentation at [gofastmcp.com](https://gofastmcp.com).
|
||||
The change most likely to affect your daily work is [testing](/servers/testing). FastMCP ships a client that connects to a server object in the same Python process, so a test calls your tools directly — no subprocess, no stdio pipes, no transport to stand up.
|
||||
622
docs/getting-started/upgrading/from-low-level-sdk-v2.mdx
Normal file
622
docs/getting-started/upgrading/from-low-level-sdk-v2.mdx
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
---
|
||||
title: Upgrading from the Low-Level SDK v2
|
||||
sidebarTitle: "From Low-Level SDK v2"
|
||||
description: Move a server built on v2 of the low-level Python SDK's Server class to FastMCP
|
||||
icon: up
|
||||
---
|
||||
|
||||
If your server builds on the `mcp` package's low-level `Server` class as SDK v2 rebuilt it — handlers passed to the constructor as `on_list_tools`, `on_call_tool`, and their siblings, each taking `(ctx, params)` and returning a wrapped result object — this guide is for you. FastMCP replaces that machinery with a declarative API where your functions *are* the protocol surface.
|
||||
|
||||
The core idea: instead of describing your tools to the SDK and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The dispatch you wrote to route a call by name, and the schemas you wrote by hand to describe it, both disappear.
|
||||
|
||||
Migrating from SDK v2 is the most direct of the four upgrade paths, because you and FastMCP already share a protocol layer. FastMCP 4 is built on SDK v2, so `mcp_types` imports keep working, field names are already snake_case, and the era negotiation you get is the one you have. Almost nothing about the wire changes — the one exception is [argument strictness](#stricter-arguments), covered below.
|
||||
|
||||
<Note>
|
||||
On SDK v1's decorator-registered `Server` — `@server.list_tools()`, `@server.call_tool()` — instead? See [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1), where the before-and-after code matches that API.
|
||||
|
||||
Using SDK v2's high-level `MCPServer` class? See [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2) — that migration is mostly renaming.
|
||||
</Note>
|
||||
|
||||
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
|
||||
You are rewriting an MCP server built on the MCP Python SDK v2's low-level `Server` class (`mcp.server.lowlevel.server.Server`, with `on_*` handlers passed to the constructor) using FastMCP 4's high-level API.
|
||||
|
||||
FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2 — it explains every item below in full, with before-and-after code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not guess at a FastMCP API you have not confirmed in the docs.
|
||||
|
||||
Then work through the provided code looking for each of these. The guide has the replacement for every one:
|
||||
|
||||
CONSTRUCTION AND TRANSPORT
|
||||
- `Server(name, on_list_tools=..., on_call_tool=..., ...)` — the whole constructor, including every handler passed to it
|
||||
- `server.run(read_stream, write_stream, server.create_initialization_options())` and its `stdio_server()` context manager
|
||||
- `server.streamable_http_app()` and any Starlette app assembled around it
|
||||
- `asyncio.run(main())` boilerplate
|
||||
- `lifespan=` — carries over directly: pass the same async context manager to `FastMCP(lifespan=...)`, and read what it yields from `ctx.lifespan_context` in any tool. Do not drop it — the tools that depended on it (a DB connection, a client pool) lose their dependency silently if you do.
|
||||
|
||||
HANDLERS TO DELETE, EACH REPLACED BY ONE DECORATOR (not simply removed)
|
||||
- `on_list_tools` + `on_call_tool` → one `@mcp.tool` function per branch of the `if params.name == ...` dispatch chain inside `on_call_tool`
|
||||
- `on_list_resources` + `on_list_resource_templates` + `on_read_resource` → one `@mcp.resource` function per resource/template
|
||||
- `on_list_prompts` + `on_get_prompt` → one `@mcp.prompt` function per prompt
|
||||
- `on_completion` → one `@mcp.completion` function. This one is easy to drop by mistake: skipping it does not just remove autocomplete cleanly, it silently stops FastMCP from advertising the completions capability at all, since that capability is only advertised when a handler is registered.
|
||||
- `on_subscribe_resource` / `on_unsubscribe_resource` / `on_subscriptions_listen` — flag for the user, no single-decorator equivalent
|
||||
- `on_set_logging_level`, `on_progress`, `on_roots_list_changed`, `on_ping` — flag for the user, these are protocol-level hooks with no direct FastMCP surface
|
||||
|
||||
TYPES THAT DISAPPEAR FROM YOUR CODE
|
||||
- Hand-written `input_schema` / `output_schema` JSON Schema dicts — these come from type hints now
|
||||
- `types.ListToolsResult`, `types.CallToolResult`, `types.ListResourcesResult`, `types.ListResourceTemplatesResult`, `types.ReadResourceResult`, `types.ListPromptsResult`, `types.GetPromptResult` — result wrappers FastMCP builds for you
|
||||
- `types.TextContent`, `types.TextResourceContents`, `types.BlobResourceContents` — return plain Python values instead
|
||||
- `types.ImageContent` / `types.AudioContent` — `fastmcp.utilities.types.Image` / `Audio`
|
||||
- `types.Tool`, `types.Resource`, `types.ResourceTemplate`, `types.Prompt`, `types.PromptArgument` — declaration types FastMCP derives
|
||||
- `types.PromptMessage` — `fastmcp.prompts.Message`
|
||||
- Note which `mcp_types` imports are still needed afterward; protocol types are unchanged in FastMCP, so surviving imports stay as they are.
|
||||
|
||||
CONTEXT AND SIDE CHANNELS
|
||||
- `ctx.session.send_log_message(...)` — `ctx.info()` / `ctx.debug()` / `ctx.warning()` / `ctx.error()` on a `fastmcp.Context` parameter
|
||||
- `ctx.session.report_progress(...)` — `ctx.report_progress()`
|
||||
- `ctx.request_id`, `ctx.meta`, `ctx.protocol_version` — these live on `ctx.request_context` in FastMCP (`ctx.request_context.request_id`, and so on); note that `ctx.protocol_version` directly on the Context does not exist
|
||||
- `ctx.params` — no equivalent, and none is needed: the raw request params were how a low-level handler read the tool's arguments, and those are now the decorated function's typed parameters. `ctx.request_context.params` does NOT exist and raises AttributeError.
|
||||
- Direct `ctx.session` use for anything else — `Context.session` exists in FastMCP too and returns the same raw SDK session, so this still works; prefer a `Context` method where one exists, and note the remaining uses as SDK-coupled
|
||||
|
||||
ERRORS AND AUTH
|
||||
- `raise ValueError(f"Unknown tool: ...")` dispatch fallbacks — these become unnecessary
|
||||
- `MCPError` construction and any error-code mapping
|
||||
- `auth=AuthSettings(...)`, `token_verifier=`, `auth_server_provider=` — one `auth=` provider in FastMCP
|
||||
- `TransportSecuritySettings`
|
||||
|
||||
For each item found, show the original code, say what it did, and give the FastMCP equivalent. Where several handlers collapse into one decorated function, show the collapse rather than a line-by-line mapping. Call out anything you could not find a documented FastMCP replacement for instead of inventing one.
|
||||
</Prompt>
|
||||
|
||||
## Install
|
||||
|
||||
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
|
||||
|
||||
```bash
|
||||
pip install "fastmcp==4.0.0b1"
|
||||
# or
|
||||
uv add "fastmcp==4.0.0b1"
|
||||
```
|
||||
|
||||
An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
|
||||
|
||||
FastMCP 4 depends on the MCP SDK v2 you are already using, so `mcp_types` stays importable and every protocol type keeps its current name and fields. Most of those imports vanish from your code anyway — FastMCP derives them — but the ones you keep need no changes.
|
||||
|
||||
## Server and Transport
|
||||
|
||||
The `Server` class asks you to open a transport, connect its streams, build initialization options, and run an event loop. FastMCP collapses that into a constructor and a `run()` call.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before test="skip"
|
||||
import asyncio
|
||||
|
||||
from mcp.server.lowlevel.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
server = Server("my-server") # plus every on_* handler
|
||||
|
||||
async def main():
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
server.create_initialization_options(),
|
||||
)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
```python After
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
|
||||
# ... register tools, resources, prompts ...
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Serving HTTP is the same shape. Where the low-level class hands you a Starlette app from `server.streamable_http_app()` and leaves the hosting to you, FastMCP runs it directly:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8000)
|
||||
```
|
||||
|
||||
`mcp.http_app()` still returns a Starlette app when you need to mount the server inside a larger application.
|
||||
|
||||
## Tools
|
||||
|
||||
This is where the difference is largest. SDK v2 requires two handlers — one describing your tools with hand-written JSON Schema, one dispatching calls by name — and both are passed to the constructor, so the connection between a tool's declaration and its implementation lives only in your head. FastMCP derives both from the function.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
import mcp_types as types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params) -> types.ListToolsResult:
|
||||
number = {"type": "number"}
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"a": number, "b": number},
|
||||
"required": ["a", "b"],
|
||||
}
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(name="add", description="Add two numbers", input_schema=schema),
|
||||
types.Tool(
|
||||
name="multiply", description="Multiply two numbers", input_schema=schema
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def call_tool(
|
||||
ctx: ServerRequestContext, params: types.CallToolRequestParams
|
||||
) -> types.CallToolResult:
|
||||
arguments = params.arguments or {}
|
||||
if params.name == "add":
|
||||
result = arguments["a"] + arguments["b"]
|
||||
elif params.name == "multiply":
|
||||
result = arguments["a"] * arguments["b"]
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {params.name}")
|
||||
return types.CallToolResult(content=[types.TextContent(type="text", text=str(result))])
|
||||
|
||||
|
||||
server = Server("math", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
```
|
||||
|
||||
```python After
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("math")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def add(a: float, b: float) -> float:
|
||||
"""Add two numbers"""
|
||||
return a + b
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def multiply(a: float, b: float) -> float:
|
||||
"""Multiply two numbers"""
|
||||
return a * b
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Each `@mcp.tool` function is self-contained: its name becomes the tool name, its docstring becomes the description, its annotations become the JSON Schema, and its return value is serialized for you. The dispatch chain, the schema dicts, the `CallToolResult` wrapper, the `TextContent` wrapper, and the unknown-tool fallback all go away — a tool that doesn't exist is now the framework's problem, not a branch you maintain.
|
||||
|
||||
### Type Mapping
|
||||
|
||||
Your hand-written `input_schema` becomes the function's parameters:
|
||||
|
||||
| JSON Schema | Python type |
|
||||
|---|---|
|
||||
| `{"type": "string"}` | `str` |
|
||||
| `{"type": "number"}` | `float` |
|
||||
| `{"type": "integer"}` | `int` |
|
||||
| `{"type": "boolean"}` | `bool` |
|
||||
| `{"type": "array", "items": {"type": "string"}}` | `list[str]` |
|
||||
| `{"type": "object"}` | `dict` |
|
||||
| A property absent from `required` | `param: str \| None = None` |
|
||||
|
||||
Constraints carry over too. A schema with `"minimum"` and `"maximum"` becomes a Pydantic `Field`, and a nested object schema becomes a Pydantic model or dataclass used as the annotation — FastMCP generates the same schema back out of it.
|
||||
|
||||
### Return Values
|
||||
|
||||
The low-level class requires tools to return a `CallToolResult` wrapping a list of content blocks. FastMCP takes the value itself — strings, numbers, dicts, lists, dataclasses, Pydantic models — and handles both the content block and the structured output. For images and audio, FastMCP provides wrapper types that carry the format:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.types import Image
|
||||
|
||||
mcp = FastMCP("media")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def create_chart(data: list[float]) -> Image:
|
||||
"""Generate a chart from data."""
|
||||
png_bytes = render_png(data) # your logic
|
||||
return Image(data=png_bytes, format="png")
|
||||
```
|
||||
|
||||
When you need full control over the wire result — multiple content blocks, or structured content that differs from the content blocks — return a `ToolResult` from `fastmcp.tools` instead.
|
||||
|
||||
### Stricter Arguments
|
||||
|
||||
Deriving the schema from your signature also tightens what callers may send, and this is the one behavior change the migration introduces. Your `on_call_tool` handler reads `params.arguments` as a plain dict and never looks at keys it doesn't need, so a call carrying an unexpected key succeeds. FastMCP declares `"additionalProperties": false` on the generated schema and enforces it, so the same call fails:
|
||||
|
||||
```python test="skip"
|
||||
# Against the low-level handler: succeeds, "extra" never read.
|
||||
# Against FastMCP: raises, "extra" is not a parameter of greet().
|
||||
await client.call_tool("greet", {"name": "World", "extra": "surprise"})
|
||||
```
|
||||
|
||||
For most servers this is an improvement that costs nothing — a caller sending keys your handler never read was already a bug, and the hand-written schema never advertised that they were allowed. It matters if a client in your fleet attaches metadata alongside real arguments, since those calls start failing the moment you migrate. Accept them explicitly as optional parameters if you need to keep them working.
|
||||
|
||||
## Resources
|
||||
|
||||
Resources take three handlers on the low-level class: one to list static resources, one to list URI templates, and one to read whichever URI arrives, with routing you write by hand. FastMCP replaces all three with a decorator per resource, and detects templates from the URI itself.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
import json
|
||||
|
||||
import mcp_types as types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
|
||||
async def list_resources(ctx: ServerRequestContext, params) -> types.ListResourcesResult:
|
||||
return types.ListResourcesResult(
|
||||
resources=[
|
||||
types.Resource(
|
||||
uri="config://app",
|
||||
name="app_config",
|
||||
description="Application configuration",
|
||||
mime_type="application/json",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def list_resource_templates(
|
||||
ctx: ServerRequestContext, params
|
||||
) -> types.ListResourceTemplatesResult:
|
||||
return types.ListResourceTemplatesResult(
|
||||
resource_templates=[
|
||||
types.ResourceTemplate(
|
||||
uri_template="users://{user_id}/profile",
|
||||
name="user_profile",
|
||||
description="User profile by ID",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def read_resource(
|
||||
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
|
||||
) -> types.ReadResourceResult:
|
||||
uri = str(params.uri)
|
||||
if uri == "config://app":
|
||||
text = json.dumps({"debug": False, "version": "1.0"})
|
||||
elif uri.startswith("users://"):
|
||||
user_id = uri.split("/")[2]
|
||||
text = json.dumps({"id": user_id, "name": f"User {user_id}"})
|
||||
else:
|
||||
raise ValueError(f"Unknown resource: {uri}")
|
||||
return types.ReadResourceResult(
|
||||
contents=[
|
||||
types.TextResourceContents(
|
||||
uri=params.uri, mime_type="application/json", text=text
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
server = Server(
|
||||
"data",
|
||||
on_list_resources=list_resources,
|
||||
on_list_resource_templates=list_resource_templates,
|
||||
on_read_resource=read_resource,
|
||||
)
|
||||
```
|
||||
|
||||
```python After
|
||||
import json
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("data")
|
||||
|
||||
|
||||
@mcp.resource("config://app", mime_type="application/json")
|
||||
def app_config() -> str:
|
||||
"""Application configuration"""
|
||||
return json.dumps({"debug": False, "version": "1.0"})
|
||||
|
||||
|
||||
@mcp.resource("users://{user_id}/profile", mime_type="application/json")
|
||||
def user_profile(user_id: str) -> str:
|
||||
"""User profile by ID"""
|
||||
return json.dumps({"id": user_id, "name": f"User {user_id}"})
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
The URI does the routing. A `{placeholder}` in the URI makes the resource a template, and FastMCP matches the parameter to the function argument of the same name — so the `uri.split("/")[2]` parsing goes away along with the handler that held it. Return a `str` for text content and `bytes` for binary; FastMCP builds the `TextResourceContents` or `BlobResourceContents` wrapper.
|
||||
|
||||
Templated resources also gain a protection the low-level version left to you: FastMCP screens extracted parameter values for path traversal, absolute paths, and null bytes before your function runs. See [Path Security](/servers/resources#path-security) if a template legitimately accepts those values.
|
||||
|
||||
## Prompts
|
||||
|
||||
The same collapse, one more time: `on_list_prompts` declares arguments as `PromptArgument` objects, `on_get_prompt` routes by name and assembles a `GetPromptResult` of `PromptMessage` objects. FastMCP takes a function whose parameters are the arguments.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
import mcp_types as types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
|
||||
async def list_prompts(ctx: ServerRequestContext, params) -> types.ListPromptsResult:
|
||||
return types.ListPromptsResult(
|
||||
prompts=[
|
||||
types.Prompt(
|
||||
name="review_code",
|
||||
description="Review code for issues",
|
||||
arguments=[
|
||||
types.PromptArgument(
|
||||
name="code", description="The code to review", required=True
|
||||
),
|
||||
types.PromptArgument(
|
||||
name="language", description="Programming language", required=False
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def get_prompt(
|
||||
ctx: ServerRequestContext, params: types.GetPromptRequestParams
|
||||
) -> types.GetPromptResult:
|
||||
if params.name != "review_code":
|
||||
raise ValueError(f"Unknown prompt: {params.name}")
|
||||
arguments = params.arguments or {}
|
||||
language = arguments.get("language", "")
|
||||
note = f" (written in {language})" if language else ""
|
||||
text = f"Please review this code{note}:\n\n{arguments.get('code', '')}"
|
||||
return types.GetPromptResult(
|
||||
description="Code review prompt",
|
||||
messages=[
|
||||
types.PromptMessage(
|
||||
role="user", content=types.TextContent(type="text", text=text)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
server = Server("prompts", on_list_prompts=list_prompts, on_get_prompt=get_prompt)
|
||||
```
|
||||
|
||||
```python After
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("prompts")
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def review_code(code: str, language: str | None = None) -> str:
|
||||
"""Review code for issues"""
|
||||
note = f" (written in {language})" if language else ""
|
||||
return f"Please review this code{note}:\n\n{code}"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Returning a `str` wraps it as a single user message. Whether an argument is required is read from the signature: `code` has no default, so it's required; `language` defaults to `None`, so it isn't. Multi-turn prompts return a list of `Message` objects, which take their text positionally and default to the user role:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.prompts import Message
|
||||
|
||||
mcp = FastMCP("prompts")
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def debug_session(error: str) -> list[Message]:
|
||||
"""Start a debugging conversation"""
|
||||
return [
|
||||
Message(f"I'm seeing this error:\n\n{error}"),
|
||||
Message("I'll help you debug that. Can you share the relevant code?", role="assistant"),
|
||||
]
|
||||
```
|
||||
|
||||
## Request Context
|
||||
|
||||
The low-level class hands each handler a `ServerRequestContext` carrying the raw `ServerSession`, and you reach through it to send notifications. FastMCP injects a typed `Context` into any function that declares one, and puts the operations you actually want on it directly.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
import mcp_types as types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
|
||||
async def call_tool(
|
||||
ctx: ServerRequestContext, params: types.CallToolRequestParams
|
||||
) -> types.CallToolResult:
|
||||
if params.name == "process_data":
|
||||
await ctx.session.send_log_message(level="info", data="Starting processing...")
|
||||
await ctx.session.report_progress(1, 2)
|
||||
# ... do work ...
|
||||
await ctx.session.send_log_message(level="info", data="Done!")
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Processed")]
|
||||
)
|
||||
raise ValueError(f"Unknown tool: {params.name}")
|
||||
|
||||
|
||||
server = Server("worker", on_call_tool=call_tool)
|
||||
```
|
||||
|
||||
```python After
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP("worker")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def process_data(ctx: Context) -> str:
|
||||
"""Process data with progress logging"""
|
||||
await ctx.info("Starting processing...")
|
||||
await ctx.report_progress(1, 2)
|
||||
# ... do work ...
|
||||
await ctx.info("Done!")
|
||||
return "Processed"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
The `Context` parameter is injected by type annotation and never appears in the tool's schema, so clients see `process_data` as taking no arguments. Beyond logging and progress, it carries resource reads, [session state](/servers/sessions), elicitation, and component visibility — see [Context](/servers/context) for the full surface.
|
||||
|
||||
One thing to check as you migrate: `ctx.session` still exists on a FastMCP `Context` as an escape hatch, and it hands back the same raw SDK session your handlers use today. That makes it a working translation for anything with no `Context` equivalent — but it's also the one part of your server that stays coupled to SDK internals, so reach for the `Context` method first and keep the escape hatch for what genuinely has no equivalent.
|
||||
|
||||
## Complete Example
|
||||
|
||||
Everything above, applied at once:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before expandable
|
||||
import json
|
||||
|
||||
import mcp_types as types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="greet",
|
||||
description="Greet someone by name",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def call_tool(
|
||||
ctx: ServerRequestContext, params: types.CallToolRequestParams
|
||||
) -> types.CallToolResult:
|
||||
if params.name == "greet":
|
||||
name = (params.arguments or {})["name"]
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text=f"Hello, {name}!")]
|
||||
)
|
||||
raise ValueError(f"Unknown tool: {params.name}")
|
||||
|
||||
|
||||
async def list_resources(ctx: ServerRequestContext, params) -> types.ListResourcesResult:
|
||||
return types.ListResourcesResult(
|
||||
resources=[
|
||||
types.Resource(
|
||||
uri="info://version", name="version", description="Server version"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def read_resource(
|
||||
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
|
||||
) -> types.ReadResourceResult:
|
||||
if str(params.uri) != "info://version":
|
||||
raise ValueError(f"Unknown resource: {params.uri}")
|
||||
return types.ReadResourceResult(
|
||||
contents=[
|
||||
types.TextResourceContents(
|
||||
uri=params.uri, text=json.dumps({"version": "1.0.0"})
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def list_prompts(ctx: ServerRequestContext, params) -> types.ListPromptsResult:
|
||||
return types.ListPromptsResult(
|
||||
prompts=[
|
||||
types.Prompt(
|
||||
name="summarize",
|
||||
description="Summarize text",
|
||||
arguments=[types.PromptArgument(name="text", required=True)],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def get_prompt(
|
||||
ctx: ServerRequestContext, params: types.GetPromptRequestParams
|
||||
) -> types.GetPromptResult:
|
||||
if params.name != "summarize":
|
||||
raise ValueError(f"Unknown prompt: {params.name}")
|
||||
text = (params.arguments or {}).get("text", "")
|
||||
return types.GetPromptResult(
|
||||
description="Summarize text",
|
||||
messages=[
|
||||
types.PromptMessage(
|
||||
role="user",
|
||||
content=types.TextContent(type="text", text=f"Summarize:\n\n{text}"),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
server = Server(
|
||||
"demo",
|
||||
on_list_tools=list_tools,
|
||||
on_call_tool=call_tool,
|
||||
on_list_resources=list_resources,
|
||||
on_read_resource=read_resource,
|
||||
on_list_prompts=list_prompts,
|
||||
on_get_prompt=get_prompt,
|
||||
)
|
||||
```
|
||||
|
||||
```python After
|
||||
import json
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("demo")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
|
||||
@mcp.resource("info://version")
|
||||
def version() -> str:
|
||||
"""Server version"""
|
||||
return json.dumps({"version": "1.0.0"})
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def summarize(text: str) -> str:
|
||||
"""Summarize text"""
|
||||
return f"Summarize:\n\n{text}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## What You Gain
|
||||
|
||||
Deleting the handler machinery is the immediate payoff, but the reason to make this move is what becomes available once your server is a FastMCP server.
|
||||
|
||||
[Server composition](/servers/composition) mounts one server inside another, so a surface that grew unwieldy as a single dispatch chain splits into modules developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching, with hooks at whichever level of specificity you need — the cross-cutting concerns that, on the low-level class, meant threading the same code through every handler. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control, and the [OpenAPI integration](/integrations/openapi) generates an entire server from an API specification you already have. [Authentication](/servers/auth/authentication) consolidates the SDK's separate token verifier, authorization-server provider, and `AuthSettings` into a single `auth=` provider, with named providers for GitHub, Google, Auth0, Keycloak, and others.
|
||||
|
||||
The change most likely to affect your daily work is [testing](/servers/testing). FastMCP ships a client that connects to a server object in the same Python process, so a test calls your tools directly — no subprocess, no stdio pipes, no transport to stand up.
|
||||
264
docs/getting-started/upgrading/from-mcp-sdk-v1.mdx
Normal file
264
docs/getting-started/upgrading/from-mcp-sdk-v1.mdx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
---
|
||||
title: Upgrading from MCP SDK v1
|
||||
sidebarTitle: "From MCP SDK v1"
|
||||
description: Upgrade from FastMCP 1.0, bundled in v1 of the MCP Python SDK, to the standalone FastMCP framework
|
||||
icon: up
|
||||
---
|
||||
|
||||
If your server starts with `from mcp.server.fastmcp import FastMCP`, you're using FastMCP 1.0 — the version bundled with v1 of the `mcp` package. Upgrading to the standalone FastMCP framework is easy. **For most servers, it's a single import change.**
|
||||
|
||||
```python test="skip"
|
||||
# Before
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
# After
|
||||
from fastmcp import FastMCP
|
||||
```
|
||||
|
||||
That's it. Your `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` decorators, your `mcp.run()` call, and the rest of your server code all work as-is.
|
||||
|
||||
<Tip>
|
||||
**Why upgrade?** FastMCP 1.0 pioneered the Pythonic MCP server experience, and we're proud it was bundled into the `mcp` package. The standalone FastMCP project has since grown into a full framework for taking MCP servers from prototype to production — with composition, middleware, proxy servers, authentication, and much more. Upgrading gives you access to all of that, plus ongoing updates and fixes.
|
||||
</Tip>
|
||||
|
||||
## The SDK v2 Transition
|
||||
|
||||
MCP SDK v2 is a substantial, deliberate modernization of the protocol layer, and part of that work rebuilt the high-level server as `MCPServer` under `mcp.server.mcpserver`. `mcp.server.fastmcp` does not exist there — so a FastMCP 1.0 server meets the change the moment its environment resolves `mcp` to v2:
|
||||
|
||||
```
|
||||
ModuleNotFoundError: No module named 'mcp.server.fastmcp'
|
||||
```
|
||||
|
||||
Often nobody chose that moment. An unpinned `mcp` dependency, a fresh lockfile, or a rebuilt container picks up the new major version and the module your server imports on line one has moved. Nothing is wrong with your code, and nothing is wrong with the SDK — major versions are exactly where a change like this belongs. Your build just crossed it earlier than you planned to.
|
||||
|
||||
Pinning the SDK back restores the old module immediately, with no code changes, and buys you time to choose deliberately:
|
||||
|
||||
```bash
|
||||
pip install "mcp<2"
|
||||
```
|
||||
|
||||
## Two Upgrade Paths
|
||||
|
||||
From here, both directions are reasonable, and which is less work depends on which API you already write.
|
||||
|
||||
**`MCPServer`, the SDK's high-level server**, is a capable, well-designed API and the direct continuation of the SDK's own line. Because it was rebuilt rather than renamed, expect real work: a new class and import, a different decorator call style, and protocol types imported from the standalone `mcp_types` package with snake_case field names.
|
||||
|
||||
**FastMCP** is the import change at the top of this page. It is short for a specific, historical reason: FastMCP 1.0 *is* early FastMCP — it was contributed into the `mcp` package, and the standalone project kept developing that same high-level API. The surface you already write against is the surface FastMCP still offers. FastMCP 4 is itself built on MCP SDK v2, so both paths land you on the same modern protocol layer; FastMCP absorbs the adaptation internally rather than asking your code to do it.
|
||||
|
||||
The claim is narrower than it may sound. It holds for FastMCP 1.0 servers specifically, because of shared lineage — not because one library is better than the other. Both projects are moving the same direction on the same protocol.
|
||||
|
||||
If you have already moved to SDK v2 and write against `MCPServer` today, see [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2). If your server uses the low-level `Server` class rather than the high-level one, see [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1).
|
||||
|
||||
## Install
|
||||
|
||||
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
|
||||
|
||||
```bash
|
||||
pip install "fastmcp==4.0.0b1"
|
||||
# or
|
||||
uv add "fastmcp==4.0.0b1"
|
||||
```
|
||||
|
||||
An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
|
||||
|
||||
FastMCP depends on the `mcp` package, so the SDK stays installed and importable. What changes is which parts of it you reach for. FastMCP 4 builds on SDK v2, where `mcp.server.fastmcp` is gone — anything you imported from it needs a new home, and the sections below cover that. `mcp.types` still resolves (it aliases the standalone `mcp_types` package), though its fields are snake_case now. Update your import, run your server, and if your tools work, you're done.
|
||||
|
||||
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
|
||||
You are upgrading an MCP server from FastMCP 1.0 (bundled in v1 of the `mcp` package) to standalone FastMCP 4.
|
||||
|
||||
FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1 — it explains every item below, with the replacement code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs.
|
||||
|
||||
For most servers the entire upgrade is the first item. Work through the rest looking for signals, and report only what you actually find.
|
||||
|
||||
THE IMPORT (every server needs this)
|
||||
- `from mcp.server.fastmcp import FastMCP` → `from fastmcp import FastMCP`
|
||||
- `from mcp.server.fastmcp import Context`
|
||||
- `from mcp.server.fastmcp import Image`
|
||||
|
||||
CONSTRUCTOR ARGUMENTS THAT MOVED (all raise TypeError)
|
||||
- moved to run()/http_app(), and FastMCP names them in the error: host, port, log_level, debug, sse_path, message_path, streamable_http_path, json_response, stateless_http
|
||||
- moved but rejected with only a generic "unexpected keyword argument", so flag these explicitly: `event_store=` (→ `http_app(event_store=...)`; dropping it silently disables streamable-HTTP resumability), `mount_path=` (→ `http_app(path=...)`), `transport=` (→ `run(transport=...)`), `transport_security=` (→ host/origin settings on `http_app()`), `warn_on_duplicate_tools/_resources/_prompts=` (→ one `on_duplicate=`), `dependencies=` (→ a fastmcp.json file)
|
||||
- `name`, `instructions`, `website_url`, `icons`, `tools`, `lifespan` carry over unchanged
|
||||
- note when reporting: FastMCP names the streamable HTTP transport "http", not "streamable-http"
|
||||
|
||||
CONTEXT METHODS WITH CHANGED SIGNATURES (compile fine, fail at runtime)
|
||||
- `ctx.log(level, data)` → `ctx.log(message, level=...)`, message first
|
||||
- `ctx.info(data)` / `debug` / `warning` / `error` → take a str message, not arbitrary JSON-serializable data
|
||||
- `ctx.elicit(..., schema=Model)` → `response_type=Model`
|
||||
- `ctx.read_resource(uri)` → returns a `ResourceResult`; read `.contents` rather than iterating the return value
|
||||
- `ctx.report_progress`, `ctx.request_id`, `ctx.client_id` are unchanged
|
||||
|
||||
AUTHENTICATION (the one case where the single import change is NOT enough)
|
||||
- `token_verifier=` and `auth_server_provider=` — both raise TypeError on FastMCP 4
|
||||
- `auth=AuthSettings(...)` — the keyword survives but the value does not: FastMCP's `auth=` takes a FastMCP `AuthProvider`, not the SDK settings object
|
||||
Report these as a real migration, not a rename: FastMCP consolidates all three into one provider, and ships `JWTVerifier` for tokens you already issue, `RemoteAuthProvider` for delegating to an external authorization server, `OAuthProxy` for wrapping a provider without Dynamic Client Registration, and named providers for GitHub, Google, Auth0, Keycloak, and others. Look up the right one at https://gofastmcp.com/servers/auth/authentication rather than guessing.
|
||||
|
||||
PROMPT RETURN VALUES
|
||||
- prompt functions returning `PromptMessage`, or `TextContent`-wrapped content
|
||||
- prompt functions returning raw dicts with "role"/"content" keys — FastMCP 1.0 coerced these silently, standalone FastMCP does not
|
||||
|
||||
OTHER mcp.* IMPORTS
|
||||
- anything from `mcp.types` — the import path still works in the SDK v2 that FastMCP 4 builds on, but the fields were renamed from camelCase to snake_case
|
||||
- `from mcp.server.stdio import stdio_server` and any transport boilerplate around it
|
||||
- `mcp.types.TextContent` / `ImageContent` used to wrap tool return values — FastMCP has friendlier equivalents, so prefer those over keeping the raw protocol types
|
||||
|
||||
DECORATOR RETURN VALUES
|
||||
- any code reading `.name`, `.description`, or other component attributes off a `@mcp.tool` / `@mcp.resource` / `@mcp.prompt` decorated function. Decorators return the original function now.
|
||||
|
||||
For each item found, show the original line, name what changed, and give the corrected code from the guide. If the only change needed is the import, say so plainly rather than manufacturing work.
|
||||
</Prompt>
|
||||
|
||||
## What Might Need Updating
|
||||
|
||||
Most servers need nothing beyond the import change. Skim the sections below to see if any apply.
|
||||
|
||||
### Constructor Settings
|
||||
|
||||
If you passed transport settings like `host` or `port` directly to `FastMCP()`, those now belong on `run()`. This keeps your server definition independent of how it's deployed:
|
||||
|
||||
```python test="skip"
|
||||
from fastmcp import FastMCP
|
||||
|
||||
# Before
|
||||
mcp = FastMCP("my-server", host="0.0.0.0", port=8080)
|
||||
mcp.run()
|
||||
|
||||
# After
|
||||
mcp = FastMCP("my-server")
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8080)
|
||||
```
|
||||
|
||||
Nine arguments move this way, and each raises a `TypeError` naming its own replacement, so you can also just run the server and follow the errors: `host`, `port`, `log_level`, `debug`, `sse_path`, `message_path`, `streamable_http_path`, `json_response`, and `stateless_http`.
|
||||
|
||||
A second group is rejected with only a generic "unexpected keyword argument" and no hint, which makes these the ones worth reading in advance:
|
||||
|
||||
| SDK v1 `FastMCP(...)` | FastMCP 4 |
|
||||
|---|---|
|
||||
| `event_store=` | `mcp.http_app(event_store=...)` |
|
||||
| `mount_path=` | `mcp.http_app(path=...)` |
|
||||
| `transport=` | `mcp.run(transport=...)` |
|
||||
| `transport_security=` | `host_origin_protection=`, `allowed_hosts=`, `allowed_origins=` on `http_app()` |
|
||||
| `warn_on_duplicate_tools=`, `_resources=`, `_prompts=` | a single `on_duplicate=` |
|
||||
| `dependencies=[...]` | a [`fastmcp.json`](/deployment/server-configuration) configuration file |
|
||||
| `auth_server_provider=`, `token_verifier=` | a single `auth=` provider — see [Authentication](#authentication) below |
|
||||
|
||||
Dropping `event_store=` rather than moving it is the one to watch: it silently disables streamable-HTTP resumability, so a client that reconnects loses the events it missed instead of replaying them.
|
||||
|
||||
`name`, `instructions`, `website_url`, `icons`, `tools`, and `lifespan` carry over to the constructor unchanged.
|
||||
|
||||
### Authentication
|
||||
|
||||
This is the one case where the import change alone won't do. FastMCP 1.0 exposed the SDK's auth plumbing as three separate constructor arguments — `token_verifier=`, `auth_server_provider=`, and `auth=AuthSettings(...)`. The first two raise `TypeError` on FastMCP 4, and while `auth=` survives as a keyword, its value doesn't: FastMCP expects one of its own `AuthProvider` objects rather than the SDK's settings object.
|
||||
|
||||
The replacement is a single provider carrying the whole configuration, chosen by what you're actually doing:
|
||||
|
||||
| What you were doing | FastMCP provider |
|
||||
|---|---|
|
||||
| Validating JWTs you already issue | `JWTVerifier` |
|
||||
| Delegating to an external authorization server | `RemoteAuthProvider` |
|
||||
| Wrapping a provider without Dynamic Client Registration | `OAuthProxy` |
|
||||
| GitHub, Google, Auth0, Keycloak, WorkOS, … | the matching named provider |
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import JWTVerifier
|
||||
|
||||
mcp = FastMCP("my-server", auth=JWTVerifier(jwks_uri="https://example.com/.well-known/jwks.json"))
|
||||
```
|
||||
|
||||
See [Authentication](/servers/auth/authentication) for the full set and their configuration.
|
||||
|
||||
### Context Methods
|
||||
|
||||
`from fastmcp import Context` gets you the injected context object, but four of its methods took a different shape in FastMCP 1.0, and a bare import swap leaves calls that compile and then fail:
|
||||
|
||||
| SDK v1 | FastMCP 4 |
|
||||
|---|---|
|
||||
| `ctx.log(level, data)` | `ctx.log(message, level=...)` — message is first now |
|
||||
| `ctx.info(data)` and its `debug`/`warning`/`error` siblings | take a `str` message, where v1 accepted any JSON-serializable value |
|
||||
| `ctx.elicit(message, schema=Model)` | `ctx.elicit(message, response_type=Model)` |
|
||||
| `ctx.read_resource(uri)` | returns a `ResourceResult`; the payload is under `.contents` rather than being iterable directly |
|
||||
|
||||
`ctx.report_progress()`, `ctx.request_id`, and `ctx.client_id` are unchanged.
|
||||
|
||||
### Prompts
|
||||
|
||||
If your prompt functions return `mcp.types.PromptMessage` objects or raw dicts with `role`/`content` keys, upgrade them to FastMCP's `Message` class. Or just return a plain string — it's automatically wrapped as a user message. FastMCP 1.0 silently coerced dicts into messages; standalone FastMCP requires typed `Message` objects or strings.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("prompts")
|
||||
|
||||
@mcp.prompt
|
||||
def review(code: str) -> str:
|
||||
"""Review code for issues"""
|
||||
return f"Please review this code:\n\n{code}"
|
||||
```
|
||||
|
||||
Multi-turn prompts return a list of messages. `Message` takes the text positionally and defaults to the user role, so only the assistant turns need a `role`:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.prompts import Message
|
||||
|
||||
mcp = FastMCP("prompts")
|
||||
|
||||
@mcp.prompt
|
||||
def debug(error: str) -> list[Message]:
|
||||
"""Start a debugging session"""
|
||||
return [
|
||||
Message(f"I'm seeing this error:\n\n{error}"),
|
||||
Message("I'll help debug that. Can you share the relevant code?", role="assistant"),
|
||||
]
|
||||
```
|
||||
|
||||
### Other `mcp.*` Imports
|
||||
|
||||
FastMCP 4 builds on MCP SDK v2, which moved the protocol types into a standalone `mcp_types` package and re-exports it as `mcp.types` — so `from mcp.types import X` keeps working. The field names did change, from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, and so on). For everything else SDK v2 changed, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3), which covers the same protocol rebuild from the FastMCP side.
|
||||
|
||||
Where FastMCP provides its own API for the same thing, it's worth switching over rather than importing the protocol type:
|
||||
|
||||
| MCP SDK v1 | FastMCP equivalent |
|
||||
|---|---|
|
||||
| `mcp.types.TextContent(type="text", text=str(x))` | Just return `x` from your tool |
|
||||
| `mcp.types.ImageContent(...)` | `from fastmcp.utilities.types import Image` |
|
||||
| `mcp.types.PromptMessage(...)` | `from fastmcp.prompts import Message` |
|
||||
| `mcp.server.fastmcp.Context` | `from fastmcp import Context` |
|
||||
| `from mcp.server.stdio import stdio_server` | Not needed — `mcp.run()` handles transport |
|
||||
|
||||
For protocol types without a FastMCP equivalent, import them from `mcp_types` directly.
|
||||
|
||||
### Decorated Functions
|
||||
|
||||
In FastMCP 1.0, `@mcp.tool` replaced your function with a `FunctionTool` object. Now decorators return your original function unchanged, so decorated functions stay callable for testing, reuse, and composition:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("greeter")
|
||||
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# This works now — the function is still a regular function
|
||||
assert greet("World") == "Hello, World!"
|
||||
```
|
||||
|
||||
Code that reads `.name`, `.description`, or other component attributes off the decorated result needs updating. This is uncommon — most servers never touch the tool object. When you do need the component itself, reach it through the server with `await mcp.get_tool("greet")`.
|
||||
|
||||
## Verifying the Upgrade
|
||||
|
||||
Run your server the way you always have. To confirm every component came across, inspect the server with the FastMCP CLI:
|
||||
|
||||
```bash
|
||||
fastmcp inspect my_server.py
|
||||
```
|
||||
|
||||
The output lists every tool, resource, template, and prompt your server exposes, so a component that failed to register shows up here rather than at the first client call.
|
||||
|
||||
## Looking Ahead
|
||||
|
||||
The MCP ecosystem is evolving fast. Part of FastMCP's job is to absorb that complexity on your behalf — as the protocol and its tooling grow, we do the work so your server code doesn't have to change. The SDK v1 to v2 transition is the clearest example so far: an entire protocol layer was rewritten underneath FastMCP 4, and the servers on this page cross it with one line.
|
||||
328
docs/getting-started/upgrading/from-mcp-sdk-v2.mdx
Normal file
328
docs/getting-started/upgrading/from-mcp-sdk-v2.mdx
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
---
|
||||
title: Upgrading from MCP SDK v2
|
||||
sidebarTitle: "From MCP SDK v2"
|
||||
description: Move a server built on the MCP Python SDK v2's MCPServer class to FastMCP
|
||||
icon: up
|
||||
---
|
||||
|
||||
If your server starts with `from mcp.server.mcpserver import MCPServer`, you're using the high-level server API introduced in v2 of the `mcp` package. Moving to FastMCP is a mechanical migration: the two APIs share a lineage, so most of your code carries over with a rename.
|
||||
|
||||
```python
|
||||
# Before
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
server = MCPServer("my-server")
|
||||
|
||||
@server.tool()
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# After
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name"""
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
That resemblance is not a coincidence. `MCPServer` is the SDK's successor to FastMCP 1.0, the high-level server that shipped inside SDK v1; FastMCP is the standalone framework that grew from the same starting point. Both derive the protocol layer from your function signatures — type hints become JSON Schema, docstrings become descriptions, return values are serialized for you. What separates them is scope: `MCPServer` is the SDK's ergonomic surface over the protocol, while FastMCP builds on that same SDK v2 and adds the machinery a server needs in production — composition, middleware, proxying, authentication providers, tool transformation, a client, and a testing story.
|
||||
|
||||
<Note>
|
||||
Building on the low-level `Server` class instead? See [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2). Still on SDK v1's `mcp.server.fastmcp.FastMCP`? Your upgrade is a single import — see [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1).
|
||||
</Note>
|
||||
|
||||
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
|
||||
You are migrating an MCP server from the MCP Python SDK v2's high-level `MCPServer` class (`mcp.server.mcpserver`) to FastMCP 4. The two APIs are close relatives, so most of this is mechanical renaming.
|
||||
|
||||
FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2 — it carries the full mapping table and before-and-after code for everything below. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs.
|
||||
|
||||
Then work through the provided code looking for each of these.
|
||||
|
||||
IMPORTS AND CONSTRUCTION
|
||||
- `MCPServer`, and `Context`, `Image`, `Audio`, `Message` imported from `mcp.server.mcpserver`
|
||||
- `mcp_types` imports — these are UNCHANGED. FastMCP 4 builds on the same SDK v2, so leave them alone and say so.
|
||||
|
||||
DECORATORS
|
||||
- `@server.tool()`, `@server.prompt()` — FastMCP takes a bare `@mcp.tool` / `@mcp.prompt` (and still accepts the called form)
|
||||
- `@server.resource(...)`, `@server.completion()`, `@server.custom_route(...)`
|
||||
|
||||
TRANSPORT
|
||||
- `run(transport="streamable-http")` — FastMCP names this transport "http"
|
||||
- `streamable_http_app()`, `sse_app()`
|
||||
|
||||
CONSTRUCTOR ARGUMENTS THAT DO NOT CARRY OVER
|
||||
- `debug=`, `log_level=`
|
||||
- `warn_on_duplicate_tools=` / `_resources=` / `_prompts=`
|
||||
- `dependencies=`
|
||||
- `title=`, `description=`
|
||||
- `token_verifier=`, `auth_server_provider=`, `auth=AuthSettings(...)` — FastMCP consolidates all three into one `auth=` provider
|
||||
- `cache_hints=`
|
||||
- `extensions=`
|
||||
- `tools=[...]` (rare — the SDK's `Tool` type is not exported): FastMCP takes plain callables, so pass the underlying functions
|
||||
These raise TypeError, most naming their replacement. `name`, `version`, `instructions`, `icons`, `website_url`, `lifespan`, `resource_security`, and `request_state_security` carry over unchanged.
|
||||
|
||||
CONTEXT — these ten properties do NOT exist on FastMCP's Context and raise AttributeError if you only swap the import:
|
||||
- `ctx.mcp_server` → `ctx.fastmcp`
|
||||
- `ctx.headers` → `get_http_headers()` from `fastmcp.server.dependencies` (a function, not a property)
|
||||
- `ctx.protocol_version` → `ctx.request_context.protocol_version`
|
||||
- `ctx.client_capabilities` → read it off `ctx.session` / `ctx.request_context`
|
||||
- `ctx.notify_tools_changed()`, `notify_resources_changed()`, `notify_prompts_changed()`, `notify_resource_updated()` → `ctx.send_notification(...)` with the matching `mcp_types` notification. FastMCP emits the list-changed ones for you when components change visibility through `ctx.enable_components` / `ctx.disable_components`.
|
||||
- `ctx.elicit_url` → not the same thing as `ctx.elicit` (that one is form elicitation, with a different signature and wire behavior). The URL flow survives on the raw session as `ctx.session.elicit_url(...)` — use that rather than deleting an OAuth or payment handoff.
|
||||
- `ctx.close_standalone_sse_stream` → no public FastMCP equivalent, and NOT on `ctx.request_context`. Flag it for the user.
|
||||
These four exist on both but with DIFFERENT signatures, so a bare import swap compiles and then fails at runtime:
|
||||
- `ctx.log(level, data)` → `ctx.log(message, level=...)` — the first positional argument is now the message, not the level
|
||||
- `ctx.info(data)` / `debug` / `warning` / `error` → these take `message` as a string, where the SDK accepted any JSON-serializable `data`
|
||||
- `ctx.elicit(message, schema=Model)` → `ctx.elicit(message, response_type=Model)` — the keyword was renamed
|
||||
- `ctx.read_resource(uri)` → still takes a URI, but returns a `ResourceResult` whose payload is under `.contents`, where the SDK returned an iterable of content objects directly. Code that iterates or indexes the return value needs updating.
|
||||
|
||||
Genuinely unchanged: `report_progress`, `request_id`, `client_id`, `input_responses`, `request_state`, `session`, and `request_context`.
|
||||
|
||||
RESOLVERS — the one part that is not a rename, so check for it first
|
||||
- any `Annotated[T, Resolve(fn)]` parameter, and the resolvers behind it
|
||||
- resolvers returning `Elicit[...]`, `Sample`, or `ListRoots`
|
||||
FastMCP has no resolver injection, but the underlying requests survive in a different shape: on a modern connection `Elicit`, `Sample`, and `ListRoots` all ride the guard pattern, where the tool returns an `InputRequiredResult` and the client answers on the next call. Do not tell the user these capabilities are simply unavailable. Flag every resolver with the guide's per-capability reasoning (server-side LLM call is usually better than guard-routed sampling; roots are often simplest as ordinary tool arguments) rather than picking a rewrite yourself. Also note that a resolved parameter is hidden from the tool's input schema, so replacing it with an ordinary argument changes the schema clients see.
|
||||
|
||||
For each item found, show the original code, name what changed, and give the FastMCP equivalent from the guide. Call out anything you could not find a documented replacement for instead of inventing one.
|
||||
</Prompt>
|
||||
|
||||
## Install
|
||||
|
||||
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
|
||||
|
||||
```bash
|
||||
pip install "fastmcp==4.0.0b1"
|
||||
# or
|
||||
uv add "fastmcp==4.0.0b1"
|
||||
```
|
||||
|
||||
An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
|
||||
|
||||
FastMCP 4 depends on the MCP SDK v2, so nothing you already import from `mcp_types` moves. That is the practical benefit of migrating at this version rather than an earlier one: you and FastMCP are on the same protocol layer, with the same snake_case field names and the same type package, so the migration touches only the server API.
|
||||
|
||||
## The Mechanical Part
|
||||
|
||||
Most of the work is renaming. This table covers the surfaces a typical `MCPServer` server touches:
|
||||
|
||||
| MCP SDK v2 | FastMCP |
|
||||
|---|---|
|
||||
| `from mcp.server.mcpserver import MCPServer` | `from fastmcp import FastMCP` |
|
||||
| `from mcp.server.mcpserver import Context` | `from fastmcp import Context` |
|
||||
| `from mcp.server.mcpserver import Image, Audio` | `from fastmcp.utilities.types import Image, Audio` |
|
||||
| `from mcp.server.mcpserver.prompts.base import Message` | `from fastmcp.prompts import Message` |
|
||||
| `@server.tool()` | `@mcp.tool` |
|
||||
| `@server.prompt()` | `@mcp.prompt` |
|
||||
| `@server.resource("uri://x")` | `@mcp.resource("uri://x")` |
|
||||
| `@server.completion()` | `@mcp.completion` |
|
||||
| `@server.custom_route(path, methods)` | `@mcp.custom_route(path, methods)` |
|
||||
| `server.run(transport="streamable-http")` | `mcp.run(transport="http")` |
|
||||
| `server.streamable_http_app()` | `mcp.http_app()` |
|
||||
| `server.sse_app()` | `mcp.http_app(transport="sse")` |
|
||||
| `ctx.mcp_server` | `ctx.fastmcp` |
|
||||
| `ctx.headers` | `get_http_headers()` from `fastmcp.server.dependencies` |
|
||||
| `ctx.protocol_version` | `ctx.request_context.protocol_version` |
|
||||
| `ctx.client_capabilities` | read it off `ctx.session` |
|
||||
| `from mcp_types import X` | unchanged |
|
||||
|
||||
Two of these are worth a sentence each. The decorators lose their parentheses: `MCPServer` required `@server.tool()` and raised a `TypeError` telling you so if you wrote `@server.tool`, while FastMCP accepts both forms, so `@mcp.tool` is the idiomatic spelling and `@mcp.tool()` keeps working if you'd rather not touch every line. And the streamable HTTP transport is named `"http"` in FastMCP rather than `"streamable-http"` — the transport is the same, and `mcp.run()` still defaults to stdio.
|
||||
|
||||
Here is a complete server before and after. Nothing in the logic changes:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
import json
|
||||
from mcp.server.mcpserver import MCPServer, Context
|
||||
|
||||
server = MCPServer("demo")
|
||||
|
||||
@server.tool()
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
@server.tool()
|
||||
async def process(items: list[str], ctx: Context) -> str:
|
||||
"""Process a batch of items"""
|
||||
for i, item in enumerate(items):
|
||||
await ctx.report_progress(i, len(items))
|
||||
return f"Processed {len(items)} items"
|
||||
|
||||
@server.resource("config://app", mime_type="application/json")
|
||||
def app_config() -> str:
|
||||
"""Application configuration"""
|
||||
return json.dumps({"debug": False})
|
||||
|
||||
@server.resource("users://{user_id}/profile")
|
||||
def profile(user_id: str) -> str:
|
||||
"""User profile by ID"""
|
||||
return json.dumps({"id": user_id})
|
||||
|
||||
@server.prompt()
|
||||
def summarize(text: str) -> str:
|
||||
"""Summarize text"""
|
||||
return f"Summarize:\n\n{text}"
|
||||
|
||||
if __name__ == "__main__":
|
||||
server.run(transport="streamable-http")
|
||||
```
|
||||
|
||||
```python After
|
||||
import json
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP("demo")
|
||||
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
@mcp.tool
|
||||
async def process(items: list[str], ctx: Context) -> str:
|
||||
"""Process a batch of items"""
|
||||
for i, item in enumerate(items):
|
||||
await ctx.report_progress(i, len(items))
|
||||
return f"Processed {len(items)} items"
|
||||
|
||||
@mcp.resource("config://app", mime_type="application/json")
|
||||
def app_config() -> str:
|
||||
"""Application configuration"""
|
||||
return json.dumps({"debug": False})
|
||||
|
||||
@mcp.resource("users://{user_id}/profile")
|
||||
def profile(user_id: str) -> str:
|
||||
"""User profile by ID"""
|
||||
return json.dumps({"id": user_id})
|
||||
|
||||
@mcp.prompt
|
||||
def summarize(text: str) -> str:
|
||||
"""Summarize text"""
|
||||
return f"Summarize:\n\n{text}"
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="http")
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Constructor Arguments
|
||||
|
||||
`FastMCP()` describes your server's identity and behavior; how it gets deployed is decided when you serve it. Several `MCPServer` constructor arguments move accordingly, and each raises a `TypeError` naming its replacement rather than being silently ignored.
|
||||
|
||||
`name`, `version`, `instructions`, `icons`, `website_url`, `lifespan`, `resource_security`, and `request_state_security` all mean what they meant before. The rest map like this:
|
||||
|
||||
| `MCPServer(...)` | FastMCP |
|
||||
|---|---|
|
||||
| `debug=True` | `FASTMCP_DEBUG` environment variable |
|
||||
| `log_level="DEBUG"` | `run_http_async(log_level=...)` or `FASTMCP_LOG_LEVEL` |
|
||||
| `warn_on_duplicate_tools`, `_resources`, `_prompts` | a single `on_duplicate=` |
|
||||
| `dependencies=[...]` | a [`fastmcp.json`](/deployment/server-configuration) configuration file |
|
||||
| `title=`, `description=` | `instructions=` |
|
||||
| `tools=[Tool, ...]` | `tools=[callable, ...]`, or FastMCP's own `Tool` |
|
||||
| `resources=[Resource, ...]` | no constructor keyword — register with `@mcp.resource` or `mcp.add_resource()` |
|
||||
| `subscriptions=<SubscriptionBus>` | no equivalent — see below |
|
||||
| `token_verifier=`, `auth_server_provider=`, `auth=AuthSettings(...)` | a single `auth=` provider |
|
||||
| `cache_hints={...}` | `cache_ttl=`, `cache_scope=` |
|
||||
| `extensions=[...]` | `mcp.add_extension(...)` |
|
||||
| `middleware=[ServerMiddleware, ...]` | `middleware=[Middleware, ...]` — same keyword, different class |
|
||||
|
||||
`middleware=` is the row most likely to be mistaken for a rename. Both constructors take a `middleware=` sequence, but an `MCPServer` wants the SDK's `ServerMiddleware` — one hook wrapping every raw JSON-RPC message — while FastMCP wants its own `Middleware`, which adds typed per-operation hooks (`on_call_tool`, `on_list_tools`, and the rest) on top of the same message-level pass. Keeping the keyword and swapping the base class is the migration; see [Middleware](/servers/middleware).
|
||||
|
||||
Authentication is the largest of these, and it consolidates rather than moves. `MCPServer` exposes the SDK's raw auth plumbing — a token verifier, an authorization-server provider, and an `AuthSettings` object, configured separately. FastMCP takes one `auth=` provider that carries the whole configuration, and ships providers for the common cases: `JWTVerifier` for validating tokens you already issue, `RemoteAuthProvider` for delegating to an external authorization server, `OAuthProxy` for wrapping a provider that lacks Dynamic Client Registration, and named providers for GitHub, Google, Auth0, Keycloak, WorkOS, and others. See [Authentication](/servers/auth/authentication).
|
||||
|
||||
Two rows are worth reading before you delete the argument. `resources=` has no constructor equivalent, so pre-built `Resource` objects need registering through `@mcp.resource` or `mcp.add_resource()` instead — dropping the keyword silently drops the resources with it. And `subscriptions=`, which an `MCPServer` uses to plug in an external pub/sub bus so resource-update notifications reach clients across replicas, has no FastMCP equivalent at all. A multi-replica deployment that relies on it should confirm it can live without cross-replica subscription fan-out before migrating, because a mechanical rename removes that behavior without any error to warn you.
|
||||
|
||||
### Serving HTTP
|
||||
|
||||
Renaming `streamable_http_app()` to `http_app()` is only mechanical for a call with no arguments. The keywords were renamed and regrouped, so an existing call carries arguments `http_app()` does not accept:
|
||||
|
||||
| SDK v2 | FastMCP |
|
||||
|---|---|
|
||||
| `streamable_http_app(streamable_http_path=...)` | `http_app(path=...)` |
|
||||
| `sse_app(sse_path=...)` | `http_app(path=..., transport="sse")` |
|
||||
| `sse_app(message_path=...)` | no equivalent |
|
||||
| `transport_security=TransportSecuritySettings(...)` | `host_origin_protection=`, `allowed_hosts=`, `allowed_origins=` |
|
||||
| `host=...` | pass to `mcp.run(host=...)` instead |
|
||||
|
||||
`json_response`, `stateless_http`, `event_store`, and `retry_interval` keep their names. See [Deploying HTTP servers](/deployment/http) for the host and origin settings.
|
||||
|
||||
### Stricter Arguments
|
||||
|
||||
One behavior change survives the rename and is worth knowing before you migrate. `MCPServer` binds the arguments it recognizes and ignores the rest, so a call carrying an unexpected key succeeds. FastMCP declares `"additionalProperties": false` on every generated schema and enforces it, so the same call fails:
|
||||
|
||||
```python test="skip"
|
||||
# Against MCPServer: succeeds, "extra" ignored.
|
||||
# Against FastMCP: raises, "extra" is not a parameter of greet().
|
||||
await client.call_tool("greet", {"name": "World", "extra": "surprise"})
|
||||
```
|
||||
|
||||
For most servers this is an improvement that costs nothing — a caller sending keys your tool never reads was already a bug. It matters if a client in your fleet passes extra metadata alongside real arguments, since those calls start failing the moment you migrate. Accept the extras explicitly as optional parameters if you need to keep them working.
|
||||
|
||||
## Asking for Input
|
||||
|
||||
This is the one part of the migration that is not a rename, so read it before you start if your tools use resolvers.
|
||||
|
||||
`MCPServer` asks the client for things through dependency-injection resolvers. A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body, and the resolver can return a request marker — `Elicit[T]` to ask the user, `Sample` to borrow the client's model, `ListRoots` to fetch its roots — which the framework turns into the right wire interaction for whichever protocol era the connection negotiated:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from pydantic import BaseModel
|
||||
from mcp.server.mcpserver import MCPServer, Resolve, Elicit
|
||||
|
||||
server = MCPServer("booking")
|
||||
|
||||
|
||||
class Destination(BaseModel):
|
||||
destination: str
|
||||
|
||||
|
||||
def ask_destination() -> Elicit[Destination]:
|
||||
return Elicit("Where would you like to fly?", Destination)
|
||||
|
||||
|
||||
@server.tool()
|
||||
def book_flight(dest: Annotated[Destination, Resolve(ask_destination)]) -> str:
|
||||
"""Book a flight"""
|
||||
return f"Booked to {dest.destination}"
|
||||
```
|
||||
|
||||
FastMCP has no equivalent annotation, and it makes the protocol era explicit instead of hiding it. Which replacement you want depends on which era your clients speak.
|
||||
|
||||
On **handshake-era connections** (≤ 2025-11-25), a running tool asks the user directly with `ctx.elicit()`, and the call blocks until the answer arrives. Where the resolver returned a value or aborted the call, `ctx.elicit()` hands you the outcome to branch on, so declining and cancelling become cases your tool answers for itself:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP("booking")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def book_flight(ctx: Context) -> str:
|
||||
"""Book a flight"""
|
||||
result = await ctx.elicit("Where would you like to fly?", response_type=str)
|
||||
if result.action == "accept":
|
||||
return f"Booked to {result.data}"
|
||||
return "Booking cancelled"
|
||||
```
|
||||
|
||||
On the **modern protocol** (2026-07-28), server-initiated requests are gone from the wire, so a tool asks by *returning* a description of what it needs. The client answers and calls the tool again with the answer attached, and the tool re-runs from the top. This is the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), and it reads the answers off `ctx.input_responses`.
|
||||
|
||||
The two are era-gated in both directions: `ctx.elicit()` raises on a modern connection, and a guard result raises on a handshake one. A server that must serve both branches on `ctx.request_context.protocol_version`. See [Elicitation](/servers/elicitation#which-approach-to-use) for both shapes side by side.
|
||||
|
||||
Resolvers that return `Sample` or `ListRoots` have no *injected* equivalent — FastMCP has no `ctx.sample()` or `ctx.list_roots()` — but the underlying request survives, so this is a change of shape rather than a loss of capability. On a modern connection both ride the same guard pattern as elicitation: the tool returns an `InputRequiredResult` describing the sampling or roots request, and the client answers on the next call.
|
||||
|
||||
Which shape you want differs by capability. For **roots**, the guard route is the natural replacement, since one round buys the whole answer — and taking the paths as ordinary tool arguments is simpler still whenever the caller can supply them. For **generation**, prefer [calling an LLM from your server](/servers/sampling) with your own API key: your tool then behaves identically for every client, including the many that never implemented sampling, and you avoid paying a full request-response cycle per generation step. Reach for the guard route when using the *caller's* model is specifically the point.
|
||||
|
||||
One schema detail is easy to miss during the rewrite. A resolved parameter never appears in the tool's input schema — `book_flight` above advertises no arguments at all. When you replace a resolver with an explicit tool argument, the schema the client sees gains a field, which is usually what you want but is a visible change to your tool's contract.
|
||||
|
||||
## What You Gain
|
||||
|
||||
The migration is worth doing for what sits on the other side of it. FastMCP is a framework rather than a protocol surface, and these are the capabilities that most often motivate the move:
|
||||
|
||||
[Server composition](/servers/composition) mounts one server inside another, so a large surface splits into modules that are developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching, with hooks at whichever level of specificity you need. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control. The [OpenAPI integration](/integrations/openapi) generates a whole server from an existing API specification. [Tool transformation](/servers/transforms/transforms) rewrites the tools a server exposes — renaming, hiding, and reshaping arguments — without touching the code that defines them.
|
||||
|
||||
FastMCP also ships a [client](/clients/client), which `MCPServer` has no counterpart for. It speaks every transport, drives both protocol eras, and connects to a server object in-process — so [testing](/servers/testing) a server means calling its tools in the same Python process, with no subprocess and no network.
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
---
|
||||
title: Upgrading from the MCP SDK
|
||||
sidebarTitle: "From MCP SDK"
|
||||
description: Upgrade from FastMCP in the MCP Python SDK to the standalone FastMCP framework
|
||||
icon: up
|
||||
---
|
||||
|
||||
If your server starts with `from mcp.server.fastmcp import FastMCP`, you're using FastMCP 1.0 — the version bundled with v1 of the `mcp` package. Upgrading to the standalone FastMCP framework is easy. **For most servers, it's a single import change.**
|
||||
|
||||
```python
|
||||
# Before
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
# After
|
||||
from fastmcp import FastMCP
|
||||
```
|
||||
|
||||
That's it. Your `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` decorators, your `mcp.run()` call, and the rest of your server code all work as-is.
|
||||
|
||||
<Tip>
|
||||
**Why upgrade?** FastMCP 1.0 pioneered the Pythonic MCP server experience, and we're proud it was bundled into the `mcp` package. The standalone FastMCP project has since grown into a full framework for taking MCP servers from prototype to production — with composition, middleware, proxy servers, authentication, and much more. Upgrading gives you access to all of that, plus ongoing updates and fixes.
|
||||
</Tip>
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install --upgrade fastmcp
|
||||
# or
|
||||
uv add fastmcp
|
||||
```
|
||||
|
||||
FastMCP includes the `mcp` package as a dependency, so you don't lose access to anything. Update your import, run your server, and if your tools work, you're done.
|
||||
|
||||
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
|
||||
You are upgrading an MCP server from FastMCP 1.0 (bundled in the `mcp` package v1) to standalone FastMCP 4. Analyze the provided code and identify every change needed. The full upgrade guide is at https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context.
|
||||
|
||||
STEP 1 — IMPORT (required for all servers):
|
||||
Change "from mcp.server.fastmcp import FastMCP" to "from fastmcp import FastMCP".
|
||||
|
||||
STEP 2 — CONSTRUCTOR KWARGS (only if FastMCP() receives transport settings):
|
||||
FastMCP() no longer accepts: host, port, log_level, debug, sse_path, streamable_http_path, json_response, stateless_http.
|
||||
Fix: pass these to run() instead.
|
||||
Before: `mcp = FastMCP("server", host="0.0.0.0", port=8080); mcp.run()`
|
||||
After: `mcp = FastMCP("server"); mcp.run(transport="http", host="0.0.0.0", port=8080)`
|
||||
|
||||
STEP 3 — PROMPTS (only if using PromptMessage directly or returning dicts):
|
||||
mcp.types.PromptMessage is replaced by fastmcp.prompts.Message.
|
||||
Before: `PromptMessage(role="user", content=TextContent(type="text", text="Hello"))`
|
||||
After: `Message("Hello")` — role defaults to "user", accepts plain strings.
|
||||
Also: if prompts return raw dicts like `{"role": "user", "content": "..."}`, these must become Message objects or plain strings.
|
||||
The MCP SDK's FastMCP 1.0 silently coerced dicts; standalone FastMCP requires typed returns.
|
||||
|
||||
STEP 4 — OTHER MCP IMPORTS (only if importing from mcp.* directly):
|
||||
FastMCP now builds on MCP SDK v2, which removed the `mcp.types` module — protocol types live in the standalone `mcp_types` package. Update any `from mcp.types import X` to `from mcp_types import X`. Prefer FastMCP's own APIs where equivalents exist:
|
||||
- mcp_types.TextContent for tool returns → just return plain Python values (str, int, dict, etc.)
|
||||
- mcp_types.ImageContent → fastmcp.utilities.types.Image
|
||||
- from mcp.server.stdio import stdio_server → not needed, mcp.run() handles transport
|
||||
|
||||
STEP 5 — DECORATORS (only if treating decorated functions as objects):
|
||||
@mcp.tool, @mcp.resource, @mcp.prompt now return the original function, not a component object. Code that accesses .name or .description on the decorated result needs updating. Set FASTMCP_DECORATOR_MODE=object temporarily to restore v1 behavior (this compat setting is itself deprecated).
|
||||
|
||||
For each issue found, show the original line, explain what changed, and provide the corrected code.
|
||||
</Prompt>
|
||||
|
||||
## What Might Need Updating
|
||||
|
||||
Most servers need nothing beyond the import change. Skim the sections below to see if any apply.
|
||||
|
||||
### Constructor Settings
|
||||
|
||||
If you passed transport settings like `host` or `port` directly to `FastMCP()`, those now belong on `run()`. This keeps your server definition independent of how it's deployed:
|
||||
|
||||
```python
|
||||
# Before
|
||||
mcp = FastMCP("my-server", host="0.0.0.0", port=8080)
|
||||
mcp.run()
|
||||
|
||||
# After
|
||||
mcp = FastMCP("my-server")
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8080)
|
||||
```
|
||||
|
||||
If you pass the old kwargs, you'll get a clear `TypeError` with a migration hint.
|
||||
|
||||
### Prompts
|
||||
|
||||
If your prompt functions return `mcp.types.PromptMessage` objects or raw dicts with `role`/`content` keys, you'll need to upgrade to FastMCP's `Message` class. Or just return a plain string — it's automatically wrapped as a user message. The MCP SDK's bundled FastMCP 1.0 silently coerced dicts into messages; standalone FastMCP requires typed `Message` objects or strings.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("prompts")
|
||||
|
||||
@mcp.prompt
|
||||
def review(code: str) -> str:
|
||||
"""Review code for issues"""
|
||||
return f"Please review this code:\n\n{code}"
|
||||
```
|
||||
|
||||
For multi-turn prompts:
|
||||
|
||||
```python
|
||||
from fastmcp.prompts import Message
|
||||
|
||||
@mcp.prompt
|
||||
def debug(error: str) -> list[Message]:
|
||||
"""Start a debugging session"""
|
||||
return [
|
||||
Message(f"I'm seeing this error:\n\n{error}"),
|
||||
Message("I'll help debug that. Can you share the relevant code?", role="assistant"),
|
||||
]
|
||||
```
|
||||
|
||||
### Other `mcp.*` Imports
|
||||
|
||||
FastMCP now builds on MCP SDK v2. The `mcp.types` module no longer exists — protocol types moved to a standalone `mcp_types` package, and the field names were renamed from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, and so on). Update `from mcp.types import X` to `from mcp_types import X`. For the full picture, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3).
|
||||
|
||||
Where FastMCP provides its own API for the same thing, it's worth switching over:
|
||||
|
||||
| mcp Package | FastMCP Equivalent |
|
||||
|---|---|
|
||||
| `mcp.types.TextContent(type="text", text=str(x))` | Just return `x` from your tool |
|
||||
| `mcp.types.ImageContent(...)` | `from fastmcp.utilities.types import Image` |
|
||||
| `mcp.types.PromptMessage(...)` | `from fastmcp.prompts import Message` |
|
||||
| `from mcp.server.stdio import stdio_server` | Not needed — `mcp.run()` handles transport |
|
||||
|
||||
For protocol types without a FastMCP equivalent, import them from `mcp_types` directly.
|
||||
|
||||
### Decorated Functions
|
||||
|
||||
In FastMCP 1.0, `@mcp.tool` returned a `FunctionTool` object. Now decorators return your original function unchanged — so decorated functions stay callable for testing, reuse, and composition:
|
||||
|
||||
```python
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# This works now — the function is still a regular function
|
||||
assert greet("World") == "Hello, World!"
|
||||
```
|
||||
|
||||
If you have code that accesses `.name`, `.description`, or other attributes on the decorated result, that will need updating. This is uncommon — most servers don't interact with the tool object directly. If you need the old behavior temporarily, set `FASTMCP_DECORATOR_MODE=object` to restore it (this compatibility setting is itself deprecated and will be removed in a future release).
|
||||
|
||||
## Verify the Upgrade
|
||||
|
||||
```bash
|
||||
# Install
|
||||
pip install --upgrade fastmcp
|
||||
|
||||
# Check version
|
||||
fastmcp version
|
||||
|
||||
# Run your server
|
||||
python my_server.py
|
||||
```
|
||||
|
||||
You can also inspect your server's registered components with the FastMCP CLI:
|
||||
|
||||
```bash
|
||||
fastmcp inspect my_server.py
|
||||
```
|
||||
|
||||
## Looking Ahead
|
||||
|
||||
The MCP ecosystem is evolving fast. Part of FastMCP's job is to absorb that complexity on your behalf — as the protocol and its tooling grow, we do the work so your server code doesn't have to change.
|
||||
|
|
@ -8,12 +8,12 @@ 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.
|
||||
|
||||
<Note>
|
||||
FastMCP 4 is in **alpha**. Pin an exact version and expect sharp edges.
|
||||
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).
|
||||
</Note>
|
||||
|
||||
## 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 split the protocol types into a standalone `mcp_types` package, renamed every wire field from camelCase to snake_case, 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 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.
|
||||
|
||||
|
|
@ -23,9 +23,11 @@ The rebuild also pulls the protocol's recent evolution forward in a single step.
|
|||
|
||||
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.
|
||||
|
||||
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. See [Protocol negotiation](/clients/client#protocol-negotiation).
|
||||
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).
|
||||
|
||||
The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577). Imperative `ctx.elicit` and `ctx.list_roots` move to a request-shaped pattern on modern connections, and server-initiated sampling — which has no such replacement — is [deprecated](/servers/sampling). Everything else about writing a server is unchanged.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## State without a session
|
||||
|
||||
|
|
@ -47,7 +49,7 @@ When a client offers autocomplete for a prompt argument or a resource-template p
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import PromptReference
|
||||
from mcp.types import PromptReference
|
||||
|
||||
mcp = FastMCP("Docs")
|
||||
|
||||
|
|
@ -84,6 +86,47 @@ 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.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
```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).
|
||||
|
||||
## Faster and safer
|
||||
|
||||
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.
|
||||
|
|
@ -96,4 +139,8 @@ 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.
|
||||
|
||||
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).
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ FastMCP supports two Auth0 integration paths:
|
|||
|
||||
## Auth for MCP (DCR)
|
||||
|
||||
<VersionBadge version="3.3.0" />
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
This path uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern. Auth0 acts as the authorization server; FastMCP is the resource server.
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ The connector must be explicitly enabled in each chat session through Developer
|
|||
Use `annotations=ToolAnnotations(readOnlyHint=True)` to skip confirmation prompts for read-only tools:
|
||||
|
||||
```python
|
||||
from mcp_types import ToolAnnotations
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
||||
def get_status() -> str:
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider)
|
|||
|
||||
### Scope discovery and validation
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
When both `scopes_supported` and `required_scopes` are omitted, `DescopeProvider` discovers `scopes_supported` lazily from the OpenID configuration and advertises them to MCP clients. Provider construction remains network-free, and a transient discovery failure is retried on a later metadata request.
|
||||
|
||||
Set both options when clients should request a broader set of scopes than the server requires on every token:
|
||||
|
|
|
|||
|
|
@ -452,4 +452,21 @@ FastMCP handles array parameters according to OpenAPI specifications:
|
|||
|
||||
### Headers
|
||||
|
||||
Header parameters are automatically converted to strings and included in the HTTP request.
|
||||
Header parameters are automatically converted to strings and included in the HTTP request.
|
||||
|
||||
### Composed Request Bodies
|
||||
|
||||
A request body becomes a flat set of tool arguments, which is the shape LLM tool-calling APIs fill in most reliably. Schemas composed with `allOf` are resolved first, following `$ref` members, so fields inherited from a parent schema appear alongside the ones a schema declares itself.
|
||||
|
||||
Schemas that use a `discriminator` are flattened the same way. FastMCP merges in the fields of every subtype named in the discriminator's `mapping`, marks them optional, and names the accepted values on the discriminator's own description. Given a `Pet` body discriminated by `petType` and mapped onto `Cat` and `Dog`, the tool takes the discriminator plus whichever fields that variant uses:
|
||||
|
||||
```python
|
||||
await client.call_tool("create_pet", {
|
||||
"petType": "cat",
|
||||
"meowVolume": 11,
|
||||
})
|
||||
```
|
||||
|
||||
The discriminator stays required; every variant field is optional, because only one variant applies to any given call.
|
||||
|
||||
This trades local strictness for a schema models complete accurately. The generated schema permits any combination of variant fields, so sending `packSize` with `petType: "cat"` passes FastMCP's validation and is rejected by the API itself, exactly as it would be for any other HTTP client. Where two variants declare the same field differently, the declarations are combined with `anyOf` so that neither variant's constraints are advertised as applying to both.
|
||||
|
|
@ -1,9 +1,116 @@
|
|||
---
|
||||
title: FAQ
|
||||
description: Answers to common questions about installing and using FastMCP
|
||||
description: Direct answers to the questions that come up most often about FastMCP 4, the protocol eras, and installation
|
||||
icon: circle-question
|
||||
---
|
||||
|
||||
## Do I need to change my server code for FastMCP 4?
|
||||
|
||||
Most servers run untouched. The defining change in FastMCP 4 is its engine — the MCP Python SDK v2 — and FastMCP absorbs nearly all of it for you, including the wire-wide rename from camelCase to snake_case, which is bridged so your existing reads keep working.
|
||||
|
||||
Most of what does reach your code fails loudly at import or call time, and the fix is mechanical: `McpError(ErrorData(...))` becomes `McpError(code=..., message=...)`, custom `httpx` clients handed to a transport become `httpx2`, and `ctx.sample()` and `ctx.list_roots()` are gone.
|
||||
|
||||
One change is silent, so go looking for it: an `except httpx.ConnectError:` around a FastMCP call still imports and still type-checks, because `httpx` usually remains installed through some other dependency — but FastMCP now raises the `httpx2` exception, so the handler simply stops matching and your fallback quietly never runs. Grep for `except httpx.` and move those to `httpx2`. [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) covers each one and ends with a checklist.
|
||||
|
||||
## Why does my client connect with a different protocol version than before?
|
||||
|
||||
`fastmcp.Client` defaults to `mode="auto"` as of FastMCP 4, so it negotiates the newest era both sides speak rather than pinning the handshake. Over streamable HTTP or stdio to a FastMCP server that means the sessionless `2026-07-28` protocol, where FastMCP 3 connected at `2025-11-25`.
|
||||
|
||||
Two transports are exceptions: SSE predates the sessionless era and cannot carry it, and a multi-server `MCPConfigTransport` mounts each backend behind a legacy-era composite. Under `mode="auto"` the client recognizes both and settles on the handshake without probing, so seeing `2025-11-25` there is correct rather than a negotiation failure. Pinning a modern version explicitly on either skips that substitution and asks the transport for something it cannot serve, so leave them on auto or legacy.
|
||||
|
||||
The client probes `server/discover` and adopts the modern protocol when the server answers, falling back to the `initialize` handshake for anything that is not positive evidence of a modern peer — so a mixed fleet of servers still connects. Pin the old behavior per client with `Client(url, mode="legacy")`. See [Protocol negotiation](/clients/client#protocol-negotiation).
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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).
|
||||
|
||||
## 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.
|
||||
|
||||
You do not need to pin it just because you registered a `sampling_handler`, `roots=`, or an `elicitation_handler`. None of the three require the handshake on their own: `mode="auto"` reaches whichever era the connection negotiates, and on a modern connection a tool can still exercise any of them through the guard pattern — it manually returns an `InputRequiredResult` embedding the request, and the same handler you already registered answers it. [Roots](/clients/roots) and [elicitation](/clients/elicitation) document this pattern directly; [sampling](/clients/sampling) works through the identical mechanism, though calling an LLM directly from the server is the recommended path there rather than a round trip for it.
|
||||
|
||||
Pinning is per client, not a deployment setting: `Client(url, mode="legacy")`. The trade runs the other way as well — [background tasks](/clients/tasks) are modern-only, so a legacy client never triggers one and a task-enabled tool simply runs synchronously.
|
||||
|
||||
## Why did my `ctx.sample()` code stop working?
|
||||
|
||||
`ctx.sample()` and `ctx.sample_step()` are not part of FastMCP 4. Calling either raises `AttributeError` on every protocol era, and `FastMCP(sampling_handler=...)` raises `TypeError` naming the migration.
|
||||
|
||||
Sampling was a server-to-client *request*: the server sent `sampling/createMessage` and blocked until an answer came back down the session. The modern protocol has no server-to-client request direction at all, so the pushed form has nowhere to go.
|
||||
|
||||
The asking survives in a different shape. A tool can return an `InputRequiredResult` carrying a `CreateMessageRequest`; the client answers it through the same `sampling_handler` it already registers, and your tool runs again with the completion. Reach for that when using *the caller's* model is the point. Otherwise put generation in your server — hold a provider API key and call the model directly, which has the side benefit that your tool behaves identically for every client, including the many that never implemented sampling. [Sampling](/servers/sampling) shows both.
|
||||
|
||||
## What happened to `ctx.list_roots()`?
|
||||
|
||||
Removed, for the same reason as sampling: `roots/list` was a server-to-client request, and the modern protocol has no channel to send one.
|
||||
|
||||
Take the paths you need as ordinary tool arguments. The agent already knows which directory it is working in, and an explicit argument is visible in the tool's schema instead of hidden in a protocol round-trip. When the caller genuinely has to be asked mid-run, the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) carries a roots request in its `input_requests` map alongside elicitation, and `fastmcp.Client` answers it from the `roots=` you already configured.
|
||||
|
||||
## Why does `ctx.info()` still work when sampling doesn't?
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Why can't I call `client.set_logging_level()` anymore?
|
||||
|
||||
On a modern connection it raises, because `logging/setLevel` is not in the `2026-07-28` protocol. The method asked the server to remember a level for the rest of the session, and a sessionless protocol has nowhere to keep that.
|
||||
|
||||
The messages themselves are unaffected — the server still sends whatever its own configuration allows. Filter on the receiving side in your `log_handler`, which sees each message's `level` field. See [Client Logging](/clients/logging). On a handshake-era connection (`Client(url, mode="legacy")`) the call works as before.
|
||||
|
||||
Receiving-side filtering only narrows what already arrives. A server that sets `FastMCP(client_log_level="error")` drops anything below that threshold before it reaches the wire, and a modern client has no way to ask for the missing levels — the server operator has to lower `client_log_level` for them to be sent at all.
|
||||
|
||||
## What replaces elicitation on the modern protocol?
|
||||
|
||||
The guard pattern. Rather than pausing mid-execution to ask, a tool *returns* an `InputRequiredResult` describing what it needs. That round completes normally, the client collects the answer, and it calls the tool again with the answer attached. Any state you carry between rounds is sealed by the framework before it reaches the wire, so the client holds an opaque token it cannot read or forge.
|
||||
|
||||
`ctx.elicit()` still works on handshake-era connections and raises on modern ones, so a server that must serve both eras needs both paths. `fastmcp.Client` drives whichever the connection negotiated with no extra wiring on your side. See [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol).
|
||||
|
||||
## Why doesn't my middleware's `on_initialize` hook run?
|
||||
|
||||
Because the modern protocol has no `initialize` request. The hook fires on handshake-era connections and never on modern ones, and since `Client` now defaults to `mode="auto"`, that is the common case against a FastMCP 4 server.
|
||||
|
||||
Work that must happen once per process belongs in the server [lifespan](/servers/lifespan). Per-request work such as an auth check belongs in `on_request` or a specific operation hook, both of which run on every era — on a modern connection `on_request` sees `server/discover` where a handshake connection sees `initialize`. See [Middleware](/servers/middleware).
|
||||
|
||||
## Why doesn't state I set in one tool call show up in the next?
|
||||
|
||||
On a modern connection every request is a fresh connection, so `ctx.set_state` lives only for the duration of the call that wrote it. The same code persists state across calls on a handshake-era connection, which is why it appears to break the moment a client negotiates `2026-07-28`.
|
||||
|
||||
[Session state](/servers/sessions) is the durable answer, following MCP's own decision to move session semantics up into the application. Declare a `UserSession` parameter and FastMCP injects one bucket of stored state keyed to the authenticated user, with nothing to pass around. Declare a `SessionId` argument when a single user needs several independent sessions, and the caller mints an id with `create_session` and supplies it on each call — register `mcp.add_provider(SessionProvider())` first, since `create_session` doesn't exist until a `SessionProvider` contributes it. Both store server-side and key to the authenticated caller's identity, so a handle is inert in anyone else's hands.
|
||||
|
||||
That isolation comes from authentication, not from the id. On an unauthenticated server there is no principal to key on, so every session shares one anonymous namespace and a `SessionId` becomes a bearer capability — anyone holding it can read and write that state. Treat unauthenticated sessions as single-tenant or trusted-network only; `UserSession` sidesteps the question by requiring an authenticated principal outright.
|
||||
|
||||
## How do I run background tasks now?
|
||||
|
||||
The same way, plus one registration. `@mcp.tool(task=True)` is still the authoring surface and [Docket](https://github.com/chrisguidry/docket) still runs the work. What changed is underneath: tasks left the core MCP spec and returned as the `io.modelcontextprotocol/tasks` extension (SEP-2663), which FastMCP implements in the optional `fastmcp-tasks` package.
|
||||
|
||||
Install `fastmcp[tasks]` and register the extension on your server. A `task=True` tool on a server with no tasks extension refuses to start and names the fix, so a missing registration is impossible to ship by accident.
|
||||
|
||||
```python
|
||||
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:
|
||||
return "done"
|
||||
```
|
||||
|
||||
Tasks are modern-only: the capability is negotiated over `2026-07-28`, so a `mode="legacy"` client never triggers one. A tool marked `task=True` (equivalently `mode="optional"`) then just runs synchronously. A tool that sets `TaskConfig(mode="required")` has no synchronous form to fall back to, so the call fails with a missing-required-capability error instead. See [Background Tasks](/servers/tasks).
|
||||
|
||||
## `import fastmcp` stopped working after I upgraded with pip
|
||||
|
||||
This can happen when you upgrade to FastMCP 3.3 or later from FastMCP 3.2 or earlier with `pip`. The quick fix is `pip install --force-reinstall fastmcp`. See [Troubleshooting](/getting-started/installation#troubleshooting) for the clean-reinstall fallback and an explanation of why it happens.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` env
|
|||
|---|---|---|---|
|
||||
| `FASTMCP_LOG_LEVEL` | `Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]` | `INFO` | Log level for FastMCP's own logging output. Case-insensitive. |
|
||||
| `FASTMCP_LOG_ENABLED` | `bool` | `true` | Enable or disable FastMCP logging entirely. |
|
||||
| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. |
|
||||
| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Handshake-era clients can override this per-session using the MCP `logging/setLevel` request; the modern protocol has no session to hold that level, so clients on it filter by level in their own log handler instead. |
|
||||
| `FASTMCP_ENABLE_RICH_LOGGING` | `bool` | `true` | Use rich formatting for log output. Set to `false` for plain Python logging. |
|
||||
| `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. |
|
||||
| `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. |
|
||||
|
|
@ -77,7 +77,7 @@ These control how the server listens when running with an HTTP transport.
|
|||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `FASTMCP_ENABLE_TELEMETRY` | `bool` | `true` | Whether FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry) is active. Enabled by default; FastMCP uses only the OpenTelemetry API, so span creation is a no-op with negligible overhead unless an OpenTelemetry SDK and exporter are configured. Set to `false` to turn instrumentation off entirely, in which case no FastMCP spans are created even when an SDK is configured. |
|
||||
| `FASTMCP_TELEMETRY_MODE` | `Literal["native", "propagation_only", "off"]` | `native` | Controls FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry). `native` emits FastMCP's MCP spans and propagates trace context; because FastMCP uses only the OpenTelemetry API, this costs almost nothing unless an SDK and exporter are configured. `propagation_only` keeps `_meta` trace propagation and still parents downstream spans from the incoming context, but emits none of FastMCP's own spans, so another instrumentation layer can own the MCP span hierarchy. `off` is a full pass-through: no spans, and no trace context extracted or attached. |
|
||||
|
||||
## Tasks (Docket)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Custom exceptions for FastMCP.
|
|||
|
||||
## Functions
|
||||
|
||||
### `to_mcp_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L98" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `to_mcp_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L122" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
to_mcp_error(exc: Exception) -> MCPError
|
||||
|
|
@ -119,3 +119,16 @@ or policy tripped.
|
|||
|
||||
Error when authorization check fails.
|
||||
|
||||
|
||||
### `InsufficientScopeError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L98" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
|
||||
Authorization failed because the token is missing required OAuth scopes.
|
||||
|
||||
Unlike a bare ``AuthorizationError``, this carries the specific scopes the
|
||||
caller must obtain. A component-level scope shortfall can then be signalled
|
||||
as a spec-correct ``insufficient_scope`` step-up (SEP-2350 / RFC 6750 §3),
|
||||
naming exactly what to re-authorize for instead of an opaque denial. The
|
||||
named scopes are only the *unmet* ones, so an existing grant is accumulated
|
||||
rather than replaced when the caller re-authorizes.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ deny with a custom message; other exceptions are masked and treated as denial.
|
|||
|
||||
## Functions
|
||||
|
||||
### `require_scopes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L50" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `require_scopes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L143" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
require_scopes(*scopes: str) -> AuthCheck
|
||||
|
|
@ -25,7 +25,52 @@ require_scopes(*scopes: str) -> AuthCheck
|
|||
Require all of the given OAuth scopes.
|
||||
|
||||
|
||||
### `restrict_tag` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L62" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `require_roles` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L148" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
require_roles(*roles: str) -> AuthCheck
|
||||
```
|
||||
|
||||
|
||||
Require all of the given roles, read from the token's claims.
|
||||
|
||||
Roles and groups are not part of OIDC, so every identity provider puts them
|
||||
somewhere different: `realm_access.roles` on Keycloak, `roles` on Microsoft
|
||||
Entra, `cognito:groups` on AWS Cognito, `permissions` or a namespaced custom
|
||||
claim on Auth0. `extract` receives the token's claims and returns the
|
||||
caller's roles, which keeps that provider-specific knowledge at the call
|
||||
site instead of guessing it here.
|
||||
|
||||
```python
|
||||
from fastmcp.server.auth import require_roles
|
||||
|
||||
keycloak = require_roles("admin", extract=lambda c: c["realm_access"]["roles"])
|
||||
cognito = require_roles("admins", extract=lambda c: c["cognito:groups"])
|
||||
```
|
||||
|
||||
A token missing the claim entirely is denied rather than treated as an
|
||||
error, so `extract` may index into the claims without guarding. An
|
||||
extractor returning a bare string is treated as one role, since a provider
|
||||
that stores a single role as a scalar is common.
|
||||
|
||||
Unlike `require_scopes`, this check cannot signal a shortfall: OAuth has no
|
||||
way to request a role, so there is no `insufficient_scope` challenge to
|
||||
emit. A role denial is therefore reported as a plain `AuthorizationError`,
|
||||
and it suppresses any scope shortfall alongside it — a caller blocked by
|
||||
their role must not be told to go obtain a scope that would not help.
|
||||
Scope shortfalls are still reported normally whenever the role check
|
||||
passes.
|
||||
|
||||
**Args:**
|
||||
- `*roles`: Roles the caller must hold. All are required (AND logic).
|
||||
- `extract`: Callable mapping the token's claims to the caller's roles.
|
||||
|
||||
**Raises:**
|
||||
- `ValueError`: If no roles are given, which would allow any authenticated
|
||||
caller and is more likely a mistake than an intent.
|
||||
|
||||
|
||||
### `restrict_tag` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L197" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
restrict_tag(tag: str) -> AuthCheck
|
||||
|
|
@ -35,14 +80,62 @@ restrict_tag(tag: str) -> AuthCheck
|
|||
Require scopes when the accessed component has a specific tag.
|
||||
|
||||
|
||||
### `run_auth_checks` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L76" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `scope_requirements` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L202" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
scope_requirements(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> list[str] | None
|
||||
```
|
||||
|
||||
|
||||
Scopes a check list requires but the token lacks, without running it.
|
||||
|
||||
Returns ``None`` when the list contains any opaque (non-scope) check. Such a
|
||||
check might deny for a reason unrelated to scopes, and evaluating it here
|
||||
would run authorization logic — with whatever side effects it carries —
|
||||
outside its normal place in the chain. Since its verdict is unknown, its
|
||||
siblings' scopes must not be disclosed either, so the whole list is withheld.
|
||||
|
||||
When every check is scope-aware, the result is their combined shortfall,
|
||||
computed purely from the token and component (an empty list means the list is
|
||||
already satisfied). This lets a shortfall be aggregated across authorization
|
||||
layers without evaluating anything that would otherwise be skipped.
|
||||
|
||||
|
||||
### `run_auth_checks_with_shortfall` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L253" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
run_auth_checks_with_shortfall(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> tuple[bool, list[str]]
|
||||
```
|
||||
|
||||
|
||||
Run auth checks with AND logic, classifying the denial cause.
|
||||
|
||||
Returns ``(authorized, missing_scopes)``. ``missing_scopes`` names every
|
||||
scope the caller must obtain to satisfy *all* scope requirements at once:
|
||||
the union of the shortfalls across every scope-aware check, not just the
|
||||
first one to fail. Reporting only the first would strand a caller in a
|
||||
step-up loop — it obtains that scope, retries, and is denied again for the
|
||||
next — so the union is what makes a single re-authorization converge.
|
||||
|
||||
The challenge is withheld entirely (an empty list, which the caller surfaces
|
||||
as a plain ``AuthorizationError``) unless every non-scope check passes. A
|
||||
custom policy denial — a tenant check, say — must never be reported as an
|
||||
``insufficient_scope`` shortfall, and must never name the scopes of a
|
||||
component the caller could not otherwise reach. To guarantee that, the
|
||||
opaque checks are all evaluated before any scope is disclosed; a shortfall
|
||||
is only reported once they have all passed.
|
||||
|
||||
An ``AuthorizationError`` raised by a check propagates unchanged.
|
||||
|
||||
|
||||
### `run_auth_checks` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L304" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool
|
||||
```
|
||||
|
||||
|
||||
Run auth checks with AND logic.
|
||||
Run auth checks with AND logic, stopping at the first failure.
|
||||
|
||||
|
||||
## Classes
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ sidebarTitle: json_schema
|
|||
|
||||
## Functions
|
||||
|
||||
### `require_discriminator_property` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L116" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `require_discriminator_property` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L149" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
require_discriminator_property(schema: dict[str, Any]) -> dict[str, Any]
|
||||
|
|
@ -24,7 +24,7 @@ model with ``union_tag_not_found``. No-op if there is no string
|
|||
``propertyName``.
|
||||
|
||||
|
||||
### `dereference_refs` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L147" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `dereference_refs` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L180" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
dereference_refs(schema: dict[str, Any]) -> dict[str, Any]
|
||||
|
|
@ -57,7 +57,7 @@ schemas from untrusted servers.
|
|||
- when no longer needed
|
||||
|
||||
|
||||
### `resolve_root_ref` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L294" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `resolve_root_ref` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L327" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]
|
||||
|
|
@ -79,7 +79,7 @@ the referenced definition while preserving $defs for nested references.
|
|||
- if no resolution is needed
|
||||
|
||||
|
||||
### `compress_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L693" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `compress_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L741" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False) -> dict[str, Any]
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ This is used to exclude parameters from type adapter processing when they can't
|
|||
The excluded parameters are removed from the function's __annotations__ dictionary.
|
||||
|
||||
|
||||
### `replace_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L466" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `replace_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L469" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
replace_type(type_, type_map: dict[type, type])
|
||||
|
|
@ -145,13 +145,13 @@ Helper class for returning audio from tools.
|
|||
|
||||
**Methods:**
|
||||
|
||||
#### `to_audio_content` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L352" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `to_audio_content` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L355" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
to_audio_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp_types.AudioContent
|
||||
```
|
||||
|
||||
### `File` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L373" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `File` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L376" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
|
||||
Helper class for returning file data from tools.
|
||||
|
|
@ -159,10 +159,10 @@ Helper class for returning file data from tools.
|
|||
|
||||
**Methods:**
|
||||
|
||||
#### `to_resource_content` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L412" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `to_resource_content` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L415" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp_types.EmbeddedResource
|
||||
```
|
||||
|
||||
### `ContextSamplingFallbackProtocol` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L502" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `ContextSamplingFallbackProtocol` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L505" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
|
|
|||
|
|
@ -189,11 +189,19 @@ from fastmcp import FastMCP
|
|||
from fastmcp.server.auth import MultiAuth, OAuthProxy
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
|
||||
upstream_verifier = JWTVerifier(
|
||||
jwks_uri="https://login.example.com/.well-known/jwks.json",
|
||||
issuer="https://login.example.com",
|
||||
audience="my-app",
|
||||
)
|
||||
|
||||
auth = MultiAuth(
|
||||
server=OAuthProxy(
|
||||
issuer_url="https://login.example.com/...",
|
||||
client_id="my-app",
|
||||
client_secret="secret",
|
||||
upstream_authorization_endpoint="https://login.example.com/oauth/authorize",
|
||||
upstream_token_endpoint="https://login.example.com/oauth/token",
|
||||
upstream_client_id="my-app",
|
||||
upstream_client_secret="secret",
|
||||
token_verifier=upstream_verifier,
|
||||
base_url="https://my-server.com",
|
||||
),
|
||||
verifiers=[
|
||||
|
|
|
|||
|
|
@ -22,11 +22,19 @@ from fastmcp import FastMCP
|
|||
from fastmcp.server.auth import MultiAuth, OAuthProxy
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
|
||||
upstream_verifier = JWTVerifier(
|
||||
jwks_uri="https://login.example.com/.well-known/jwks.json",
|
||||
issuer="https://login.example.com",
|
||||
audience="my-app",
|
||||
)
|
||||
|
||||
auth = MultiAuth(
|
||||
server=OAuthProxy(
|
||||
issuer_url="https://login.example.com/...",
|
||||
client_id="my-app",
|
||||
client_secret="secret",
|
||||
upstream_authorization_endpoint="https://login.example.com/oauth/authorize",
|
||||
upstream_token_endpoint="https://login.example.com/oauth/token",
|
||||
upstream_client_id="my-app",
|
||||
upstream_client_secret="secret",
|
||||
token_verifier=upstream_verifier,
|
||||
base_url="https://my-server.com",
|
||||
),
|
||||
verifiers=[
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ mcp = FastMCP(name="My Server", auth=auth)
|
|||
<ParamField body="base_url" type="AnyHttpUrl | str" required>
|
||||
Public URL where OAuth endpoints will be accessible, **including any mount path** (e.g., `https://your-server.com/api`).
|
||||
|
||||
This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to specify where auth server metadata is located (typically at root level).
|
||||
This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to give the server an OAuth identity that differs from where its endpoints are mounted (typically the root level).
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="resource_base_url" type="AnyHttpUrl | str | None">
|
||||
|
|
@ -135,6 +135,8 @@ mcp = FastMCP(name="My Server", auth=auth)
|
|||
<ParamField body="issuer_url" type="AnyHttpUrl | str | None">
|
||||
Issuer URL for OAuth authorization server metadata (defaults to `base_url`).
|
||||
|
||||
`issuer_url` is the server's OAuth identity: it is the `issuer` field of the authorization server metadata, the `iss` claim of the tokens the proxy mints, and the RFC 9207 `iss` parameter on authorization responses. `base_url` remains the location of the endpoints, so `authorization_endpoint`, `token_endpoint`, and the rest of the metadata still point at `base_url` where the routes are actually mounted.
|
||||
|
||||
When `issuer_url` has a path component (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`.
|
||||
|
||||
**Default behavior (recommended for most cases):**
|
||||
|
|
@ -204,9 +206,11 @@ mcp = FastMCP(name="My Server", auth=auth)
|
|||
</ParamField>
|
||||
|
||||
<ParamField body="valid_scopes" type="list[str] | None">
|
||||
List of all possible valid scopes for the OAuth provider. These are advertised
|
||||
to clients through the `/.well-known` endpoints. Defaults to `required_scopes`
|
||||
from your TokenVerifier if not specified.
|
||||
The complete set of scopes clients are allowed to request — the full set of
|
||||
available scopes (a superset of `required_scopes`). These are advertised to
|
||||
clients through the `/.well-known` endpoints and enforced at Dynamic Client
|
||||
Registration. Defaults to `required_scopes` from your TokenVerifier if not
|
||||
specified.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="extra_authorize_params" type="dict[str, str] | None">
|
||||
|
|
@ -585,6 +589,27 @@ auth = OAuthProxy(
|
|||
|
||||
Check your server logs for "Client registered with redirect_uri" messages to identify what URLs your clients use.
|
||||
|
||||
### Application Type (Web vs. Native)
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
During Dynamic Client Registration, a client may declare an `application_type` (per RFC 7591 and SEP-837) that governs which redirect URIs it is allowed to use. The OAuth proxy honors this field both at registration and when authorizing a redirect.
|
||||
|
||||
`application_type` defaults to `"native"` because MCP clients typically run locally and register loopback callbacks. Clients that omit the field keep the permissive behavior described above. A client that explicitly registers as `"web"` is held to the stricter browser-app rules.
|
||||
|
||||
Loopback covers the whole reserved range in both the address and name forms: every address in `127.0.0.0/8`, `::1`, and — per RFC 6761 — the name `localhost` along with any subdomain of it, such as `app.localhost`. The absolute (trailing-dot) spellings `localhost.` and `127.0.0.1.` are treated identically. A name that merely contains `localhost` as a label of a registrable domain, like `localhost.example.com`, is an ordinary public host and is not treated as loopback.
|
||||
|
||||
| `application_type` | Allowed redirect URIs |
|
||||
| ------------------ | --------------------- |
|
||||
| `"native"` (default) | `https` URLs; app and private-use schemes (`vscode://callback`, `com.example.app:/callback`, `myapp://callback`, `urn:ietf:wg:oauth:2.0:oob`); and loopback `http` (`http://127.0.0.1`, any address in `127.0.0.0/8`, `http://localhost`, subdomains such as `http://app.localhost`, `http://[::1]`, any port) |
|
||||
| `"web"` | `https` on a non-loopback host only |
|
||||
|
||||
Web clients must register a non-loopback `https` callback — that is the restriction SEP-837 asks for, and a web client that registers no redirect URI at all is refused, since it could never complete an authorization. Native clients keep the full range of schemes their platforms use; the only new limit is that cleartext `http` must target a loopback host, per RFC 8252 §7.3.
|
||||
|
||||
Both application types always reject unsafe browser schemes (`javascript:`, `data:`, `file:`, `vbscript:`). FastMCP does not otherwise filter a native client's scheme: there is no reliable way to tell an app-dispatch scheme from a network transport, since the IANA registry lists `vscode:` alongside `coap:` and `smb:`, so any such filter would reject callbacks that real MCP clients depend on.
|
||||
|
||||
A redirect URI that violates the declared type is refused during registration with a `RegistrationError` (`invalid_redirect_uri`). For example, a `"web"` client that registers `http://localhost:12345/callback` is rejected, since web clients must use a non-loopback `https` callback. Configure remote, browser-based clients as `application_type="web"` and give them an `https` callback URL.
|
||||
|
||||
## CIMD Support
|
||||
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
|
@ -699,7 +724,7 @@ For each ID-JAG presented at the token endpoint, the proxy checks that:
|
|||
- the JOSE header `typ` is `oauth-id-jag+jwt`;
|
||||
- the `iss` claim is one of the configured `trusted_issuers`;
|
||||
- the signature verifies against the issuer's published keys;
|
||||
- the `aud` claim identifies this authorization server;
|
||||
- the `aud` claim identifies this authorization server — configure your identity provider to mint assertions whose `aud` is the `issuer` value published at `/.well-known/oauth-authorization-server`, which is your `issuer_url` when you set one and your `base_url` otherwise;
|
||||
- the signed `client_id` claim matches the client presenting the assertion — an assertion the IdP minted for one client cannot be redeemed by another;
|
||||
- the signed `resource` claim names this server — an assertion minted for a different MCP server behind the same IdP is rejected;
|
||||
- `exp` (and `iat`/`nbf`, when present) place the assertion within a short lifetime and its validity window; and
|
||||
|
|
|
|||
|
|
@ -132,6 +132,15 @@ These patterns apply to MCP client loopback redirects. Configure the upstream OA
|
|||
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="valid_scopes" type="list[str] | None">
|
||||
The complete set of scopes clients are allowed to request — the full set of
|
||||
available scopes (a superset of `required_scopes`). These are advertised to
|
||||
clients through the `/.well-known` endpoints (as `scopes_supported`) and
|
||||
enforced at Dynamic Client Registration: a client registering with a scope
|
||||
outside this set is rejected. Defaults to `required_scopes` from your token
|
||||
verifier if not specified.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="token_endpoint_auth_method" type="str | None">
|
||||
Token endpoint authentication method for the upstream OAuth server. Controls how the proxy authenticates when exchanging authorization codes and refresh tokens with the upstream provider.
|
||||
- `"client_secret_basic"`: Send credentials in Authorization header (most common)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,75 @@ def read_write_operation() -> str:
|
|||
return "Read/write action completed"
|
||||
```
|
||||
|
||||
### require_roles
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
Scopes are standardized, so `require_scopes` works the same everywhere. Roles and groups are not part of OIDC, so every identity provider puts them under a different claim. `require_roles` handles the comparison and takes an `extract` callable that tells it where to look.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import require_roles
|
||||
|
||||
def keycloak_roles(claims: dict) -> list[str]:
|
||||
return claims["realm_access"]["roles"]
|
||||
|
||||
mcp = FastMCP("Role Server")
|
||||
|
||||
@mcp.tool(auth=require_roles("admin", extract=keycloak_roles))
|
||||
def admin_operation() -> str:
|
||||
"""Requires the 'admin' role."""
|
||||
return "Admin action completed"
|
||||
|
||||
@mcp.tool(auth=require_roles("admin", "auditor", extract=keycloak_roles))
|
||||
def audited_admin_operation() -> str:
|
||||
"""Requires both the 'admin' AND 'auditor' roles."""
|
||||
return "Audited admin action"
|
||||
```
|
||||
|
||||
Multiple roles are required together, matching `require_scopes`. A token whose claims lack the path entirely is denied rather than raising, so the extractor can index directly.
|
||||
|
||||
Keeping the claim path at the call site means any provider works, including ones with unusual shapes. Common locations:
|
||||
|
||||
| Provider | Extractor |
|
||||
| --- | --- |
|
||||
| Keycloak | `lambda c: c["realm_access"]["roles"]` |
|
||||
| Microsoft Entra | `lambda c: c["roles"]` |
|
||||
| AWS Cognito | `lambda c: c["cognito:groups"]` |
|
||||
| Auth0 | `lambda c: c["permissions"]` |
|
||||
|
||||
Verify the claim against your own tenant before relying on it. Auth0's namespaced custom claims are configured per tenant, and Entra emits `roles` or `groups` depending on the app manifest.
|
||||
|
||||
<Note>
|
||||
`require_roles` cannot signal a scope shortfall, because OAuth has no way to request a role. A role denial surfaces as a plain `AuthorizationError` rather than one of the `insufficient_scope` challenges described in [Signaling Scope Shortfalls](#signaling-scope-shortfalls), and it suppresses any scope shortfall raised alongside it — a caller blocked by their role should not be told to go obtain a scope that would not help them. Combining `require_roles` with `require_scopes` is otherwise fine: whenever the role check passes, a scope shortfall is reported as usual.
|
||||
</Note>
|
||||
|
||||
### Checking Other Claims
|
||||
|
||||
`require_roles` is a convenience for the common case. `AccessToken.claims` holds every claim from the token, so gating on anything else needs no special API — just an auth check that reads it.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import AuthCheck, AuthContext
|
||||
|
||||
mcp = FastMCP("Claim Server")
|
||||
|
||||
def require_tenant(tenant_id: str) -> AuthCheck:
|
||||
"""Require the token to come from a specific tenant."""
|
||||
def check(ctx: AuthContext) -> bool:
|
||||
if ctx.token is None:
|
||||
return False
|
||||
return ctx.token.claims.get("tid") == tenant_id
|
||||
return check
|
||||
|
||||
@mcp.tool(auth=require_tenant("acme"))
|
||||
def tenant_operation() -> str:
|
||||
"""Only callable by tokens issued for the acme tenant."""
|
||||
return "Tenant action completed"
|
||||
```
|
||||
|
||||
The same caveat applies: a check like this is opaque, so it suppresses scope disclosure for its siblings.
|
||||
|
||||
### restrict_tag
|
||||
|
||||
Tag-based restrictions apply scope requirements conditionally. If a component has the specified tag, the token must have the required scopes. Components without the tag are unaffected.
|
||||
|
|
@ -107,7 +176,7 @@ Any callable that accepts `AuthContext` and returns `bool` can serve as an auth
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import AuthContext
|
||||
from fastmcp.server.auth import AuthCheck, AuthContext
|
||||
|
||||
mcp = FastMCP("Custom Auth Server")
|
||||
|
||||
|
|
@ -117,7 +186,7 @@ def require_premium_user(ctx: AuthContext) -> bool:
|
|||
return False
|
||||
return ctx.token.claims.get("premium", False) is True
|
||||
|
||||
def require_access_level(minimum_level: int):
|
||||
def require_access_level(minimum_level: int) -> AuthCheck:
|
||||
"""Factory function for level-based authorization."""
|
||||
def check(ctx: AuthContext) -> bool:
|
||||
if ctx.token is None:
|
||||
|
|
@ -168,6 +237,7 @@ Sync and async checks can be freely combined in a list — each check is handled
|
|||
Auth checks can raise exceptions for explicit denial with custom messages:
|
||||
|
||||
- **`AuthorizationError`**: Propagates with its custom message, useful for explaining why access was denied
|
||||
- **`InsufficientScopeError`**: A subclass of `AuthorizationError` raised by `AuthMiddleware` when the denial is a missing scope; it [names the scopes the caller needs](#signaling-scope-shortfalls)
|
||||
- **Other exceptions**: Masked for security (logged internally, treated as denial)
|
||||
|
||||
```python
|
||||
|
|
@ -215,7 +285,7 @@ Component-level `auth` controls both visibility (list filtering) and access (dir
|
|||
|
||||
## Server-Level Authorization
|
||||
|
||||
For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution with explicit `AuthorizationError` responses.
|
||||
For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution with explicit `AuthorizationError` responses. When the denial is specifically a missing scope, the error [names the scopes the caller needs](#signaling-scope-shortfalls).
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -296,6 +366,48 @@ def read_record(id: str) -> str:
|
|||
return f"Record {id}"
|
||||
```
|
||||
|
||||
### Signaling Scope Shortfalls
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
A denial is more useful when it says what would fix it. When `AuthMiddleware` blocks a call because the token is missing scopes — rather than because some other policy rejected it — it raises `InsufficientScopeError`, which carries the specific scopes the caller needs in its `required_scopes` attribute. An agent that reads the error knows exactly which scopes to re-authorize for, instead of retrying blindly against an opaque refusal.
|
||||
|
||||
`InsufficientScopeError` subclasses `AuthorizationError`, so existing handlers that catch `AuthorizationError` keep catching it and nothing about your error handling has to change to adopt this.
|
||||
|
||||
Only the scopes the token *lacks* are named, so re-authorizing accumulates permissions rather than replacing them. A caller holding `read` that needs `read` and `write` is told to obtain `write` alone, and keeps `read` through the re-authorization. When several scope requirements fail at once, every unmet scope is reported together — a caller granted them all in one round succeeds on the retry, instead of discovering the next missing scope only after obtaining the first.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import InsufficientScopeError
|
||||
from fastmcp.server.auth import require_scopes
|
||||
from fastmcp.server.middleware import AuthMiddleware
|
||||
|
||||
mcp = FastMCP(
|
||||
"Step-Up Server",
|
||||
middleware=[AuthMiddleware(auth=require_scopes("read", "write"))],
|
||||
)
|
||||
|
||||
@mcp.tool
|
||||
def update_record(id: str) -> str:
|
||||
"""Requires both 'read' and 'write'."""
|
||||
return f"Updated {id}"
|
||||
|
||||
# A token holding only "read" is denied with:
|
||||
# InsufficientScopeError(required_scopes=["write"])
|
||||
```
|
||||
|
||||
This holds across several `AuthMiddleware` instances too, not just several checks within one. In the [tag-based configuration](#tag-based-global-authorization) each middleware contributes its own requirement, and the first to find a shortfall reports the requirements of the others alongside its own — so one re-authorization covers the whole chain rather than one layer at a time.
|
||||
|
||||
A shortfall is reported only when the scope requirement is what actually caused the denial. If you [combine checks](#combining-checks) and a non-scope check rejects the request first — a tenant policy, say — the denial stays a plain `AuthorizationError` and names no scopes at all. Disclosing a scope requirement for a component the caller could not reach anyway would leak information about components they are not authorized to see.
|
||||
|
||||
That rule also bounds what gets aggregated. Combining requirements only reaches as far down the chain as the request itself would have gone: it stops at the first layer holding a custom check, since whether that layer would admit the caller is unknown until it runs, and running it early would trigger authorization logic the request had not reached yet. Requirements at or beyond that point sit behind an unverified gate and are left out.
|
||||
|
||||
So a custom check early in the chain makes the reported set partial, and a caller may need more than one round to satisfy everything. The reported set is complete when the layers ahead are scope-only and conservative otherwise: it may name fewer scopes than the full chain requires, but it never names scopes behind a policy that might reject the caller regardless.
|
||||
|
||||
<Note>
|
||||
This names the missing scopes in the error rather than emitting an HTTP `403` challenge. A per-tool denial is a JSON-RPC error carried inside a `200` response, so there is no HTTP status at that layer to attach a `WWW-Authenticate` header to. Token-level scope failures — where the token does not satisfy the server's own `required_scopes` — are a separate concern handled by the transport middleware, which does return a spec-correct `403` with an `insufficient_scope` challenge.
|
||||
</Note>
|
||||
|
||||
## Accessing Tokens in Tools
|
||||
|
||||
Tools can access the current authentication token using `get_access_token()` from `fastmcp.server.dependencies`. This enables tools to make decisions based on user identity or permissions beyond simple authorization checks.
|
||||
|
|
@ -376,9 +488,15 @@ from fastmcp.server.auth import (
|
|||
AuthContext, # Context with .token, .component
|
||||
AuthCheck, # Type alias: sync or async Callable[[AuthContext], bool]
|
||||
require_scopes, # Built-in: requires specific scopes
|
||||
require_roles, # Built-in: requires roles read from token claims
|
||||
restrict_tag, # Built-in: tag-based scope requirements
|
||||
run_auth_checks, # Utility: run checks with AND logic
|
||||
)
|
||||
|
||||
from fastmcp.exceptions import (
|
||||
AuthorizationError, # Denial with a custom message
|
||||
InsufficientScopeError, # Subclass of AuthorizationError; has .required_scopes
|
||||
)
|
||||
|
||||
from fastmcp.server.middleware import AuthMiddleware
|
||||
```
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ A server has a single completion handler, registered with the `@mcp.completion`
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import PromptReference
|
||||
from mcp.types import PromptReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ The same handler answers completion for resource template parameters. A `Resourc
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import ResourceTemplateReference
|
||||
from mcp.types import ResourceTemplateReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
|
|
@ -84,7 +84,7 @@ Completions often depend on values the user has already entered. A repository su
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import ResourceTemplateReference
|
||||
from mcp.types import ResourceTemplateReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ The MCP protocol caps a single response at 100 values. When more candidates exis
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import Completion, PromptReference
|
||||
from mcp.types import Completion, PromptReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ A completion handler may be sync or async, and it can reach the active request t
|
|||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.dependencies import get_context
|
||||
from mcp_types import PromptReference
|
||||
from mcp.types import PromptReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ The `Context` object provides a clean interface to access MCP features within yo
|
|||
- **Progress Reporting**: Update the client on the progress of long-running operations
|
||||
- **Resource Access**: List and read data from resources registered with the server
|
||||
- **Prompt Access**: List and retrieve prompts registered with the server
|
||||
- **LLM Sampling**: Request the client's LLM to generate text based on provided messages
|
||||
- **User Elicitation**: Request structured input from users during tool execution
|
||||
- **Request State**: Pass values and non-serializable resources between middleware and handlers within a request (for state that persists across requests, see [Session State](/servers/sessions))
|
||||
- **Session Visibility**: [Control which components are visible](/servers/visibility#per-session-visibility) to the current session
|
||||
|
|
@ -152,18 +151,9 @@ if result.action == "accept":
|
|||
|
||||
See [User Elicitation](/servers/elicitation) for detailed examples and supported response types.
|
||||
|
||||
### LLM Sampling
|
||||
|
||||
<VersionBadge version="2.0.0" />
|
||||
|
||||
Request the client's LLM to generate text based on provided messages, useful for leveraging AI capabilities within your tools.
|
||||
|
||||
```python
|
||||
response = await ctx.sample("Analyze this data", temperature=0.7)
|
||||
```
|
||||
|
||||
See [LLM Sampling](/servers/sampling) for comprehensive usage and advanced techniques.
|
||||
### Sampling and Roots
|
||||
|
||||
Neither capability has a `Context` method. Both used to *push* a request into a live client connection, which the modern MCP protocol has no channel to carry, so a tool now asks for them by returning the request and reading the answer on the next round — the same [guard pattern](/servers/elicitation#sampling-and-roots) elicitation uses on modern connections. That route is the natural one for roots; for generation, [call an LLM directly from your server](/servers/sampling).
|
||||
|
||||
### Progress Reporting
|
||||
|
||||
|
|
@ -281,7 +271,7 @@ 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
|
||||
import mcp.types as mcp_types
|
||||
|
||||
@mcp.tool
|
||||
async def custom_tool_management(ctx: Context) -> str:
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Elicitation reaches the user two different ways, depending on the protocol era t
|
|||
- **On handshake-era connections (≤ 2025-11-25)**, a running tool calls [`ctx.elicit()`](#requesting-input-on-handshake-connections). The tool pauses mid-execution, the server sends a request over the session back-channel, and the tool resumes with the answer. This is the original elicitation API and the rest of this page's first half covers it in full.
|
||||
- **On the modern protocol (2026-07-28)**, that back-channel is gone — server-initiated requests were removed from the wire (SEP-2577), so a tool cannot issue a request mid-execution and block on the answer. Instead a tool asks for input by *returning* a description of what it needs; each round completes normally and the client issues a new call with the answer attached. This is the [guard pattern](#elicitation-on-the-modern-protocol), covered in the second half.
|
||||
|
||||
The era gate is strict: `ctx.elicit()` only works on handshake connections, and the guard pattern only works on modern ones. A tool that returns a guard result on a handshake connection — or calls `ctx.elicit()` on a modern one — raises a clear era error rather than failing obscurely. A server that serves both eras may need both paths; branch on `ctx.protocol_version` to pick the right one. `fastmcp.Client` drives whichever the connection negotiated automatically.
|
||||
The era gate is strict: `ctx.elicit()` only works on handshake connections, and the guard pattern only works on modern ones. A tool that returns a guard result on a handshake connection — or calls `ctx.elicit()` on a modern one — raises a clear era error rather than failing obscurely. A server that serves both eras may need both paths; branch on `ctx.request_context.protocol_version` to pick the right one. `fastmcp.Client` drives whichever the connection negotiated automatically.
|
||||
|
||||
## Requesting input on handshake connections
|
||||
|
||||
|
|
@ -187,9 +187,9 @@ async def confirm_purchase(ctx: Context) -> str:
|
|||
|
||||
These arguments only apply when FastMCP is adding the wrapper. For structured responses (`BaseModel`, dataclass, `TypedDict`), set the metadata on the individual fields via `Field(title=..., description=...)` — passing `response_title` or `response_description` alongside a model type raises `TypeError`.
|
||||
|
||||
### Empty Responses
|
||||
### Confirmations
|
||||
|
||||
Passing `None` as the response type creates an empty-object schema and returns an accepted result with `data == {}`. This form is deprecated because some clients render empty forms poorly; prefer an explicit response type such as `bool` for confirmations.
|
||||
`response_type` is required. When all you want is a yes/no answer, ask for a `bool` rather than an empty schema — an empty schema gives the client nothing to render, and some clients show an empty, non-functional form.
|
||||
|
||||
```python
|
||||
@mcp.tool
|
||||
|
|
@ -412,7 +412,7 @@ The following tool books a flight across three rounds: it asks for a destination
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP, Context
|
||||
from mcp_types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams
|
||||
from mcp.types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams
|
||||
|
||||
mcp = FastMCP("Booking Server")
|
||||
|
||||
|
|
@ -539,13 +539,66 @@ connection negotiated '2025-11-25'. Use ctx.elicit() for server-initiated input
|
|||
on handshake-era connections.
|
||||
```
|
||||
|
||||
If you need to support both eras, branch on `ctx.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones.
|
||||
If you need to support both eras, branch on `ctx.request_context.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones.
|
||||
|
||||
### Prompts and resources
|
||||
|
||||
`InputRequiredResult` is a **result type**, not a tools feature: any request can resolve to one. Prompts, resources, and resource templates ask for input exactly the way tools do — return an `InputRequiredResult`, read `ctx.input_responses` on the next round, and the client re-issues the same `prompts/get` or `resources/read` with the answer attached.
|
||||
|
||||
This prompt gathers the context it needs before rendering:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP, Context
|
||||
from mcp.types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams
|
||||
|
||||
mcp = FastMCP("Reporting Server")
|
||||
|
||||
ask_for_quarter = InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={
|
||||
"quarter": ElicitRequest(
|
||||
method="elicitation/create",
|
||||
params=ElicitRequestFormParams(
|
||||
message="Which quarter should the summary cover?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"quarter": {"type": "string"}},
|
||||
"required": ["quarter"],
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
async def summarize(ctx: Context) -> str | InputRequiredResult:
|
||||
responses = ctx.input_responses
|
||||
if responses is None:
|
||||
return ask_for_quarter
|
||||
quarter = responses["quarter"].content["quarter"]
|
||||
return f"Summarize the {quarter} results."
|
||||
```
|
||||
|
||||
Resources and resource templates work the same way, with the URI standing in for the tool name:
|
||||
|
||||
```python
|
||||
@mcp.resource("report://summary")
|
||||
async def report(ctx: Context) -> str | InputRequiredResult:
|
||||
responses = ctx.input_responses
|
||||
if responses is None:
|
||||
return ask_for_quarter
|
||||
quarter = responses["quarter"].content["quarter"]
|
||||
return f"Revenue report for {quarter}"
|
||||
```
|
||||
|
||||
The same protocol requirement applies: returning an `InputRequiredResult` from a prompt or resource needs a 2026-07-28 connection, and FastMCP names the era mismatch if one arrives on an older one. Client-side, `read_resource` and `get_prompt` drive the loop the way `call_tool` does, so a configured elicitation handler answers all three without extra wiring.
|
||||
|
||||
### Sampling and roots
|
||||
|
||||
Elicitation is the most common request to carry this way, and **roots** requests work identically — the `input_requests` map holds them the same way, and each answer comes back in `ctx.input_responses` under its key (an `ElicitResult` or `ListRootsResult`). See [Client Roots](/clients/roots) for what a roots request contains. `fastmcp.Client` answers both from the handlers you already configured, so a guard tool that mixes them needs no extra client wiring.
|
||||
Elicitation is the most common request to carry this way, and the map carries the others just as well. A `ListRootsRequest` or a `CreateMessageRequest` sits in `input_requests` exactly as an `ElicitRequest` does, and its answer arrives in `ctx.input_responses` under the same key as a `ListRootsResult` or a `CreateMessageResult`. One map can mix all three, and `fastmcp.Client` answers each from the handlers it already has — `elicitation_handler=`, `roots=`, and `sampling_handler=` — so a tool that asks for a mixture needs no extra client wiring. [Client Roots](/clients/roots) covers what a roots request contains.
|
||||
|
||||
The map can structurally hold a **sampling** request too (its answer would be a `CreateMessageResult`), but SEP-2577 deprecated server-initiated sampling on the modern protocol, so reach for a direct server-side LLM call instead of routing generation through a guard round. See [Sampling](/servers/sampling).
|
||||
Roots and sampling differ in how well they suit the round trip. A server asks for roots once and then has what it needs, so the extra round buys the whole answer. Generation rarely works out that way, because every round is a full request-response cycle and a tool that generates in a loop pays that cost each time — [call an LLM directly from your server](/servers/sampling) unless the point is specifically to use the caller's model.
|
||||
|
||||
### Middleware
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Icons provide visual representations for your MCP servers and components, helpin
|
|||
Icons use the standard MCP Icon type from the MCP protocol specification. Each icon specifies a source URL or data URI, and optionally includes MIME type, size, and theme information.
|
||||
|
||||
```python
|
||||
from mcp_types import Icon
|
||||
from mcp.types import Icon
|
||||
|
||||
icon = Icon(
|
||||
src="https://example.com/icon.png",
|
||||
|
|
@ -37,7 +37,7 @@ Add icons and a website URL to your server for display in client applications. M
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import Icon
|
||||
from mcp.types import Icon
|
||||
|
||||
mcp = FastMCP(
|
||||
name="WeatherService",
|
||||
|
|
@ -66,7 +66,7 @@ Icons can be added to individual tools, resources, resource templates, and promp
|
|||
### Tool Icons
|
||||
|
||||
```python
|
||||
from mcp_types import Icon
|
||||
from mcp.types import Icon
|
||||
|
||||
@mcp.tool(
|
||||
icons=[Icon(src="https://example.com/calculator-icon.png")]
|
||||
|
|
@ -121,7 +121,7 @@ Supply two icons with complementary `theme` values and the client picks the one
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import Icon
|
||||
from mcp.types import Icon
|
||||
|
||||
mcp = FastMCP(
|
||||
name="WeatherService",
|
||||
|
|
@ -135,7 +135,7 @@ mcp = FastMCP(
|
|||
The same field works on tools, resources, resource templates, and prompts:
|
||||
|
||||
```python
|
||||
from mcp_types import Icon
|
||||
from mcp.types import Icon
|
||||
|
||||
@mcp.tool(
|
||||
icons=[
|
||||
|
|
@ -155,7 +155,7 @@ Omitting `theme` means the icon is assumed suitable for any theme. That's the ri
|
|||
For small icons or when you want to embed the icon directly without external dependencies, use data URIs. This approach eliminates the need for hosting and ensures the icon is always available.
|
||||
|
||||
```python
|
||||
from mcp_types import Icon
|
||||
from mcp.types import Icon
|
||||
from fastmcp.utilities.types import Image
|
||||
|
||||
# SVG icon as data URI
|
||||
|
|
@ -175,7 +175,7 @@ def my_tool() -> str:
|
|||
FastMCP provides the `Image` utility class to convert local image files into data URIs.
|
||||
|
||||
```python
|
||||
from mcp_types import Icon
|
||||
from mcp.types import Icon
|
||||
from fastmcp.utilities.types import Image
|
||||
|
||||
# Generate a data URI from a local image file
|
||||
|
|
|
|||
|
|
@ -438,6 +438,10 @@ Notifications are only sent when these operations occur within an active MCP req
|
|||
|
||||
Clients can handle these notifications using a [message handler](/clients/notifications) to automatically refresh their prompt lists or update their interfaces.
|
||||
|
||||
## Requesting Input
|
||||
|
||||
A prompt can ask the client for information before it renders. On an MCP 2026-07-28 connection, return an `InputRequiredResult` describing what you need; the client answers and re-issues the `prompts/get`, and your function runs again with the answer on `ctx.input_responses`. See [Elicitation](/servers/elicitation#prompts-and-resources) for the full pattern.
|
||||
|
||||
## Server Behavior
|
||||
|
||||
### Duplicate Prompts
|
||||
|
|
|
|||
|
|
@ -172,6 +172,8 @@ backend = ProxyClient(
|
|||
|
||||
### Tool Results Are Relayed, Not Inspected
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
A proxy passes a backend's tool results through untouched, including results that don't match the output schema the backend advertised. Deciding whether a server honored its own contract belongs to the client consuming the result, and that client validates for itself.
|
||||
|
||||
This matters when a backend's declared schema is subtly wrong — an enum missing a variant it actually returns, say. A proxy that enforced the schema would replace the backend's working response with an error of its own, and the client would never see what the backend actually said.
|
||||
|
|
|
|||
|
|
@ -783,6 +783,10 @@ def get_data_by_id(id: str) -> dict:
|
|||
|
||||
When `mask_error_details=True`, only error messages from `ResourceError` will include details, other exceptions will be converted to a generic message.
|
||||
|
||||
## Requesting Input
|
||||
|
||||
A resource or resource template can ask the client for information before it produces content. On an MCP 2026-07-28 connection, return an `InputRequiredResult` describing what you need; the client answers and re-issues the `resources/read`, and your function runs again with the answer on `ctx.input_responses`. See [Elicitation](/servers/elicitation#prompts-and-resources) for the full pattern.
|
||||
|
||||
## Server Behavior
|
||||
|
||||
### Duplicate Resources
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Sampling
|
||||
sidebarTitle: Sampling
|
||||
description: Request LLM text generation from the client or a configured provider through the MCP context.
|
||||
description: Generate text from a FastMCP server — by calling an LLM directly, or by asking the client to sample.
|
||||
icon: robot
|
||||
---
|
||||
|
||||
|
|
@ -10,582 +10,102 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
|
|||
<VersionBadge version="2.0.0" />
|
||||
|
||||
<Warning>
|
||||
**Sampling is deprecated and will be removed in a future FastMCP release.**
|
||||
**`ctx.sample()` and `ctx.sample_step()` were removed in FastMCP 4.** The modern MCP protocol gives a server no channel to push a request to its client, so there is nothing left for those methods to do.
|
||||
|
||||
`ctx.sample()` and `ctx.sample_step()` rely on server-initiated `createMessage`
|
||||
requests, which MCP removed as of the 2026-07-28 protocol (SEP-2577). They work
|
||||
only on session-based (handshake-era) connections; on a 2026-07-28 connection
|
||||
they raise a clear error rather than reaching the client.
|
||||
|
||||
**Migration:** call an LLM directly from your server using your own API key and
|
||||
provider SDK instead of borrowing the client's model. There is no drop-in
|
||||
replacement on modern connections — this architectural shift is the intended
|
||||
answer.
|
||||
To build a server that uses sampling, stay on [FastMCP 3.x](/v3/servers/sampling). On FastMCP 4, generate by [calling an LLM directly](#calling-an-llm-directly), or [ask the caller's model](#asking-the-callers-model) when borrowing their model is the point.
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
This page covers `ctx.sample()`, which requests generation over the handshake-era back-channel. The modern protocol's guard mechanism can structurally carry a sampling request in its `input_requests` map, but SEP-2577 deprecated server-initiated sampling as a pattern rather than only the `ctx.sample()` spelling of it, so that is not a supported migration path. Use the guard mechanism for [elicitation](/servers/elicitation#elicitation-on-the-modern-protocol) and roots, and call an LLM directly from your server for generation.
|
||||
</Note>
|
||||
A tool that needs text generated calls a model to get it, and in FastMCP 4 that call is ordinary Python: your server holds an API key, creates a provider client, and awaits a completion inside the tool. No protocol is involved, so the tool behaves the same for every client — including the many that never implemented sampling at all.
|
||||
|
||||
LLM sampling allows your MCP tools to request text generation from an LLM during execution. This enables tools to leverage AI capabilities for analysis, generation, reasoning, and more—without the client needing to orchestrate multiple calls.
|
||||
The alternative is to ask the caller. Sampling borrows *the caller's* model — their provider, their credentials, their bill — by returning a request for a completion that the client fulfils and hands back. Every ask costs a full round trip, so it earns its keep when using the caller's model is the point, and rarely otherwise.
|
||||
|
||||
By default, sampling requests are routed to the client's LLM. You can also configure a fallback handler to use a specific provider (like OpenAI) when the client doesn't support sampling, or to always use your own LLM regardless of client capabilities.
|
||||
## Calling an LLM directly
|
||||
|
||||
## Overview
|
||||
|
||||
The simplest use of sampling is passing a prompt string to `ctx.sample()`. The method sends the prompt to the LLM, waits for the complete response, and returns a `SamplingResult`. You can access the generated text through the `.text` attribute.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
async def summarize(content: str, ctx: Context) -> str:
|
||||
"""Generate a summary of the provided content."""
|
||||
result = await ctx.sample(f"Please summarize this:\n\n{content}")
|
||||
return result.text or ""
|
||||
```
|
||||
|
||||
The `SamplingResult` also provides `.result` (identical to `.text` for plain text responses) and `.history` containing the full message exchange—useful if you need to continue the conversation or debug the interaction.
|
||||
|
||||
### System Prompts
|
||||
|
||||
System prompts let you establish the LLM's role and behavioral guidelines before it processes your request. This is useful for controlling tone, enforcing constraints, or providing context that shouldn't clutter the user-facing prompt.
|
||||
|
||||
````python
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
async def generate_code(concept: str, ctx: Context) -> str:
|
||||
"""Generate a Python code example for a concept."""
|
||||
result = await ctx.sample(
|
||||
messages=f"Write a Python example demonstrating '{concept}'.",
|
||||
system_prompt=(
|
||||
"You are an expert Python programmer. "
|
||||
"Provide concise, working code without explanations."
|
||||
),
|
||||
temperature=0.7,
|
||||
max_tokens=300
|
||||
)
|
||||
return f"```python\n{result.text}\n```"
|
||||
````
|
||||
|
||||
The `temperature` parameter controls randomness—higher values (up to 1.0) produce more varied outputs, while lower values make responses more deterministic. The `max_tokens` parameter limits response length.
|
||||
|
||||
### Model Preferences
|
||||
|
||||
Model preferences let you hint at which LLM the client should use for a request. You can pass a single model name or a list of preferences in priority order. These are hints rather than requirements—the actual model used depends on what the client has available.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
async def technical_analysis(data: str, ctx: Context) -> str:
|
||||
"""Analyze data using a reasoning-focused model."""
|
||||
result = await ctx.sample(
|
||||
messages=f"Analyze this data:\n\n{data}",
|
||||
model_preferences=["claude-opus-4-5", "gpt-5-2"],
|
||||
temperature=0.2,
|
||||
)
|
||||
return result.text or ""
|
||||
```
|
||||
|
||||
Use model preferences when different tasks benefit from different model characteristics. Creative writing might prefer faster models with higher temperature, while complex analysis might benefit from larger reasoning-focused models.
|
||||
|
||||
### Multi-Turn Conversations
|
||||
|
||||
For requests that need conversational context, construct a list of `SamplingMessage` objects representing the conversation history. Each message has a `role` ("user" or "assistant") and `content` (a `TextContent` object).
|
||||
|
||||
```python
|
||||
from mcp_types import SamplingMessage, TextContent
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
async def contextual_analysis(query: str, data: str, ctx: Context) -> str:
|
||||
"""Analyze data with conversational context."""
|
||||
messages = [
|
||||
SamplingMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text=f"Here's my data: {data}"),
|
||||
),
|
||||
SamplingMessage(
|
||||
role="assistant",
|
||||
content=TextContent(type="text", text="I see the data. What would you like to know?"),
|
||||
),
|
||||
SamplingMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text=query),
|
||||
),
|
||||
]
|
||||
result = await ctx.sample(messages=messages)
|
||||
return result.text or ""
|
||||
```
|
||||
|
||||
The LLM receives the full conversation thread and responds with awareness of the preceding context.
|
||||
|
||||
### Fallback Handlers
|
||||
|
||||
Client support for sampling is optional—some clients may not implement it. To ensure your tools work regardless of client capabilities, configure a `sampling_handler` that sends requests directly to an LLM provider.
|
||||
|
||||
FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format.
|
||||
|
||||
<Note>
|
||||
Install handlers with `pip install 'fastmcp[openai]'` or `pip install 'fastmcp[anthropic]'`.
|
||||
</Note>
|
||||
Hold a provider API key in your server's environment, create the client once at module scope so connections are reused across calls, and generate inside the tool. You choose the model, control the prompt, see the token usage, and can test the tool with no client attached.
|
||||
|
||||
```python
|
||||
import anthropic
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
|
||||
|
||||
server = FastMCP(
|
||||
name="My Server",
|
||||
sampling_handler=OpenAISamplingHandler(default_model="gpt-4o-mini"),
|
||||
sampling_handler_behavior="fallback",
|
||||
)
|
||||
```
|
||||
mcp = FastMCP("Summarizer")
|
||||
llm = anthropic.AsyncAnthropic()
|
||||
|
||||
The `sampling_handler_behavior` parameter controls when the handler is used:
|
||||
|
||||
- **`"fallback"`** (default): Use the handler only when the client doesn't support sampling. This lets capable clients use their own LLM while ensuring your tools still work with clients that lack sampling support.
|
||||
- **`"always"`**: Always use the handler, bypassing the client entirely. Use this when you need guaranteed control over which LLM processes requests—for cost control, compliance requirements, or when specific model characteristics are essential.
|
||||
|
||||
## Structured Output
|
||||
|
||||
<VersionBadge version="2.14.1" />
|
||||
|
||||
When you need validated, typed data instead of free-form text, use the `result_type` parameter. FastMCP ensures the LLM returns data matching your type, handling validation and retries automatically.
|
||||
|
||||
The `result_type` parameter accepts Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. When you specify a result type, FastMCP automatically creates a `final_response` tool that the LLM calls to provide its response. If validation fails, the error is sent back to the LLM for retry.
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
class SentimentResult(BaseModel):
|
||||
sentiment: str
|
||||
confidence: float
|
||||
reasoning: str
|
||||
|
||||
@mcp.tool
|
||||
async def analyze_sentiment(text: str, ctx: Context) -> SentimentResult:
|
||||
"""Analyze text sentiment with structured output."""
|
||||
result = await ctx.sample(
|
||||
messages=f"Analyze the sentiment of: {text}",
|
||||
result_type=SentimentResult,
|
||||
async def summarize(text: str) -> str:
|
||||
"""Summarize a document in two sentences."""
|
||||
response = await llm.messages.create(
|
||||
model="claude-sonnet-4-5",
|
||||
max_tokens=512,
|
||||
system="Summarize the user's text in exactly two sentences.",
|
||||
messages=[{"role": "user", "content": text}],
|
||||
)
|
||||
return result.result # A validated SentimentResult object
|
||||
return response.content[0].text
|
||||
```
|
||||
|
||||
When you call this tool, the LLM returns a structured response that FastMCP validates against your Pydantic model. You access the validated object through `result.result`, while `result.text` contains the JSON representation.
|
||||
Any provider SDK works the same way — swap the client and the call, and the tool signature is unchanged. Because generation is ordinary application code, the concerns around it are ordinary too: retries, timeouts, caching, and cost accounting go wherever you want them rather than being negotiated across a protocol boundary. A tool that chains several generations pays nothing extra for the second and third, where asking the caller would pay a full round trip for each.
|
||||
|
||||
### Structured Output with Tools
|
||||
## Asking the caller's model
|
||||
|
||||
Combine structured output with tools for agentic workflows that return validated data. The LLM uses your tools to gather information, then returns a response matching your type.
|
||||
A tool asks for a completion by returning an `InputRequiredResult` whose `input_requests` map holds a `CreateMessageRequest` under a key you choose. That result completes the round normally. The client runs the completion, then re-issues the same `call_tool` with the answer attached, and your tool reads it from `ctx.input_responses` under the same key — a `CreateMessageResult`. Because the tool runs from the top on every round, the presence of `ctx.input_responses` is what tells the two rounds apart: `None` on the first call, populated on the continuation.
|
||||
|
||||
`fastmcp.Client` drives that loop for you and answers from the [`sampling_handler`](/clients/sampling) it already has, so a client written for a handshake-era server needs no extra wiring to satisfy a modern tool that asks this way.
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from fastmcp import FastMCP, Context
|
||||
from fastmcp import Context, FastMCP
|
||||
from mcp.types import (
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
InputRequiredResult,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
mcp = FastMCP()
|
||||
mcp = FastMCP("Research")
|
||||
|
||||
def search(query: str) -> str:
|
||||
"""Search the web for information."""
|
||||
return f"Results for: {query}"
|
||||
|
||||
def fetch_url(url: str) -> str:
|
||||
"""Fetch content from a URL."""
|
||||
return f"Content from: {url}"
|
||||
|
||||
class ResearchResult(BaseModel):
|
||||
summary: str
|
||||
sources: list[str]
|
||||
confidence: float
|
||||
|
||||
@mcp.tool
|
||||
async def research(topic: str, ctx: Context) -> ResearchResult:
|
||||
"""Research a topic and return structured findings."""
|
||||
result = await ctx.sample(
|
||||
messages=f"Research: {topic}",
|
||||
tools=[search, fetch_url],
|
||||
result_type=ResearchResult,
|
||||
)
|
||||
return result.result
|
||||
```
|
||||
|
||||
<Note>
|
||||
Structured output with automatic validation only applies to `sample()`. With `sample_step()`, you must manage structured output yourself.
|
||||
</Note>
|
||||
|
||||
## Tool Use
|
||||
|
||||
<VersionBadge version="2.14.1" />
|
||||
|
||||
Sampling with tools enables agentic workflows where the LLM can call functions to gather information before responding. This implements [SEP-1577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577), allowing the LLM to autonomously orchestrate multi-step operations.
|
||||
|
||||
Pass Python functions to the `tools` parameter, and FastMCP handles the execution loop automatically—calling tools, returning results to the LLM, and continuing until the LLM provides a final response.
|
||||
|
||||
### Defining Tools
|
||||
|
||||
Define regular Python functions with type hints and docstrings. FastMCP extracts the function's name, docstring, and parameter types to create tool schemas that the LLM can understand.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
def search(query: str) -> str:
|
||||
"""Search the web for information."""
|
||||
return f"Results for: {query}"
|
||||
|
||||
def get_time() -> str:
|
||||
"""Get the current time."""
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
async def research(question: str, ctx: Context) -> str:
|
||||
"""Answer questions using available tools."""
|
||||
result = await ctx.sample(
|
||||
messages=question,
|
||||
tools=[search, get_time],
|
||||
)
|
||||
return result.text or ""
|
||||
```
|
||||
|
||||
The LLM sees each function's signature and docstring, using this information to decide when and how to call them. Tool errors are caught and sent back to the LLM, allowing it to recover gracefully. An internal safety limit prevents infinite loops.
|
||||
|
||||
### Custom Tool Definitions
|
||||
|
||||
For custom names or descriptions, use `SamplingTool.from_function()`:
|
||||
|
||||
```python
|
||||
from fastmcp.server.sampling import SamplingTool
|
||||
|
||||
tool = SamplingTool.from_function(
|
||||
my_func,
|
||||
name="custom_name",
|
||||
description="Custom description"
|
||||
)
|
||||
|
||||
result = await ctx.sample(messages="...", tools=[tool])
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
By default, when a sampling tool raises an exception, the error message (including details) is sent back to the LLM so it can attempt recovery. To prevent sensitive information from leaking to the LLM, use the `mask_error_details` parameter:
|
||||
|
||||
```python
|
||||
result = await ctx.sample(
|
||||
messages=question,
|
||||
tools=[search],
|
||||
mask_error_details=True, # Generic error messages only
|
||||
)
|
||||
```
|
||||
|
||||
When `mask_error_details=True`, tool errors become generic messages like `"Error executing tool 'search'"` instead of exposing stack traces or internal details.
|
||||
|
||||
To intentionally provide specific error messages to the LLM regardless of masking, raise `ToolError`:
|
||||
|
||||
```python
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
def search(query: str) -> str:
|
||||
"""Search for information."""
|
||||
if not query.strip():
|
||||
raise ToolError("Search query cannot be empty")
|
||||
return f"Results for: {query}"
|
||||
```
|
||||
|
||||
`ToolError` messages always pass through to the LLM, making it the escape hatch for errors you want the LLM to see and handle.
|
||||
|
||||
### Concurrent Tool Execution
|
||||
|
||||
By default, tools execute sequentially — one at a time, in order. When your tools are independent (no shared state between them), you can execute them in parallel with `tool_concurrency`:
|
||||
|
||||
```python
|
||||
result = await ctx.sample(
|
||||
messages="Research these three topics",
|
||||
tools=[search, fetch_url],
|
||||
tool_concurrency=0, # Unlimited parallel execution
|
||||
)
|
||||
```
|
||||
|
||||
The `tool_concurrency` parameter controls how many tools run at once:
|
||||
|
||||
- **`None`** (default): Sequential execution
|
||||
- **`0`**: Unlimited parallel execution
|
||||
- **`N > 0`**: Execute at most N tools concurrently
|
||||
|
||||
For tools that must not run concurrently (file writes, shared state mutations, etc.), mark them as `sequential` when creating the `SamplingTool`:
|
||||
|
||||
```python
|
||||
from fastmcp.server.sampling import SamplingTool
|
||||
|
||||
db_writer = SamplingTool.from_function(
|
||||
write_to_db,
|
||||
sequential=True, # Forces all tools in the batch to run sequentially
|
||||
)
|
||||
|
||||
result = await ctx.sample(
|
||||
messages="Process this data",
|
||||
tools=[search, db_writer],
|
||||
tool_concurrency=0, # Would be parallel, but db_writer forces sequential
|
||||
)
|
||||
```
|
||||
|
||||
<Note>
|
||||
When any tool in a batch has `sequential=True`, the entire batch executes sequentially regardless of `tool_concurrency`. This is a conservative guarantee — if one tool needs ordering, all tools in that batch respect it.
|
||||
</Note>
|
||||
|
||||
### Client Requirements
|
||||
|
||||
<Note>
|
||||
Sampling with tools requires the client to advertise the `sampling.tools` capability. FastMCP clients do this automatically. For external clients that don't support tool-enabled sampling, configure a fallback handler with `sampling_handler_behavior="always"`.
|
||||
</Note>
|
||||
|
||||
## Advanced Control
|
||||
|
||||
<VersionBadge version="2.14.1" />
|
||||
|
||||
While `sample()` handles the tool execution loop automatically, some scenarios require fine-grained control over each step. The `sample_step()` method makes a single LLM call and returns a `SampleStep` containing the response and updated history.
|
||||
|
||||
Unlike `sample()`, `sample_step()` is stateless—it doesn't remember previous calls. You control the conversation by passing the full message history each time. The returned `step.history` includes all messages up through the current response, making it easy to continue the loop.
|
||||
|
||||
Use `sample_step()` when you need to:
|
||||
|
||||
- Inspect tool calls before they execute
|
||||
- Implement custom termination conditions
|
||||
- Add logging, metrics, or checkpointing between steps
|
||||
- Build custom agentic loops with domain-specific logic
|
||||
|
||||
### Basic Loop
|
||||
|
||||
By default, `sample_step()` executes any tool calls and includes the results in the history. Call it in a loop, passing the updated history each time, until a stop condition is met.
|
||||
|
||||
```python
|
||||
from mcp_types import SamplingMessage
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
def search(query: str) -> str:
|
||||
return f"Results for: {query}"
|
||||
|
||||
def get_time() -> str:
|
||||
return "12:00 PM"
|
||||
|
||||
@mcp.tool
|
||||
async def controlled_agent(question: str, ctx: Context) -> str:
|
||||
"""Agent with manual loop control."""
|
||||
messages: list[str | SamplingMessage] = [question]
|
||||
|
||||
while True:
|
||||
step = await ctx.sample_step(
|
||||
messages=messages,
|
||||
tools=[search, get_time],
|
||||
)
|
||||
|
||||
if step.is_tool_use:
|
||||
# Tools already executed (execute_tools=True by default)
|
||||
for call in step.tool_calls:
|
||||
print(f"Called tool: {call.name}")
|
||||
|
||||
if not step.is_tool_use:
|
||||
return step.text or ""
|
||||
|
||||
messages = step.history
|
||||
```
|
||||
|
||||
### SampleStep Properties
|
||||
|
||||
Each `SampleStep` provides information about what the LLM returned:
|
||||
|
||||
| Property | Description |
|
||||
|----------|-------------|
|
||||
| `step.is_tool_use` | True if the LLM requested tool calls |
|
||||
| `step.tool_calls` | List of tool calls requested (if any) |
|
||||
| `step.text` | The text content (if any) |
|
||||
| `step.history` | All messages exchanged so far |
|
||||
|
||||
The contents of `step.history` depend on `execute_tools`:
|
||||
- **`execute_tools=True`** (default): Includes tool results, ready for the next iteration
|
||||
- **`execute_tools=False`**: Includes the assistant's tool request, but you add results yourself
|
||||
|
||||
### Manual Tool Execution
|
||||
|
||||
Set `execute_tools=False` to handle tool execution yourself. When disabled, `step.history` contains the user message and the assistant's response with tool calls—but no tool results. You execute the tools and append the results as a user message.
|
||||
|
||||
```python
|
||||
from mcp_types import SamplingMessage, ToolResultContent, TextContent
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
async def research(question: str, ctx: Context) -> str:
|
||||
"""Research with manual tool handling."""
|
||||
|
||||
def search(query: str) -> str:
|
||||
return f"Results for: {query}"
|
||||
|
||||
def get_time() -> str:
|
||||
return "12:00 PM"
|
||||
|
||||
tools = {"search": search, "get_time": get_time}
|
||||
messages: list[SamplingMessage] = [question]
|
||||
|
||||
while True:
|
||||
step = await ctx.sample_step(
|
||||
messages=messages,
|
||||
tools=list(tools.values()),
|
||||
execute_tools=False,
|
||||
)
|
||||
|
||||
if not step.is_tool_use:
|
||||
return step.text or ""
|
||||
|
||||
# Execute tools and collect results
|
||||
tool_results = []
|
||||
for call in step.tool_calls:
|
||||
fn = tools[call.name]
|
||||
result = fn(**call.input)
|
||||
tool_results.append(
|
||||
ToolResultContent(
|
||||
type="tool_result",
|
||||
tool_use_id=call.id,
|
||||
content=[TextContent(type="text", text=result)],
|
||||
async def ask_the_caller(question: str, ctx: Context) -> str | InputRequiredResult:
|
||||
"""Put a question to the caller's model and report what it answered."""
|
||||
responses = ctx.input_responses
|
||||
if responses is None:
|
||||
return InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={
|
||||
"answer": CreateMessageRequest(
|
||||
method="sampling/createMessage",
|
||||
params=CreateMessageRequestParams(
|
||||
messages=[
|
||||
SamplingMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text=question),
|
||||
)
|
||||
],
|
||||
max_tokens=100,
|
||||
),
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
messages = list(step.history)
|
||||
messages.append(SamplingMessage(role="user", content=tool_results))
|
||||
answer = responses["answer"]
|
||||
if isinstance(answer, CreateMessageResult) and isinstance(
|
||||
answer.content, TextContent
|
||||
):
|
||||
return answer.content.text
|
||||
return "The client returned no completion."
|
||||
```
|
||||
|
||||
To report an error to the LLM, set `is_error=True` on the tool result:
|
||||
Returning an `InputRequiredResult` needs a `2026-07-28` connection, and FastMCP names the era mismatch if an older client reaches the tool; the conformance suite exercises this route on that version. The map can carry several requests at once and mix kinds — a sampling request beside an elicitation or a roots request — with each answer coming back under its own key. [Elicitation](/servers/elicitation#sampling-and-roots) covers the mechanics of the pattern in full, including how to carry state across rounds.
|
||||
|
||||
```python
|
||||
tool_result = ToolResultContent(
|
||||
type="tool_result",
|
||||
tool_use_id=call.id,
|
||||
content=[TextContent(type="text", text="Permission denied")],
|
||||
is_error=True,
|
||||
)
|
||||
```
|
||||
## The removed methods
|
||||
|
||||
## Method Reference
|
||||
`Context` has no `sample()` and no `sample_step()`; touching either raises `AttributeError` on every protocol era, rather than failing at runtime only against modern clients. `FastMCP()` accepts neither `sampling_handler=` nor `sampling_handler_behavior=`, and naming one raises a `TypeError` that points at the migration.
|
||||
|
||||
<Card icon="code" title="ctx.sample()">
|
||||
<ResponseField name="ctx.sample" type="async method">
|
||||
Request text generation from the LLM, running to completion automatically.
|
||||
The reason is the distinction MCP draws between telling and asking. A notification is fire-and-forget: the server emits it and moves on, and it travels down the response stream the caller already opened, so nothing has to be held open on the server's behalf. That is why [logging](/servers/logging) is untouched by any of this — `ctx.info()` and its siblings reach the client mid-call on every era. Sampling is the other kind. `sampling/createMessage` goes out and the caller must answer before the tool can continue, which needs a live, addressable connection the server can reach into, and the `2026-07-28` revision removed server-initiated requests ([SEP-2577](https://modelcontextprotocol.io/community/sep-guidelines)) precisely because a stateless protocol has no such thing.
|
||||
|
||||
<Expandable title="Parameters">
|
||||
<ResponseField name="messages" type="str | list[str | SamplingMessage]">
|
||||
The prompt to send. Can be a simple string or a list of messages for multi-turn conversations.
|
||||
</ResponseField>
|
||||
What the protocol removed is the pushing, not the asking, so the capability survives in the shape described above. Keeping `ctx.sample()` alongside it would mean shipping a method whose outcome against a default client — one that negotiates the modern era — is a runtime failure.
|
||||
|
||||
<ResponseField name="system_prompt" type="str | None" default="None">
|
||||
Instructions that establish the LLM's role and behavior.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="temperature" type="float | None" default="None">
|
||||
Controls randomness (0.0 = deterministic, 1.0 = creative).
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="max_tokens" type="int | None" default="512">
|
||||
Maximum tokens to generate.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="model_preferences" type="str | list[str] | None" default="None">
|
||||
Hints for which model the client should use.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="tools" type="list[Callable] | None" default="None">
|
||||
Functions the LLM can call during sampling.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="result_type" type="type[T] | None" default="None">
|
||||
A type for validated structured output. Supports Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="mask_error_details" type="bool | None" default="None">
|
||||
If True, mask detailed error messages from tool execution. When None (default), uses the global `settings.mask_error_details` value. Tools can raise `ToolError` to bypass masking and provide specific error messages to the LLM.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="tool_concurrency" type="int | None" default="None">
|
||||
Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. If any tool has `sequential=True`, all tools execute sequentially regardless.
|
||||
</ResponseField>
|
||||
|
||||
</Expandable>
|
||||
|
||||
<Expandable title="Response">
|
||||
<ResponseField name="SamplingResult[T]" type="dataclass">
|
||||
- `.text`: The raw text response (or JSON for structured output)
|
||||
- `.result`: The typed result—same as `.text` for plain text, or a validated Pydantic object for structured output
|
||||
- `.history`: All messages exchanged during sampling
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
</Card>
|
||||
|
||||
<Card icon="code" title="ctx.sample_step()">
|
||||
<ResponseField name="ctx.sample_step" type="async method">
|
||||
Make a single LLM sampling call. Use this for fine-grained control over the sampling loop.
|
||||
|
||||
<Expandable title="Parameters">
|
||||
<ResponseField name="messages" type="str | list[str | SamplingMessage]">
|
||||
The prompt or conversation history.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="system_prompt" type="str | None" default="None">
|
||||
Instructions that establish the LLM's role and behavior.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="temperature" type="float | None" default="None">
|
||||
Controls randomness (0.0 = deterministic, 1.0 = creative).
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="max_tokens" type="int | None" default="512">
|
||||
Maximum tokens to generate.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="tools" type="list[Callable] | None" default="None">
|
||||
Functions the LLM can call during sampling.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="tool_choice" type="str | None" default="None">
|
||||
Controls tool usage: `"auto"`, `"required"`, or `"none"`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="execute_tools" type="bool" default="True">
|
||||
If True, execute tool calls and append results to history. If False, return immediately with tool calls available for manual execution.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="mask_error_details" type="bool | None" default="None">
|
||||
If True, mask detailed error messages from tool execution.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="tool_concurrency" type="int | None" default="None">
|
||||
Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency.
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
|
||||
<Expandable title="Response">
|
||||
<ResponseField name="SampleStep" type="dataclass">
|
||||
- `.response`: The raw LLM response
|
||||
- `.history`: Messages including input, assistant response, and tool results
|
||||
- `.is_tool_use`: True if the LLM requested tool execution
|
||||
- `.tool_calls`: List of tool calls (if any)
|
||||
- `.text`: The text content (if any)
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
</Card>
|
||||
<Note>
|
||||
Servers on FastMCP 3 still have `ctx.sample()` and `ctx.sample_step()`, documented in the [FastMCP 3 sampling guide](/v3/servers/sampling). Nothing changes for them until they upgrade.
|
||||
</Note>
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ These parameters tune how the server processes requests and communicates with cl
|
|||
<ParamField body="client_log_level" type="LoggingLevel | None">
|
||||
<VersionBadge version="3.2.0" />
|
||||
|
||||
Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"`
|
||||
Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Handshake-era clients can override this per-session using the MCP `logging/setLevel` request; the modern protocol has no session to hold that level, so clients on it filter by level in their own log handler instead. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"`
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="dereference_schemas" type="bool" default="True">
|
||||
|
|
@ -211,19 +211,9 @@ These parameters tune how the server processes requests and communicates with cl
|
|||
</ParamField>
|
||||
</Card>
|
||||
|
||||
### Handlers and Storage
|
||||
|
||||
These parameters provide custom handlers for MCP capabilities and persistent storage for session state.
|
||||
### Storage
|
||||
|
||||
<Card>
|
||||
<ParamField body="sampling_handler" type="SamplingHandler | None">
|
||||
Custom handler for MCP sampling requests (server-initiated LLM calls). See [Sampling](/servers/sampling) for details
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="sampling_handler_behavior" type='Literal["always", "fallback"] | None' default="fallback">
|
||||
When `"fallback"`, the sampling handler is used only when no tool-specific handler exists. When `"always"`, this handler is used for all sampling requests
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="session_state_store" type="AsyncKeyValue | None">
|
||||
Persistent key-value store for session state that survives across requests. Defaults to an in-memory store. Provide a custom implementation for persistence across server restarts
|
||||
</ParamField>
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ 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
|
||||
import mcp.types as mcp_types
|
||||
|
||||
mcp = FastMCP("MyServer")
|
||||
mcp.add_extension(TasksExtension())
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ icon: chart-line
|
|||
tag: NEW
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
FastMCP includes native OpenTelemetry instrumentation for observability. Traces are automatically generated for tool, prompt, resource, resource template, and task management operations, providing visibility into server behavior, request handling, and provider delegation chains.
|
||||
|
||||
## How It Works
|
||||
|
|
@ -19,9 +21,21 @@ FastMCP uses the OpenTelemetry API for instrumentation. This means:
|
|||
|
||||
Because FastMCP only depends on the OpenTelemetry API, span creation is a no-op until you configure an SDK and exporter — so being on by default costs nothing until you opt into collection.
|
||||
|
||||
### Turning Telemetry Off
|
||||
### Telemetry Modes
|
||||
|
||||
To disable FastMCP's instrumentation entirely, set `FASTMCP_ENABLE_TELEMETRY=false` (or `fastmcp.settings.enable_telemetry = False`). When disabled, FastMCP creates no spans even if an SDK is configured.
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
`FASTMCP_TELEMETRY_MODE` (or `fastmcp.settings.telemetry_mode`) controls how much of the instrumentation is active:
|
||||
|
||||
| Mode | FastMCP spans | Trace context |
|
||||
|---|---|---|
|
||||
| `native` (default) | Emitted | Propagated |
|
||||
| `propagation_only` | Suppressed | Propagated |
|
||||
| `off` | Suppressed | Untouched |
|
||||
|
||||
Use `off` to disable FastMCP's instrumentation entirely. No spans are created even if an SDK is configured, and FastMCP leaves the surrounding OpenTelemetry context exactly as it found it.
|
||||
|
||||
Use `propagation_only` when another instrumentation layer already owns the MCP span hierarchy — see [Interoperability](#interoperability) below.
|
||||
|
||||
## Enabling Telemetry
|
||||
|
||||
|
|
@ -144,6 +158,37 @@ trace.set_tracer_provider(provider)
|
|||
|
||||
The name check must happen before `ParentBased` delegates. If the name-based sampler is nested inside `ParentBased`, it is not consulted for child spans whose parent was already sampled.
|
||||
|
||||
## Interoperability
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
FastMCP assumes it owns the MCP span hierarchy. When something else already owns it — an MCP-aware OpenTelemetry instrumentation library, or a service mesh that understands the protocol — FastMCP's spans duplicate what that layer already emits, and the same request shows up twice in your traces.
|
||||
|
||||
Setting `propagation_only` resolves the duplication in FastMCP's favor of the other layer:
|
||||
|
||||
```bash
|
||||
export FASTMCP_TELEMETRY_MODE=propagation_only
|
||||
```
|
||||
|
||||
The distinction from `off` matters here. Both emit no FastMCP spans, but `off` is fully transparent, while `propagation_only` still extracts the trace context arriving in `_meta` and attaches it for the duration of the request. Spans created downstream — by your tool handlers, or by the instrumentation layer that owns the hierarchy — are parented to the calling trace rather than starting a new one. Outbound requests still carry `traceparent` and `tracestate` in `_meta`.
|
||||
|
||||
### Suppressing spans for a single block
|
||||
|
||||
Library authors embedding FastMCP inside their own instrumented stack often want to own the hierarchy for one specific operation rather than process-wide. `suppress_fastmcp_telemetry()` applies `propagation_only` semantics to a block:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.telemetry import suppress_fastmcp_telemetry
|
||||
|
||||
async def search(client: Client, query: str):
|
||||
with suppress_fastmcp_telemetry():
|
||||
return await client.call_tool("search", {"query": query})
|
||||
```
|
||||
|
||||
This is narrower than OpenTelemetry's global instrumentation suppression: only FastMCP's spans are skipped, so nested instrumentation for HTTP clients, databases, and everything else keeps emitting normally.
|
||||
|
||||
The context manager has no effect when `telemetry_mode` is already `off`. A request to skip FastMCP's spans cannot re-enable the context propagation that `off` deliberately omits.
|
||||
|
||||
## Programmatic Configuration
|
||||
|
||||
For more control, configure the SDK in your Python code before importing FastMCP:
|
||||
|
|
@ -238,7 +283,7 @@ Custom spans are most useful around work that is expensive or hard to debug:
|
|||
- External calls such as databases, vector stores, HTTP APIs, or queue operations
|
||||
- Multi-step tool logic where one stage dominates latency
|
||||
- Prompt or resource generation that fans out to other systems
|
||||
- Sampling calls made from inside a tool via `ctx.sample(...)`
|
||||
- LLM calls a tool makes to a model provider
|
||||
|
||||
Avoid wrapping every small helper function or simple in-memory transformation. That usually adds noise without making traces easier to interpret.
|
||||
|
||||
|
|
@ -286,9 +331,9 @@ async def docs_resource(slug: str) -> str:
|
|||
return await load_doc(slug)
|
||||
```
|
||||
|
||||
### Sampling calls inside tools
|
||||
### LLM calls inside tools
|
||||
|
||||
If your tool uses `ctx.sample(...)`, keep the LLM work nested under the tool span so traces show both application logic and model latency together.
|
||||
A tool that [calls an LLM directly](/servers/sampling) should keep the model work nested under the tool span, so traces show application logic and model latency together.
|
||||
|
||||
For providers with their own OTEL integrations, prefer enabling that instrumentation rather than manually creating a span around every model call. For example, if you use Google GenAI, `logfire.instrument_google_genai()` will emit child spans with token and request metadata under the active FastMCP tool span.
|
||||
|
||||
|
|
|
|||
|
|
@ -722,8 +722,8 @@ Schema generation works for most common types including basic types, collections
|
|||
For complete control over tool responses, return a `ToolResult` object. This gives you explicit control over all aspects of the tool's output: traditional content, structured data, and metadata.
|
||||
|
||||
```python
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
from mcp_types import TextContent
|
||||
from fastmcp.tools import ToolResult
|
||||
from mcp.types import TextContent
|
||||
|
||||
@mcp.tool
|
||||
def advanced_tool() -> ToolResult:
|
||||
|
|
@ -788,7 +788,7 @@ When you need custom serialization (like YAML, Markdown tables, or specialized f
|
|||
```python
|
||||
import yaml
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
from fastmcp.tools import ToolResult
|
||||
|
||||
mcp = FastMCP("MyServer")
|
||||
|
||||
|
|
@ -944,7 +944,7 @@ Annotations serve several purposes in client applications:
|
|||
You can add annotations to a tool using the `annotations` parameter in the `@mcp.tool` decorator. FastMCP accepts either a plain dict or `ToolAnnotations`; the examples below use `ToolAnnotations` for consistency and stronger editor/type support.
|
||||
|
||||
```python
|
||||
from mcp_types import ToolAnnotations
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
@mcp.tool(
|
||||
annotations=ToolAnnotations(
|
||||
|
|
@ -983,7 +983,7 @@ Mark a tool as read-only when it retrieves data, performs calculations, or check
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import ToolAnnotations
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
mcp = FastMCP("Data Server")
|
||||
|
||||
|
|
@ -1056,22 +1056,14 @@ mcp = FastMCP(name="ContextDemo")
|
|||
async def process_data(data_uri: str, ctx: Context) -> dict:
|
||||
"""Process data from a resource with progress reporting."""
|
||||
await ctx.info(f"Processing data from {data_uri}")
|
||||
|
||||
# Read a resource
|
||||
resource = await ctx.read_resource(data_uri)
|
||||
data = resource[0].content if resource else ""
|
||||
|
||||
# Report progress
|
||||
|
||||
result = await ctx.read_resource(data_uri)
|
||||
data = result.contents[0].content if result.contents else ""
|
||||
await ctx.report_progress(progress=50, total=100)
|
||||
|
||||
# Example request to the client's LLM for help
|
||||
summary = await ctx.sample(f"Summarize this in 10 words: {data[:200]}")
|
||||
|
||||
|
||||
summary = str(data)[:200]
|
||||
await ctx.report_progress(progress=100, total=100)
|
||||
return {
|
||||
"length": len(data),
|
||||
"summary": summary.text
|
||||
}
|
||||
return {"length": len(data), "summary": summary}
|
||||
```
|
||||
|
||||
The Context object provides access to:
|
||||
|
|
@ -1079,7 +1071,6 @@ The Context object provides access to:
|
|||
- **Logging**: `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`
|
||||
- **Progress Reporting**: `ctx.report_progress(progress, total)`
|
||||
- **Resource Access**: `ctx.read_resource(uri)`
|
||||
- **LLM Sampling**: `ctx.sample(...)`
|
||||
- **Request Information**: `ctx.request_id`, `ctx.client_id`
|
||||
|
||||
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ Here's a minimal example:
|
|||
from fastmcp.experimental.transforms.code_mode import CodeMode
|
||||
from fastmcp.experimental.transforms.code_mode import GetToolCatalog, GetSchemas
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.tools.tool import Tool
|
||||
from fastmcp.tools import Tool
|
||||
|
||||
def list_all_tools(get_catalog: GetToolCatalog) -> Tool:
|
||||
async def list_tools(ctx: Context) -> str:
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ Tools discovered through search can also be called directly via `client.call_too
|
|||
|
||||
Search results respect the full authorization pipeline. Tools filtered by middleware, visibility transforms, or component-level auth checks won't appear in search results.
|
||||
|
||||
App-only tools are excluded too. A [MCP app](/apps/overview) can declare backend tools that only its UI may call, and normally the host keeps those from the model. A search result is tool output rather than an advertised listing, so no host filtering applies to it — the exclusion happens here instead. The `call_tool` proxy enforces the same boundary, since it executes a name the model supplies.
|
||||
|
||||
The search tool queries `list_tools()` through the complete pipeline at search time, so the same filtering that controls what a client sees in the listing also controls what they can discover through search.
|
||||
|
||||
```python
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ Create custom transforms by subclassing `Transform` and overriding the methods y
|
|||
```python
|
||||
from collections.abc import Sequence
|
||||
from fastmcp.server.transforms import Transform, GetToolNext
|
||||
from fastmcp.tools.tool import Tool
|
||||
from fastmcp.tools import Tool
|
||||
|
||||
class TagFilter(Transform):
|
||||
"""Filter tools to only those with specific tags."""
|
||||
|
|
|
|||
|
|
@ -5,6 +5,38 @@ icon: "sparkles"
|
|||
tag: NEW
|
||||
---
|
||||
|
||||
<Update label="FastMCP 4.0.0b1" description="July 28, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
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.
|
||||
|
||||
🌐 **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).
|
||||
</Card>
|
||||
</Update>
|
||||
|
||||
<Update label="FastMCP 3.4.5" description="July 27, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
title="FastMCP v3.4.5: Key Change"
|
||||
href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.5"
|
||||
cta="Read the release notes"
|
||||
>
|
||||
A maintenance release for the 3.x line. A single unrecognized JWKS key — Ed25519, which Rauthy and Ory Hydra publish by default — no longer poisons the entire key cache, alongside fixes for Azure scope fallback, OpenAPI `deepObject` query serialization, schema compression, and transformed tool `required` ordering.
|
||||
</Card>
|
||||
</Update>
|
||||
|
||||
<Update label="FastMCP 3.4.4" description="July 8, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
title="FastMCP v3.4.4: Host in Translation"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue