Skip to main content
FastMCP 1.0 adds the 2026-07-28 modern protocol era alongside the 2025 protocol your 0.x code already speaks. The upgrade is designed so it does not rewrite your server or client. A 1.0 server serves both eras with no configuration, and a 1.0 client negotiates automatically: one server/discover probe at connect, the modern era when the server offers it, and the legacy 2025 handshake otherwise. Against a 0.x-only server that fallback keeps the wire behavior you have today, and versionNegotiation: { mode: 'legacy' } opts a client out of the probe entirely. Most 0.x code compiles and runs against 1.0 unchanged. Protocol eras installs that model; this page is the mechanical upgrade path. The breaks fall into two groups. A small set applies the moment you upgrade, whatever era you speak. A larger set takes effect only once you opt a client into the modern era. Clear the first group to get onto 1.0, then adopt the modern era deliberately. The package name and entrypoints do not change. You still npm install @prefecthq/fastmcp-ts, and you still import from @prefecthq/fastmcp-ts/server and @prefecthq/fastmcp-ts/client. What changed underneath is the SDK: 1.0 drops @modelcontextprotocol/sdk 1.x for the version-2 scoped packages, @modelcontextprotocol/server and @modelcontextprotocol/client. FastMCP wraps them, so the repackaging only reaches you if your own code imported the SDK directly — most often its error classes, which come first.

Error handling

Two things moved with the SDK repackaging: the error class names, and one error code. The McpError class and its ErrorCode enum are renamed. 0.x threw and caught McpError with codes from ErrorCode, both imported from @modelcontextprotocol/sdk. Version 2 renames them to ProtocolError and ProtocolErrorCode, exported from @modelcontextprotocol/server on the server side and @modelcontextprotocol/client on the client side. FastMCP does not re-export them, so a handler that constructs a protocol error, or client code that catches one by class, needs the new import and the new name.
0.x
1.0
One error code changed value, and three are new. Resource-not-found moved from -32002 to -32602 (invalid params): a resources/read for an unknown resource now answers -32602. To raise it from your own handler, throw ResourceNotFoundError from @modelcontextprotocol/server — it extends ProtocolError and carries -32602. If you match the numeric code to detect a missing resource, switch to ProtocolErrorCode.InvalidParams. That code is no longer unique to resource-not-found, so match on it as “the resource was not found or the params were otherwise invalid”, not as a precise not-found signal. The ResourceNotFound = -32002 member stays importable only so a client can still recognize -32002 sent by an older peer.
1.0
The modern era also introduces three codes you may meet for the first time. -32020 marks a Streamable HTTP request whose protocol-version header disagrees with its body; it surfaces as a plain ProtocolError with that code. -32021 (MissingRequiredClientCapability) means the server needs a client capability the connection did not declare. -32022 (UnsupportedProtocolVersion) means the client pinned a version the server cannot speak. The fastmcp CLI already maps all three to plain-English messages; in library code, the SDK raises dedicated MissingRequiredClientCapabilityError and UnsupportedProtocolVersionError classes for the last two.

SSE transport

0.x connected to a Streamable HTTP URL and an HTTP+SSE URL the same way, falling back to SSE on a 4xx response. 1.0 makes SSE an explicit, deprecated opt-in. A plain URL connects over Streamable HTTP only. A URL that names an SSE endpoint — a path segment sse, or a path ending in /sse — throws, and the error points you at Streamable HTTP and the opt-in flag. If you must keep talking to an SSE-only server, set legacySSE: true. The client warns once and connects.
0.x
1.0
Prefer moving the server to Streamable HTTP. The 2026-07-28 spec deprecates the HTTP+SSE transport, so legacySSE is a bridge, not a destination. This break applies on both eras, because the transport is a connection choice rather than an era choice. Transports covers the full picture.

Server binding

1.0 adds DNS-rebinding protection, and it changes the default posture for an HTTP server. When you bind to a loopback address, the guard turns on automatically and validates the Host and Origin headers against the loopback set. When you bind to a routable address with no dnsRebinding configuration, the guard stays off and the server warns once, because it cannot know which hosts and origins are legitimate for your deployment. Configure dnsRebinding for a routable bind to enable the guard and silence the warning. List the hostnames the server answers to and the browser origins allowed to reach it.
server.ts
The bind host and this guard are coupled, so 1.0 also changes the default bind host — see Default bind host below. MCP_HOST sets the address an HTTP server binds to. Choose it on purpose, because the host you bind selects the guard’s default posture. Running a server covers binding and the guard in full.

