fastmcp/docs/development/v4-notes/protocol-2026.mdx
Jeremiah Lowin f5fca97c81
Respell server protocol restriction as a version allowlist
Replace `min_protocol_version` with `protocol_versions`, an allowlist of MCP
protocol versions taking the SDK's own era tuples. Versions are an enumerated
set, not an ordered scalar, so enforcement is set membership rather than a
bound -- which is what makes handshake-only expressible.

Generalize the startup coherence check into a capability -> required-versions
map, and make it silent unless a version set was declared.
2026-07-20 12:20:56 -04:00

180 lines
12 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 revision wants:
```python
mcp = FastMCP("pinned", protocol_versions=["2026-07-28"])
```
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. A connection is
accepted when its negotiated version is a member of the declared set, which 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.
### 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.