fastmcp/docs/development/v4-notes/protocol-2026.mdx
Jeremiah Lowin 80f753c1cb
Enforce protocol-version pins by era, not exact handshake revision
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.
2026-07-20 18:06:32 -04:00

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.