Default bind host

0.x bound an HTTP server to 0.0.0.0 by default, which exposes it on every network interface. 1.0 binds 127.0.0.1 by default, which keeps it on the local machine. This matches the parent fastmcp project, and it turns the DNS-rebinding guard on out of the box, because a loopback bind enables the guard automatically. A deployment that relied on the default-exposed bind must now set the host explicitly. Set MCP_HOST=0.0.0.0, or pass host: '0.0.0.0' to run(), and configure dnsRebinding to protect the exposed bind. The loopback default no longer answers requests from other machines.
0.x
1.0
The bind host and the DNS-rebinding guard are coupled, so choose the host on purpose. The Server binding break above covers the guard. Running a server covers binding and the guard in full.

Caching keys

If you register CachingMiddleware with its default cache key, 1.0 changes what counts as a cache hit. The default key gained an auth partition: it is now the request method, plus an identity partition, plus the serialized params, where 0.x keyed on the method and params alone. The change reaches you only on an authenticated server that serves more than one identity through the default key — there, a result computed for one caller is no longer served to another. Cache entries that 0.x could share across identities no longer merge, so the cache fails safe: correctness improves, while hit rate and memory use may shift. When you deliberately shared a response across callers, restore that with a custom keyFn that omits identity — and hash any token material the key includes, never store a raw token. Caching covers the default key and custom keys in full.

Modern era

Everything below changes behavior only when a client negotiates the modern era, or when the server itself runs legacy HTTP in stateless mode — that server-side switch trades away the same session-dependent behavior on legacy traffic too. A client that lands on the legacy era — because the server offers no modern era under the default auto negotiation, or because you passed { mode: 'legacy' } — sees none of this against an ordinary sessionful server, so you can adopt the modern era one connection at a time.

Negotiation defaults

The library Client defaults to { mode: 'auto' } on every transport. Client.connect(url) probes the server once with server/discover, uses the modern era when the server offers it, and falls back to the legacy 2025 handshake otherwise — so against a 0.x-only server the connection behaves as it did before, after one probe the server answers with method-not-found. The probe is stall-safe: over stdio it runs against a sibling process and a timeout falls back to legacy; over HTTP a timeout rejects with a typed error. To skip the probe and keep the exact 2025 connect sequence, pass { mode: 'legacy' }. { mode: { pin: '2026-07-28' } } requires the modern era and throws -32022 if the server cannot speak it. After connecting, getProtocolEra() reports the negotiated era.
0.x
1.0
The fastmcp CLI follows the same default on every transport. --legacy opts a connection out of the probe, --pin <version> forces an exact era on any transport, and --modern is now a deprecated no-op. The CLI reference has the flags.

Session state

ctx.getState, ctx.setState, and ctx.deleteState keep working on stdio and on legacy HTTP, where a session persists them across requests, as long as the server is sessionful, which is the default. A modern HTTP request has no session, and neither does a legacy HTTP server running in stateless mode, so each such call runs statelessly against a fresh store. Rather than drop a write silently, the accessors throw a pointed error, and the message names the request-scoped replacements. A handler that relied on session state and must serve modern HTTP clients moves the value off the session. For anything durable or portable, mint a server-side handle and thread it back as a tool argument — the pattern works identically on every transport.
0.x
1.0
For state that only needs to survive the rounds of one input-required flow, use ctx.mintRequestState and ctx.requestState instead. State and handles covers both, including a time-to-live cleanup policy that does not depend on a session closing.

Server-initiated requests

