# FastMCP 3 4 migration: the breaking changes that compile

> Source: <https://dev.to/wolfejam/fastmcp-3-4-migration-the-breaking-changes-that-compile-k6p>
> Published: 2026-09-07 20:31:26+00:00

*FastMCP 4 is GA. If you have an MCP server or client on `fastmcp` 3.x, you'll
upgrade soon. Most of it is painless — `FastMCP(...)`, `@mcp.tool`, and
`mcp.run(transport=...)` are all unchanged. The parts that aren't painless are the
parts that don't announce themselves.*

*These are field notes on top of the official
[Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3)
guide — the items that bit hardest when I moved one MCP server and two clients,
in the order they bit.*

`pip install -U fastmcp` can leave you half-broken
FastMCP 4 is split into extras. The `fastmcp` package is now a thin meta-package

that depends on `fastmcp-slim[client,server]`; `fastmcp-slim` carries the actual

code, and its extras are `client`, `server`, `mcp`, `anthropic`, `apps`, `azure`,

`code-mode`, `gemini`, `openai`.

On a **fresh** install this is invisible — `pip install fastmcp` pulls

`fastmcp-slim[client,server]` and everything works.

I upgraded **in place** with `pip install -U fastmcp` over `fastmcp 3.2.x`, and pip

did not re-resolve those base extras. Result: an importable shell with nothing in

it.

``` python
>>> import fastmcp
>>> fastmcp.__file__ is None
True
>>> dir(fastmcp)
[]
>>> from fastmcp import Client
ImportError: cannot import name 'Client' from 'fastmcp' (unknown location)
```

This looks exactly like a broken release. It isn't — it's the 4.x extras split not

