{"slug": "fastmcp-3-4-migration-the-breaking-changes-that-compile", "title": "FastMCP 3 4 migration: the breaking changes that compile", "summary": "A developer migrating an MCP server and two clients from FastMCP 3 to FastMCP 4 documented several breaking changes that compile but fail at runtime. Key issues include a pip in-place upgrade leaving an importable shell with no code due to the new extras split, silent exception handling failures from the httpx to httpx2 switch, and new default behaviors in Client mode that alter runtime semantics.", "body_md": "*FastMCP 4 is GA. If you have an MCP server or client on `fastmcp` 3.x, you'll\nupgrade soon. Most of it is painless — `FastMCP(...)`, `@mcp.tool`, and\n`mcp.run(transport=...)` are all unchanged. The parts that aren't painless are the\nparts that don't announce themselves.*\n\n*These are field notes on top of the official\n[Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3)\nguide — the items that bit hardest when I moved one MCP server and two clients,\nin the order they bit.*\n\n`pip install -U fastmcp` can leave you half-broken\nFastMCP 4 is split into extras. The `fastmcp` package is now a thin meta-package\n\nthat depends on `fastmcp-slim[client,server]`; `fastmcp-slim` carries the actual\n\ncode, and its extras are `client`, `server`, `mcp`, `anthropic`, `apps`, `azure`,\n\n`code-mode`, `gemini`, `openai`.\n\nOn a **fresh** install this is invisible — `pip install fastmcp` pulls\n\n`fastmcp-slim[client,server]` and everything works.\n\nI upgraded **in place** with `pip install -U fastmcp` over `fastmcp 3.2.x`, and pip\n\ndid not re-resolve those base extras. Result: an importable shell with nothing in\n\nit.\n\n``` python\n>>> import fastmcp\n>>> fastmcp.__file__ is None\nTrue\n>>> dir(fastmcp)\n[]\n>>> from fastmcp import Client\nImportError: cannot import name 'Client' from 'fastmcp' (unknown location)\n```\n\nThis looks exactly like a broken release. It isn't — it's the 4.x extras split not\n\ngetting re-resolved on an in-place upgrade. (FastMCP separately documents a\n\ndifferent `pip` file-manifest issue on the 3.2 → 3.3 hop and notes `uv` is\n\nunaffected by *that* one; this is a distinct problem, and I hit it with `pip -U` —\n\nI didn't test `uv pip install -U`.)\n\nThe fix, either way:\n\n```\npython -m pip uninstall -y fastmcp fastmcp-slim\npython -m pip install fastmcp   # or fastmcp==4.0.x to pin the version you tested\n```\n\nOr just recreate the venv. It cost me a false-alarm debugging session — twice,\n\nbecause the symptom (`ModuleNotFoundError` on a submodule that's genuinely in the\n\nwheel) is so convincing.\n\nAlso: `fastmcp` in 4.x **no longer exposes `__version__`**. If you assert on it\n\nanywhere, switch to `importlib.metadata.version(\"fastmcp\")`.\n\n`httpx` → `httpx2`: your `except` clauses go quiet\nFastMCP 4 dropped `httpx` for `httpx2` (a next-gen fork) internally. So a FastMCP\n\nclient call that used to raise `httpx.ConnectError` now raises\n\n`httpx2.ConnectError`.\n\nThe trap: `httpx` is still transitively installed in most environments, so this\n\nkeeps importing and type-checking:\n\n```\ntry:\n    async with Client(StreamableHttpTransport(url)) as c:\n        result = await c.call_tool(\"do_thing\", args)\nexcept httpx.ConnectError:   # never matches on FastMCP 4\n    ...\n```\n\nIt just silently stops catching. Grep for `except httpx.` and check whether each\n\none wraps a FastMCP `Client` / transport call — if it does, migrate it to\n\n`httpx2` (or catch FastMCP's own `fastmcp.exceptions.ToolError`, which is usually\n\nwhat you actually want). Your own direct `httpx` calls are unaffected as long as\n\nyou keep `httpx` as a dependency.\n\n**Same silent class, elsewhere:** anything you hand *into* FastMCP that's built on\n\n`httpx` — a custom `httpx_client_factory`, an `httpx.AsyncClient` passed to a\n\ntransport, an `httpx.Auth` — now needs to be `httpx2`. The official guide lists\n\nthis right next to the `except` trap.\n\nOne more downstream effect: TLS verification now uses the OS trust store via\n\n`truststore` (honouring `SSL_CERT_FILE` / `SSL_CERT_DIR`) instead of bundled\n\n`certifi` — corporate-CA setups may verify differently. HTTP log records also move\n\nfrom `httpx` / `httpcore.*` to `httpx2` / `httpcore2.*` — update logging filters.\n\n`Client` now defaults to `mode=\"auto\"`\nIn 4.x, `Client(...)` defaults to `mode=\"auto\"` and negotiates the modern\n\n`2026-07-28` protocol era. That era is sessionless, and it changes runtime\n\nbehaviour even though your code compiles fine:\n\n`on_initialize` handshake`ctx.set_state()` doesn't persist`ctx.elicit()` raises\nIf your client only does plain reads and writes (`call_tool`, `read_resource`),\n\nyou're fine — that's the common case and it needs no change. If it relies on\n\nsession state, an init hook, or elicitation, pin it back:\n\n```\nClient(server, mode=\"legacy\")\n```\n\n`StreamableHttpTransport` also dropped `sse_read_timeout=` — pass `timeout=` on the\n\n`Client` instead.\n\n`ctx` methods\nThese are gone and raise `AttributeError`:\n\n`ctx.sample()`` ctx.sample_step()``ctx.list_roots()`\nIf your server's job was to borrow the caller's model via `ctx.sample()` (or\n\n`FastMCP(sampling_handler=...)`, also removed), you either call an LLM directly\n\nfrom the server now or stay on 3.x. `ctx.elicit()` still exists but requires a\n\n`response_type` argument and raises on modern connections — rewrite it as a guard\n\ntool that returns an \"input required\" result, or branch on\n\n`ctx.request_context.protocol_version`.\n\n**Background tasks moved to an extension.** `@mcp.tool(task=True)` no longer runs\n\nanything by itself — install `fastmcp[tasks]` and register\n\n`mcp.add_extension(TasksExtension())`, or startup raises. Drop `task=` from\n\n`@mcp.resource` / `@mcp.prompt` (tools only).\n\n```\n# hard requirement — resolution fails without it\npydantic = \">=2.12\"\n\n# only if you use the server's FastAPI extra\nstarlette = \">=1.0.1\"    # → FastAPI >= 0.133.0 (first version admitting Starlette 1.x)\n```\n\nPin style unchanged: an **app** pins the exact version it tested\n\n(`fastmcp==4.0.x`); a **library** floors at `fastmcp>=4.0.0` in its own\n\ndependencies and tests against the current release.\n\n| 3.x | 4.x | \n|---|---|\n| `from fastmcp.tools.tool import Tool, ToolResult` | `from fastmcp.tools import Tool, ToolResult` | \n| `from fastmcp.resources.resource import Resource` | `from fastmcp.resources import Resource` | \n| `TextContent` ,`Tool` protocol types from`fastmcp.types` | `from mcp.types import ...` (`fastmcp.types` now holds only FastMCP-defined types) | \n| `mcp.as_proxy(sub)` | `create_proxy(sub)` from`fastmcp.server` | \n| `mcp.import_server(sub)` | `mcp.mount(sub)` (live composition, not a snapshot) | \n| `mcp.add_tool_transformation(name, cfg)` | `mcp.add_transform(ToolTransform({name: cfg}))` | \n| `CachableToolResult` (old typo) | `CacheableToolResult` — no compat alias | \n| `McpError(ErrorData(code=..., message=...))` | `McpError(code=..., message=...)` | \n\nSDK v2 also renamed model fields camelCase → snake_case (`inputSchema` →\n\n`input_schema`, `isError` → `is_error`). Old reads are auto-bridged and emit a\n\n`FastMCPDeprecationWarning`. The bridge is\n\n`fastmcp.settings.mcp_camelcase_compat` (env `FASTMCP_MCP_CAMELCASE_COMPAT`),\n\n`bool`, default `true`. Set it `false` once — that turns every remaining camelCase\n\nread into a hard error, so you can find and clear them before the bridge is\n\nremoved.\n\n[`gofastmcp.com/more/settings`](https://gofastmcp.com/more/settings) lists every\n\nsetting — each has a `fastmcp.settings.<name>` attribute and a `FASTMCP_<NAME>`\n\nenvironment variable. Three defaults changed behaviour in 4.x and don't get a\n\nline in the upgrade guide:\n\n`telemetry_mode``\"native\"` — FastMCP 4 auto-instruments\nOpenTelemetry spans for MCP calls. If you don't want that,\n`FASTMCP_TELEMETRY_MODE=off` (or `propagation_only`).` check_for_updates``\"stable\"` — the CLI checks PyPI for a newer\nFastMCP on startup. Set `FASTMCP_CHECK_FOR_UPDATES=off` in CI and containers.`client_raise_first_exceptiongroup_error`` true` — a client\nerror surfaces as the first underlying exception, not the `ExceptionGroup`.\nThat's why `except ToolError:` still works; if you were catching with `except*`,\nrevisit.\nAlso worth a look while you're there: `stateless_http` (new-transport-per-request,\n\nthe sessionless/Cloud-Run knob), `http_host_origin_protection` (new, opt-in Host/\n\nOrigin validation for Streamable HTTP), and `mask_error_details` (default `false`\n\n— error text is passed through unless you raise an explicit `ToolError` /\n\n`ResourceError` / `PromptError`).\n\nKeep `FASTMCP_DEPRECATION_WARNINGS=true` (the default) for the whole migration —\n\nit's how you find the rest of this list in your own code.\n\nIf your server is only `@mcp.tool`-decorated functions plus\n\n`mcp.run(transport=\"stdio\")` or `mcp.run(transport=\"streamable-http\")`, there is\n\n**no code change**. The constructor, the decorator, and the transport call are all\n\nthe same. You:\n\n`pydantic` (and FastAPI, if you use it),`except httpx.` and migrate the ones around FastMCP calls,\nThat's it.\n\n| Shape | Change | \n|---|---|\n| An MCP server — `@mcp.tool` +`mcp.run(\"stdio\" / \"streamable-http\")` | dependency floor only — **zero code** | \n| Two MCP clients — `Client` +`StreamableHttpTransport` +`except ToolError` | dependency floor only — verified `mode=\"auto\"` is fine for plain reads / writes | \n\nNo API changes in either. The real cost was the `pip install -U` false alarm\n\n(twice) and one test that hard-coded a version string in an assertion.\n\n```\n[ ] Recreate the venv (or `pip3 uninstall fastmcp fastmcp-slim` first) — don't `-U` over 3.x\n[ ] pydantic >= 2.12   (+ FastAPI >= 0.133.0 if you use the server's FastAPI extra)\n[ ] grep `except httpx.` — migrate the ones wrapping FastMCP Client/transport calls to httpx2\n[ ] grep `httpx_client_factory` / `httpx.AsyncClient` / `httpx.Auth` handed to FastMCP — same, → httpx2\n[ ] grep `ctx.sample` / `ctx.sample_step` / `ctx.list_roots` — removed (and `FastMCP(sampling_handler=)`)\n[ ] grep `ctx.elicit` — needs response_type + fails on modern connections\n[ ] grep `@mcp.tool(task=True)` — now needs fastmcp[tasks] + TasksExtension()\n[ ] grep `Client(` — needs mode=\"legacy\" only if it relies on session state / on_initialize / elicit\n[ ] grep `sse_read_timeout` — moved to Client(timeout=...)\n[ ] grep imports: fastmcp.tools.tool, fastmcp.resources.resource, fastmcp.types, mcp.as_proxy, import_server\n[ ] grep `fastmcp.__version__` — gone; use importlib.metadata.version(\"fastmcp\")\n[ ] set `fastmcp.settings.mcp_camelcase_compat = False` once — clear the camelCase deprecation warnings\n[ ] CI: `FASTMCP_CHECK_FOR_UPDATES=off`; decide on `FASTMCP_TELEMETRY_MODE` (default is `native` = OTel on)\n[ ] keep `FASTMCP_DEPRECATION_WARNINGS=true` (default) for the whole migration\n[ ] run the test suite\n```\n\nIf you're just `@mcp.tool` + `mcp.run`, the whole list is \"bump two floors and\n\ncheck your `httpx` catches.\" Everything else is for the code that does more.\n\n`fastmcp.settings.*` / `FASTMCP_*` knob, including the §7 defaults.`stdio`, Streamable HTTP), and where the `2026-07-28` sessionless shift sits.", "url": "https://wpnews.pro/news/fastmcp-3-4-migration-the-breaking-changes-that-compile", "canonical_source": "https://dev.to/wolfejam/fastmcp-3-4-migration-the-breaking-changes-that-compile-k6p", "published_at": "2026-09-07 20:31:26+00:00", "updated_at": "2026-09-07 21:01:42.837663+00:00", "lang": "en", "topics": ["developer-tools", "mlops"], "entities": ["FastMCP", "pip", "httpx", "httpx2"], "alternates": {"html": "https://wpnews.pro/news/fastmcp-3-4-migration-the-breaking-changes-that-compile", "markdown": "https://wpnews.pro/news/fastmcp-3-4-migration-the-breaking-changes-that-compile.md", "text": "https://wpnews.pro/news/fastmcp-3-4-migration-the-breaking-changes-that-compile.txt", "jsonld": "https://wpnews.pro/news/fastmcp-3-4-migration-the-breaking-changes-that-compile.jsonld"}}