0.x handlers called the client back mid-execution with ctx.sample, ctx.elicit, and ctx.listRoots. These are deprecated and era-gated in 1.0. They still work on a sessionful legacy connection, which is the default. A modern request has no server-to-client channel, and neither does a legacy HTTP server running in stateless mode, so both throw an error that names inputRequired(...) as the replacement. Rewrite an interactive handler to ask by returning. Return inputRequired(...) with the requests the client must fulfil; the client fulfils them and retries the same call, this time carrying the answers on ctx.inputResponses. A handler written this way serves both eras from one code path, because a built-in legacy shim turns the return value into a real server-to-client request for 2025-era clients.
0.x
1.0
On a stateless server this rewrite trades one limit for a narrower one. The shim that turns inputRequired({ inputRequests }) into a server-to-client push needs the same live session ctx.elicit() needed, so a stateless server fails it the same way. Drop inputRequests and return only requestState, though, and the handler keeps working — not because the client retries anything, but because the SDK’s legacy shim re-enters the handler itself, server-side, inside the same HTTP request, pausing 250ms between rounds instead of pushing a request over a session it doesn’t have. That narrower form is the one multi-round-trip pattern a stateless server supports, and it has real limits worth knowing before you lean on it: the shim gives up after inputRequired.maxRounds rounds (8 by default), each round holds the request open at least 250ms longer, and a handler that needs to wait on something external between rounds — a person, a webhook — has no way to signal that and will simply spin to a rounds-exceeded failure instead. Input required covers the mechanism and a worked example. Input required covers the return-value pattern, reading responses, and carrying signed state across rounds.

New capabilities

1.0 is not only breaks. Several additions are worth adopting as you migrate, each taught on the page it belongs to.
  • Return-value interactivityinputRequired(...) with the ctx.inputResponses readers is the one interactive pattern that serves both eras from a single handler (input required).
  • Request state and handlesctx.mintRequestState and ctx.requestState carry signed state across a flow, and the server-minted-handle pattern holds anything that must outlive a session (state and handles).
  • Resource change signalsserver.notifyResourceUpdated(uri) pushes a resource update, backed by server-side resources/subscribe and resources/unsubscribe on the legacy era and a subscriptions/listen stream on the modern era (resources).
  • Argument completion — a complete callback supplies suggestions for prompt arguments and resource-template variables (prompts, resources).
  • Modern client hardeningversionNegotiation and getProtocolEra() control the era, and OAuth gains client-ID metadata documents (clientMetadataUrl, CIMD) and RFC 9207 iss validation (client auth).
  • Cache hintsFastMCPOptions.cacheHints attaches per-operation ttlMs and cacheScope to cacheable results (SEP-2549), so a modern client can reuse them; FastMCP fills conservative defaults for the operations you omit (caching).
  • DNS-rebinding protection — the dnsRebinding option, covered above (running a server).
  • Stateless legacy HTTPRunOptions.stateless or FASTMCP_STATELESS_HTTP serves each legacy HTTP request from a fresh server, with no session, for a load-balanced or serverless deployment where consecutive requests can reach different instances. Off by default, and it trades away session state and server-initiated requests on the requests it serves (running a server).

Conformance

1.0 verifies against the official MCP conformance suite, and the repository tracks results with two expected-failure baselines. conformance-baseline.yml gates every pull request against the pinned suite release; its server list is empty, so any server-scenario failure is a regression. conformance-baseline-main.yml runs nightly against the suite’s moving main branch, which carries draft 2026-07-28 scenarios ahead of any tagged release, so its entry set is expected to churn. A listed scenario that starts passing fails the build as a stale entry — that loud failure is what keeps the baselines honest as the suite moves. 1.0 implements OAuth step-up. A 401 raised after connect() triggers a bounded re-authorization when an interactive OAuth flow is configured, so the client completes the second, step-up request. This covers the scenario that leaves initialize unauthenticated and first challenges on tools/list (client auth). One client OAuth gap is recorded rather than fixed on the pinned baseline. auth/basic-cimd is a harness-contract limit: the scenario server advertises CIMD support but passes the client no metadata URL, so a spec-correct client falls back to Dynamic Client Registration, and CIMD is a SHOULD. Two DPoP scenarios stay on the main-tracking baseline instead, because the SDK ships no DPoP support. The MultiServerClient has no interactive-OAuth flow at all — bearer and client-credentials work per server, but a redirect flow does not. Plan for these if your client depends on DPoP or on interactive OAuth across multiple servers.