mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
The initialize handshake is negotiated by the SDK with no knowledge of the server's protocol_versions allowlist, and FastMCP can only veto the handshake, not steer the negotiated revision. So a server pinned to an older handshake revision (e.g. ["2024-11-05"]) wrongly refused an ordinary client that requested a newer handshake revision. Enforce era membership for handshake versions (exact membership stays for modern per-request versions), refusing only a genuine cross-era mismatch.
201 lines
14 KiB
Text
201 lines
14 KiB
Text
---
|
|
title: 2026-07-28 Protocol Support
|
|
---
|
|
|
|
import { VersionBadge } from "/snippets/version-badge.mdx"
|
|
|
|
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): a complete server-side implementation
|
|
|
|
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")` probes `server/discover` and falls back to the classic handshake; the client answers multi-round-trip `input_required` requests through its existing handlers. |
|
|
| **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. |
|
|
| **Background tasks** | `@mcp.tool(task=True)` runs on a Redis-backed distributed runtime (Docket) with cross-replica notifications — execution infrastructure that is FastMCP's own, independent of the protocol-era task surface. |
|
|
| **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. |
|
|
|
|
## Restricting the protocol versions a server serves
|
|
|
|
<VersionBadge version="4.0.0" />
|
|
|
|
Most servers should skip this section. By default a FastMCP server serves every
|
|
protocol version the SDK supports, and that is the right setting for almost
|
|
everything — one server, every client, no configuration.
|
|
|
|
Some servers cannot. A server can depend on a feature that exists on only one
|
|
protocol era, and the two eras are not a ladder: the modern era added the
|
|
multi-round-trip [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol)
|
|
and *removed* the server-initiated back-channel (`ctx.elicit`, `ctx.sample`,
|
|
`ctx.list_roots`) that the handshake eras provide. Neither era is a superset of
|
|
the other, so a server can legitimately need either one.
|
|
|
|
When a server's whole purpose depends on an era, a client that cannot speak it
|
|
should be told at connect time. Today it finds out mid-call: a handshake-era
|
|
client happily connects to a guard-tool server, lists its tools, calls one, and
|
|
only then gets an era error — a failure surfacing far from its cause. Declaring
|
|
the versions the server serves moves that failure to the connection.
|
|
|
|
### Declaring versions
|
|
|
|
Pass the protocol versions the server is willing to serve. The MCP SDK exposes
|
|
each era as a tuple, and those tuples are the durable way to name an era — they
|
|
grow on their own when the SDK adds a revision, so a server pinned to
|
|
`MODERN_PROTOCOL_VERSIONS` keeps working across SDK upgrades:
|
|
|
|
```python
|
|
from fastmcp import FastMCP
|
|
from fastmcp.types import InputRequiredResult
|
|
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
|
|
|
mcp = FastMCP("guarded", protocol_versions=MODERN_PROTOCOL_VERSIONS)
|
|
|
|
|
|
@mcp.tool
|
|
def confirm(action: str) -> str | InputRequiredResult:
|
|
... # a guard tool: multi-round trips exist only on the modern era
|
|
```
|
|
|
|
A server built around the back-channel declares the other era:
|
|
|
|
```python
|
|
from fastmcp import Context, FastMCP
|
|
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
|
|
|
|
mcp = FastMCP("interviewer", protocol_versions=HANDSHAKE_PROTOCOL_VERSIONS)
|
|
|
|
|
|
@mcp.tool
|
|
async def interview(ctx: Context) -> str:
|
|
answer = await ctx.elicit("What is your name?", response_type=str)
|
|
return f"Hello, {answer.data}"
|
|
```
|
|
|
|
You can also pin exact versions, which is what a server certified against a
|
|
single modern revision wants:
|
|
|
|
```python
|
|
mcp = FastMCP("pinned", protocol_versions=["2026-07-28"])
|
|
```
|
|
|
|
Exact-revision pinning is enforceable for **modern** versions, which a client
|
|
pins in every per-request envelope. It is not enforceable *within* the handshake
|
|
era: the SDK negotiates the handshake revision and FastMCP can only veto a
|
|
connection, not steer it, so pinning `["2025-06-18"]` still admits a client that
|
|
negotiates `2025-11-25` — the pin asserts the handshake era, and the connection
|
|
settles on whatever the SDK negotiated. See [Membership, not a
|
|
minimum](#membership-not-a-minimum) below.
|
|
|
|
Any string the SDK does not recognize raises `ValueError` at construction. A
|
|
version the SDK could never negotiate is a bug in the server, not a runtime
|
|
condition worth warning about.
|
|
|
|
### Membership, not a minimum
|
|
|
|
The declaration is a **set**, not a floor. Versions are an enumerated set rather
|
|
than an ordered scale — the SDK says so directly, and future revision
|
|
identifiers are not guaranteed to be date-shaped or sortable. This is what makes
|
|
"handshake only" expressible at all: under a minimum-version model there is no
|
|
way to say "the session era", because the modern era is numerically newer while
|
|
lacking the features that era depends on.
|
|
|
|
Membership is enforced **era-aware**, because FastMCP can only veto a connection
|
|
— never steer the version the peer settles on — and the two eras negotiate
|
|
differently. A modern version is pinned exactly in each per-request envelope, so
|
|
a modern-version declaration enforces exact membership: a request at a modern
|
|
version outside the set is refused. A handshake connection is negotiated by the
|
|
SDK's initialize handler, which honors the client's requested revision (or
|
|
counters with the newest handshake revision) with no knowledge of your
|
|
declaration — FastMCP cannot make it counter-offer a specific revision. So a
|
|
handshake-version declaration enforces the handshake *era*: a server that
|
|
declares any handshake version accepts the handshake and runs at whatever
|
|
revision the SDK negotiated, and only a server that declares no handshake version
|
|
(a modern-only server) refuses it. What a handshake-version declaration
|
|
enforces is therefore the era boundary, not a specific handshake revision.
|
|
|
|
### What clients see
|
|
|
|
A refused connection gets the spec-standard `-32022` unsupported-protocol-version
|
|
error carrying the server's supported list, so a negotiating client treats it as
|
|
guidance rather than a dead end:
|
|
|
|
| Server declares | `mode="auto"` client | `mode="legacy"` client | Client pinned to `2026-07-28` |
|
|
| --- | --- | --- | --- |
|
|
| nothing (default) | modern | handshake | modern |
|
|
| `MODERN_PROTOCOL_VERSIONS` | modern | refused | modern |
|
|
| `HANDSHAKE_PROTOCOL_VERSIONS` | falls back to handshake | handshake | refused |
|
|
|
|
An `auto` client refused at `server/discover` by a handshake-only server reads
|
|
the handshake versions out of the error and completes the connection over
|
|
`initialize` on its own — the restriction steers negotiation instead of breaking
|
|
it. Only a client with no mutual version is refused outright, which is the point.
|
|
|
|
Enforcement covers both connection paths FastMCP owns. The initialize handshake
|
|
is refused before it commits. Modern connections are checked on the request
|
|
itself, because a client pinned to a modern version never probes
|
|
`server/discover` — refusing discovery alone would let it straight through.
|
|
|
|
<Note>
|
|
`fastmcp.Client` connects in-memory over the handshake by default, so testing a
|
|
modern-only server in-process needs `Client(mcp, mode="auto")`. Over HTTP and
|
|
stdio the default `auto` negotiation applies and no change is needed.
|
|
</Note>
|
|
|
|
### Startup coherence check
|
|
|
|
FastMCP knows what is registered, so at startup it warns — never fails — when a
|
|
declared version set cannot carry a capability the server actually uses. A
|
|
handshake-only server registering a guard tool gets a warning naming the tool; a
|
|
modern-only server configuring a `sampling_handler` with
|
|
`sampling_handler_behavior="fallback"` gets one too, because the modern era has
|
|
no back-channel for the fallback to reach.
|
|
|
|
The check is **silent unless you declared something**. A server with ten
|
|
ordinary tools and one guard tool is fine as-is: the guard tool raises a clear
|
|
era error if an old client reaches for it, and warning about that at startup
|
|
would only teach people to ignore warnings. The check fires when you asserted
|
|
something and the registration contradicts the assertion.
|
|
|
|
Detection is deliberately conservative. `ctx.elicit`, `ctx.sample`, and
|
|
`ctx.list_roots` are runtime calls with no reliable static signal, so only
|
|
configuration-level contradictions are caught — a missed case is a silent
|
|
startup, never a false alarm.
|
|
|
|
## 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.
|