getting re-resolved on an in-place upgrade. (FastMCP separately documents a

different `pip` file-manifest issue on the 3.2 → 3.3 hop and notes `uv` is

unaffected by *that* one; this is a distinct problem, and I hit it with `pip -U` —

I didn't test `uv pip install -U`.)

The fix, either way:

```
python -m pip uninstall -y fastmcp fastmcp-slim
python -m pip install fastmcp   # or fastmcp==4.0.x to pin the version you tested
```

Or just recreate the venv. It cost me a false-alarm debugging session — twice,

because the symptom (`ModuleNotFoundError` on a submodule that's genuinely in the

wheel) is so convincing.

Also: `fastmcp` in 4.x **no longer exposes `__version__`**. If you assert on it

anywhere, switch to `importlib.metadata.version("fastmcp")`.

`httpx` → `httpx2`: your `except` clauses go quiet
FastMCP 4 dropped `httpx` for `httpx2` (a next-gen fork) internally. So a FastMCP

client call that used to raise `httpx.ConnectError` now raises

`httpx2.ConnectError`.

The trap: `httpx` is still transitively installed in most environments, so this

keeps importing and type-checking:

```
try:
    async with Client(StreamableHttpTransport(url)) as c:
        result = await c.call_tool("do_thing", args)
except httpx.ConnectError:   # never matches on FastMCP 4
    ...
```

It just silently stops catching. Grep for `except httpx.` and check whether each

one wraps a FastMCP `Client` / transport call — if it does, migrate it to

`httpx2` (or catch FastMCP's own `fastmcp.exceptions.ToolError`, which is usually

what you actually want). Your own direct `httpx` calls are unaffected as long as

you keep `httpx` as a dependency.

**Same silent class, elsewhere:** anything you hand *into* FastMCP that's built on

`httpx` — a custom `httpx_client_factory`, an `httpx.AsyncClient` passed to a

transport, an `httpx.Auth` — now needs to be `httpx2`. The official guide lists

this right next to the `except` trap.

One more downstream effect: TLS verification now uses the OS trust store via

`truststore` (honouring `SSL_CERT_FILE` / `SSL_CERT_DIR`) instead of bundled

`certifi` — corporate-CA setups may verify differently. HTTP log records also move

from `httpx` / `httpcore.*` to `httpx2` / `httpcore2.*` — update logging filters.

`Client` now defaults to `mode="auto"`
In 4.x, `Client(...)` defaults to `mode="auto"` and negotiates the modern

`2026-07-28` protocol era. That era is sessionless, and it changes runtime

behaviour even though your code compiles fine:

`on_initialize` handshake`ctx.set_state()` doesn't persist`ctx.elicit()` raises
If your client only does plain reads and writes (`call_tool`, `read_resource`),

you're fine — that's the common case and it needs no change. If it relies on

session state, an init hook, or elicitation, pin it back:

```
Client(server, mode="legacy")
```

`StreamableHttpTransport` also dropped `sse_read_timeout=` — pass `timeout=` on the

`Client` instead.

`ctx` methods
These are gone and raise `AttributeError`:

`ctx.sample()`` ctx.sample_step()``ctx.list_roots()`
If your server's job was to borrow the caller's model via `ctx.sample()` (or

`FastMCP(sampling_handler=...)`, also removed), you either call an LLM directly

from the server now or stay on 3.x. `ctx.elicit()` still exists but requires a

`response_type` argument and raises on modern connections — rewrite it as a guard

tool that returns an "input required" result, or branch on

`ctx.request_context.protocol_version`.

**Background tasks moved to an extension.** `@mcp.tool(task=True)` no longer runs

anything by itself — install `fastmcp[tasks]` and register

`mcp.add_extension(TasksExtension())`, or startup raises. Drop `task=` from

`@mcp.resource` / `@mcp.prompt` (tools only).

```
# hard requirement — resolution fails without it
pydantic = ">=2.12"

# only if you use the server's FastAPI extra
starlette = ">=1.0.1"    # → FastAPI >= 0.133.0 (first version admitting Starlette 1.x)
```

Pin style unchanged: an **app** pins the exact version it tested

(`fastmcp==4.0.x`); a **library** floors at `fastmcp>=4.0.0` in its own

dependencies and tests against the current release.

| 3.x | 4.x | 
|---|---|
| `from fastmcp.tools.tool import Tool, ToolResult` | `from fastmcp.tools import Tool, ToolResult` | 
| `from fastmcp.resources.resource import Resource` | `from fastmcp.resources import Resource` | 
| `TextContent` ,`Tool` protocol types from`fastmcp.types` | `from mcp.types import ...` (`fastmcp.types` now holds only FastMCP-defined types) | 
| `mcp.as_proxy(sub)` | `create_proxy(sub)` from`fastmcp.server` | 
| `mcp.import_server(sub)` | `mcp.mount(sub)` (live composition, not a snapshot) | 
| `mcp.add_tool_transformation(name, cfg)` | `mcp.add_transform(ToolTransform({name: cfg}))` | 
| `CachableToolResult` (old typo) | `CacheableToolResult` — no compat alias | 
| `McpError(ErrorData(code=..., message=...))` | `McpError(code=..., message=...)` | 

SDK v2 also renamed model fields camelCase → snake_case (`inputSchema` →

`input_schema`, `isError` → `is_error`). Old reads are auto-bridged and emit a

`FastMCPDeprecationWarning`. The bridge is

`fastmcp.settings.mcp_camelcase_compat` (env `FASTMCP_MCP_CAMELCASE_COMPAT`),

`bool`, default `true`. Set it `false` once — that turns every remaining camelCase

read into a hard error, so you can find and clear them before the bridge is

removed.

[`gofastmcp.com/more/settings`](https://gofastmcp.com/more/settings) lists every

setting — each has a `fastmcp.settings.<name>` attribute and a `FASTMCP_<NAME>`

environment variable. Three defaults changed behaviour in 4.x and don't get a

line in the upgrade guide:

`telemetry_mode``"native"` — FastMCP 4 auto-instruments
OpenTelemetry spans for MCP calls. If you don't want that,
`FASTMCP_TELEMETRY_MODE=off` (or `propagation_only`).` check_for_updates``"stable"` — the CLI checks PyPI for a newer
FastMCP on startup. Set `FASTMCP_CHECK_FOR_UPDATES=off` in CI and containers.`client_raise_first_exceptiongroup_error`` true` — a client
error surfaces as the first underlying exception, not the `ExceptionGroup`.
That's why `except ToolError:` still works; if you were catching with `except*`,
revisit.
Also worth a look while you're there: `stateless_http` (new-transport-per-request,

the sessionless/Cloud-Run knob), `http_host_origin_protection` (new, opt-in Host/

Origin validation for Streamable HTTP), and `mask_error_details` (default `false`

— error text is passed through unless you raise an explicit `ToolError` /

`ResourceError` / `PromptError`).

Keep `FASTMCP_DEPRECATION_WARNINGS=true` (the default) for the whole migration —

it's how you find the rest of this list in your own code.

If your server is only `@mcp.tool`-decorated functions plus

`mcp.run(transport="stdio")` or `mcp.run(transport="streamable-http")`, there is

**no code change**. The constructor, the decorator, and the transport call are all

the same. You:

`pydantic` (and FastAPI, if you use it),`except httpx.` and migrate the ones around FastMCP calls,
That's it.

| Shape | Change | 
|---|---|
| An MCP server — `@mcp.tool` +`mcp.run("stdio" / "streamable-http")` | dependency floor only — **zero code** | 
| Two MCP clients — `Client` +`StreamableHttpTransport` +`except ToolError` | dependency floor only — verified `mode="auto"` is fine for plain reads / writes | 

No API changes in either. The real cost was the `pip install -U` false alarm

(twice) and one test that hard-coded a version string in an assertion.

```
[ ] Recreate the venv (or `pip3 uninstall fastmcp fastmcp-slim` first) — don't `-U` over 3.x
[ ] pydantic >= 2.12   (+ FastAPI >= 0.133.0 if you use the server's FastAPI extra)
[ ] grep `except httpx.` — migrate the ones wrapping FastMCP Client/transport calls to httpx2
[ ] grep `httpx_client_factory` / `httpx.AsyncClient` / `httpx.Auth` handed to FastMCP — same, → httpx2
[ ] grep `ctx.sample` / `ctx.sample_step` / `ctx.list_roots` — removed (and `FastMCP(sampling_handler=)`)
[ ] grep `ctx.elicit` — needs response_type + fails on modern connections
[ ] grep `@mcp.tool(task=True)` — now needs fastmcp[tasks] + TasksExtension()
[ ] grep `Client(` — needs mode="legacy" only if it relies on session state / on_initialize / elicit
[ ] grep `sse_read_timeout` — moved to Client(timeout=...)
[ ] grep imports: fastmcp.tools.tool, fastmcp.resources.resource, fastmcp.types, mcp.as_proxy, import_server
[ ] grep `fastmcp.__version__` — gone; use importlib.metadata.version("fastmcp")
[ ] set `fastmcp.settings.mcp_camelcase_compat = False` once — clear the camelCase deprecation warnings
[ ] CI: `FASTMCP_CHECK_FOR_UPDATES=off`; decide on `FASTMCP_TELEMETRY_MODE` (default is `native` = OTel on)
[ ] keep `FASTMCP_DEPRECATION_WARNINGS=true` (default) for the whole migration
[ ] run the test suite
```

If you're just `@mcp.tool` + `mcp.run`, the whole list is "bump two floors and

check your `httpx` catches." Everything else is for the code that does more.

`fastmcp.settings.*` / `FASTMCP_*` knob, including the §7 defaults.`stdio`, Streamable HTTP), and where the `2026-07-28` sessionless shift sits.
