Merge remote-tracking branch 'origin/main' into claude/mcp-background-tasks-v2-0f883f

This commit is contained in:
Jeremiah Lowin 2026-07-23 08:00:29 -04:00
commit 1c7ade215b
No known key found for this signature in database
9 changed files with 605 additions and 43 deletions

View file

@ -6,7 +6,7 @@ This is the complete register of user-facing changes from the MCP Python SDK v2
Each entry is tagged **Absorbed** (public surface unchanged), **Bridged** (shim keeps old code working, usually warning), **Breaking** (user code must change), or **Deprecated** (works, warns, slated for removal). See the [overview](/development/v4-notes/index) for what each disposition means.
**Empirical validation (WS2 upgrade reality-check).** The register's compatibility claims are verified, not predicted. Running unchanged 3.x-era code against this branch, all 11 upgrade scenarios pass or warn — the only failures are the two predicted breaks, user `mcp.types` imports and positional `McpError(ErrorData(...))` construction. Cross-version wire interop between a 3.4.3 peer and this branch is bidirectionally clean across 9 operations (3.4.3 client ↔ v4 server and v4 client ↔ 3.4.3 server over HTTP). All 25 `_ALIASES` bridge entries warn correctly with actionable messages.
**Empirical validation (WS2 upgrade reality-check).** The register's compatibility claims are verified, not predicted. Running unchanged 3.x-era code against this branch, all 11 upgrade scenarios pass or warn — the only failures are the two predicted breaks, user `mcp.types` imports and positional `McpError(ErrorData(...))` construction. Cross-version wire interop between a 3.4.3 peer and this branch is bidirectionally clean across 9 operations (3.4.3 client ↔ v4 server and v4 client ↔ 3.4.3 server over HTTP). All 29 `_ALIASES` bridge entries warn correctly with actionable messages.
## Environment
@ -68,7 +68,7 @@ async def read_schema():
return tools[0].inputSchema # works, warns; prefer .input_schema
```
The bridged fields are exactly those users read, data-driven from an `_ALIASES` table: `inputSchema`/`outputSchema` (Tool); `mimeType` (Resource, ResourceTemplate, TextResourceContents, BlobResourceContents, ImageContent, AudioContent) and `uriTemplate` (ResourceTemplate); `isError`/`structuredContent` (CallToolResult); `hasMore` (Completion); `serverInfo`/`protocolVersion` (InitializeResult); `nextCursor`/`resourceTemplates` (List\*Result); `systemPrompt`/`maxTokens`/`stopSequences`/`modelPreferences`/`toolChoice` (CreateMessageRequestParams); `requestedSchema` (ElicitRequestFormParams). WS2 verified all 25 alias entries warn correctly with actionable messages.
The bridged fields are exactly those users read, data-driven from an `_ALIASES` table: `inputSchema`/`outputSchema` (Tool); `readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint` (ToolAnnotations); `mimeType` (Resource, ResourceTemplate, TextResourceContents, BlobResourceContents, ImageContent, AudioContent) and `uriTemplate` (ResourceTemplate); `isError`/`structuredContent` (CallToolResult); `hasMore` (Completion); `serverInfo`/`protocolVersion` (InitializeResult); `nextCursor`/`resourceTemplates` (List\*Result); `systemPrompt`/`maxTokens`/`stopSequences`/`modelPreferences`/`toolChoice` (CreateMessageRequestParams); `requestedSchema` (ElicitRequestFormParams). WS2 verified all 29 alias entries warn correctly with actionable messages.
*Verify:* `fastmcp_slim/fastmcp/_compat.py` (the `_ALIASES` table and `install()`).

View file

@ -25,6 +25,16 @@ The migration merges to `main` and development continues there with subsequent P
- **`main` carries the beta pins.** While the SDK is on `mcp==2.0.0b1` / `mcp-types==2.0.0b1`, `main` cuts **pre-releases** (`4.0.0b1`, `4.0.0b2`, …). No stable PyPI release goes out until `mcp 2.0.0` reaches GA — at which point the pins swap to the stable SDK and `4.0.0` ships. The pin-swap is a tracked checklist item on the [Known Gaps](/development/v4-notes/known-gaps) page.
- **`release/3.x` is the maintenance line.** A `release/3.x` branch is cut from pre-merge `main`. It stays on the SDK v1 line, receives upstream security patches, and serves users who cannot move to the SDK v2 beta yet.
### Release codenames
Following the pun-title convention (`v<version>: <pun>`), the v4 line runs a single "four" motif across the whole cycle, holding the headline name for the stable release the way v3 did ("Three at Last" for `3.0.0`, stage puns for its betas):
| Release | Codename | The nod |
| --- | --- | --- |
| `4.0.0a1` (alpha) | **Fourst Contact** | _first contact_ — the first, cautious look at the new engine |
| `4.0.0b1` (beta) | **Fourgone Conclusion** | _foregone conclusion_ — once the MCP SDK went v2, v4 was inevitable |
| `4.0.0` (stable) | **Fast Fourward** | _fast forward_ — full speed onto the new foundation |
## How to read the register
Each subsystem section in the [Change Register](/development/v4-notes/change-register) tags its changes with one of four dispositions:

View file

@ -15,7 +15,7 @@ The SDK v2 raises FastMCP's dependency floors, which matters before any of your
**pydantic >= 2.12 is now the floor.** If your project pins an older pydantic (for example `pydantic==2.11.*`), installing this FastMCP release fails with an unsatisfiable-resolution error from your installer — bump your pin to `>=2.12` first. If you don't pin pydantic at all, installers upgrade it silently as part of the FastMCP upgrade.
**The server extra floors Starlette >= 1.0.** Modern FastAPI (0.11x and later) already runs on Starlette 1.x, so mounting a FastMCP server inside a FastAPI app coexists cleanly — verified with FastAPI 0.138.2. Only very old FastAPI versions pinned below Starlette 1.0 conflict; upgrade FastAPI if your resolver complains about Starlette.
**The server extra floors Starlette >= 1.0.1.** Modern FastAPI (0.11x and later) already runs on Starlette 1.x, so mounting a FastMCP server inside a FastAPI app coexists cleanly — verified with FastAPI 0.138.2. Only very old FastAPI versions pinned below Starlette 1.0.1 conflict; upgrade FastAPI if your resolver complains about Starlette.
## What FastMCP absorbs
@ -31,7 +31,7 @@ async with Client("my_mcp_server.py") as client:
schema = tools[0].inputSchema # still works, warns once
```
Each bridged read emits a `FastMCPDeprecationWarning` pointing you at the snake_case name (`tools[0].input_schema` here). The bridge covers the fields users actually read: `inputSchema`/`outputSchema` on tools, `mimeType` on resources and content, `isError`/`structuredContent` on tool results, `nextCursor` on paginated results, `serverInfo`/`protocolVersion` on the initialize result, the sampling parameter fields (`systemPrompt`, `maxTokens`, `stopSequences`, `modelPreferences`, `toolChoice`), and `requestedSchema` on elicitation parameters.
Each bridged read emits a `FastMCPDeprecationWarning` pointing you at the snake_case name (`tools[0].input_schema` here). The bridge covers the fields users actually read: `inputSchema`/`outputSchema` on tools; `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` on tool annotations; `mimeType` on resources and content; `isError`/`structuredContent` on tool results; `nextCursor` on paginated results; `serverInfo`/`protocolVersion` on the initialize result; the sampling parameter fields (`systemPrompt`, `maxTokens`, `stopSequences`, `modelPreferences`, `toolChoice`); and `requestedSchema` on elicitation parameters.
The bridge is controlled by the `mcp_camelcase_compat` setting, which defaults to on. Set it to `False` (or the environment variable `FASTMCP_MCP_CAMELCASE_COMPAT=false`) to turn the shims off, in which case only the snake_case names resolve:
@ -145,6 +145,64 @@ Grep your codebase for `except httpx.` and move those handlers to `httpx2`. The
Two runtime behaviors shift with httpx2, and because the switch is now wholesale they apply to **all** FastMCP HTTP — including server-auth upstream calls, not just the client path. TLS verification uses the operating system's trust store (via `truststore`, honoring `SSL_CERT_FILE`/`SSL_CERT_DIR`) instead of the bundled certifi CA set, so corporate-CA or certifi-pinned setups may verify differently. And the FastMCP HTTP loggers are renamed from `httpx`/`httpcore.*` to `httpx2`/`httpcore2.*` — update any logging filters that select the HTTP stack by logger name.
## Removed in FastMCP 4
Deprecations that warned throughout the 3.x line are removed in 4.0. Unlike the bridged changes above, these fail immediately at the call site — a `ModuleNotFoundError`, `ImportError`, `AttributeError`, or `TypeError` — so nothing degrades silently. Every one has a direct replacement, and the fix is mechanical.
### Moved imports
The proxy, OpenAPI, and app integrations moved to their permanent homes, and the internal component classes are no longer re-exported from their old aliases:
| Removed import | Replacement |
| --- | --- |
| `fastmcp.server.proxy` | `fastmcp.server.providers.proxy` |
| `fastmcp.server.openapi` (and `FastMCPOpenAPI`) | `FastMCP` with an `OpenAPIProvider` from `fastmcp.server.providers.openapi` |
| `fastmcp.experimental.server.openapi` | `fastmcp.server.providers.openapi` |
| `fastmcp.experimental.utilities.openapi` | `fastmcp.utilities.openapi` |
| `fastmcp.server.apps`, `fastmcp.server.app` | `fastmcp.apps` (e.g. `AppConfig`) or `fastmcp` (`FastMCPApp`) |
| `FunctionTool` / `ParsedFunction` / `tool` from `fastmcp.tools.tool` | `fastmcp.tools.function_tool` |
| `FunctionResource` / `resource` from `fastmcp.resources.resource` | `fastmcp.resources.function_resource` |
| `FunctionPrompt` / `prompt` from `fastmcp.prompts.prompt` | `fastmcp.prompts.function_prompt` |
Two renames in the same family are worth calling out because they have no compatibility alias. The response-caching wrapper models lost a spelling typo — `CachableToolResult`, `CachablePromptResult`, and their siblings became `CacheableToolResult`, `CacheablePromptResult`, etc. — so an import of the old spelling from `fastmcp.server.middleware.caching` raises `ImportError`. And `PromptToolMiddleware` / `ResourceToolMiddleware` are gone in favor of the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` (the `ToolInjectionMiddleware` base class is retained).
### Removed server methods and `mount()` keywords
These `FastMCP` methods and keywords have warned since 3.0 and are now removed:
| Removed | Replacement |
| --- | --- |
| `FastMCP.as_proxy(sub)` | `create_proxy(sub)` (from `fastmcp.server`) |
| `mcp.import_server(sub)` | `mcp.mount(sub)` |
| `mcp.mount(sub, prefix="x")` | `mcp.mount(sub, namespace="x")` |
| `mcp.mount(sub, as_proxy=True)` | wrap with `create_proxy(sub)`, then `mount` the proxy |
| `mcp.add_tool_transformation(name, cfg)` | `mcp.add_transform(ToolTransform({name: cfg}))` |
| `mcp.remove_tool_transformation(name)` | removed (was a no-op); hide tools with `mcp.disable(keys=[...])` |
| `mcp.remove_tool(name)` | `mcp.local_provider.remove_tool(name)` |
Two of these replacements are not exact behavioral swaps. `create_proxy` takes its target as the first positional argument (`target`), so a keyword call like `as_proxy(backend=server)` becomes `create_proxy(server)` rather than reusing the old keyword. And `local_provider.remove_tool` raises a plain `KeyError` when the tool is missing, where `FastMCP.remove_tool` raised a `NotFoundError` — update any `except NotFoundError` cleanup around a removal.
`mount(as_proxy=True)` used to route the child through a proxy (an MCP-client execution boundary) rather than composing it directly. To keep that boundary, wrap the child in `create_proxy()` and mount the proxy; a plain `mount(child)` composes the child in-process. Either way, the child's lifespan and middleware now run — a direct mount no longer skips them.
`import_server` → `mount` is the one row here that is not a mechanical swap, because the two never had the same semantics. `import_server` took a **one-time static snapshot** — it copied the child's tools, resources, and prompts at call time, with no live link, and did not run the child's lifespan or middleware. `mount` is a **live composition** — it holds a live link to the child and runs the child's lifespan and middleware. After switching, later changes to the child become visible through the parent, the child's lifespan runs with the parent's (entered when the server starts, held until it stops — not per request), and the child's middleware runs on the operations delegated to it. If you depended on the frozen-copy behavior (a stable snapshot, no child lifecycle), there is no drop-in replacement: register the child's components on the parent directly instead of composing the two servers.
### Removed tool and decorator parameters
Two `@tool` parameters and two settings are gone:
- **Tool `serializer=`** is removed from `@tool` / `mcp.tool()`, `Tool.from_function`, `Tool.from_tool`, and the OpenAPI tool. Return a `ToolResult` from your tool for full control over serialization instead.
- **Tool `exclude_args=`** is removed. Hide a parameter from the tool schema by injecting it instead: give it a `Depends(factory)` default (from `fastmcp.dependencies`), where `factory` is a callable returning the value the argument used to carry. An injected parameter never appears in the tool's schema, which is what `exclude_args` was for.
- **The `decorator_mode` setting** (`FASTMCP_DECORATOR_MODE`) and its `"object"` mode are removed. Decorators always return your original function with metadata attached; reach the component object through the server (`await mcp.get_tool("name")`) rather than off the decorated function.
- **`StreamableHttpTransport(sse_read_timeout=...)`** is removed — it was a no-op under the SDK v2 client. Set the read timeout through the public `Client(transport, timeout=...)` (a `timedelta` or float seconds), or reach for a custom `httpx_client_factory` when you need finer control. (`SSETransport` still accepts `sse_read_timeout`.)
## Behavior changes to verify
Two server-side behaviors changed in ways that compile fine but can surface at runtime.
**Templated resources are path-screened by default.** Every templated resource now has its extracted parameter values checked for path-traversal (`..` segments), absolute paths, and null bytes *before your handler runs*, at the server's read chokepoint. A rejected read returns a non-leaky "resource not found" error. Only a standalone `..` segment counts as traversal, so values that merely contain dots (`file.tar.gz`, `HEAD~3..HEAD`) and dotfiles (`.env`) still pass. If a template legitimately accepts `..`-bearing or absolute values, exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable the check per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security).
**Resource-not-found now returns `-32602`.** The wire error code for a missing resource from the core `resources/read` handler changed from `-32002` to `-32602` (`INVALID_PARAMS`, per SEP-2164). The human-readable message ("Resource not found: ...") is unchanged, so this only affects clients that matched on the numeric code — update those to expect `-32602`. (The opt-in `ErrorHandlingMiddleware` keeps its own per-method-prefix code mapping; if you run it with `transform_errors=True` it can still map not-found to a different code, so it is unaffected by this change.)
## Deprecation timeline
The camelCase bridge is a migration aid, not a permanent fixture. It works today and warns on every bridged read so you can find and update the affected call sites. Plan to migrate your reads to snake_case: the shims will be removed in a future release, after which only the snake_case names resolve — the same state you get today by setting `mcp_camelcase_compat = False`. Turning the setting off is a good way to surface every remaining camelCase read in your code as a hard `AttributeError` before the shims go away.
@ -181,3 +239,19 @@ Sampling is the exception that does not come back, and the reason is the protoco
If your tools rely on `ctx.elicit` or `ctx.list_roots`, they continue to work against clients on the earlier eras; on the modern era, reach for the guard pattern instead (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Sampling is deprecated on every era and will not return on modern connections — migrate those tools to server-side LLM calls.
Two of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; and each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next. A server that gates access in `on_initialize` or relies on per-session state must keep its clients on the session-based era. The narrow escape is per-client: `Client(server, mode="legacy")`. The durable, server-side answer is to declare the versions the server actually serves so a modern client is refused at connect time rather than silently losing those features — see the server's protocol-version restriction (added alongside this change).
## Upgrade checklist
Most servers upgrade untouched. Work down this list to find the ones that don't:
1. **Bump your environment.** Raise any pin below `pydantic>=2.12`; upgrade FastAPI if your resolver complains about Starlette `<1.0.1`.
2. **Fix imports that moved out.** Replace `from mcp.types import X` with `from mcp_types import X`, and update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims).
3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods-and-mount-keywords).
4. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
5. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
6. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
7. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize` or per-session state, keep its clients on `mode="legacy"` or restrict the server's served protocol versions.
8. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, and update any client that matched the old `-32002` resource-not-found code.
9. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed.
The executable version of this checklist lives in [`tests/test_upgrade_from_v3.py`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/test_upgrade_from_v3.py): it builds representative 3.x-style servers and asserts they run unchanged, and pins every removed surface to the exact error it now raises.

View file

@ -42,6 +42,12 @@ _ALIASES: dict[type, dict[str, str]] = {
"inputSchema": "input_schema",
"outputSchema": "output_schema",
},
mcp_types.ToolAnnotations: {
"readOnlyHint": "read_only_hint",
"destructiveHint": "destructive_hint",
"idempotentHint": "idempotent_hint",
"openWorldHint": "open_world_hint",
},
mcp_types.Resource: {
"mimeType": "mime_type",
},

View file

@ -7,6 +7,7 @@ import mimetypes
from collections.abc import Sequence
from pathlib import Path
from typing import Any, Literal, cast
from urllib.parse import quote, unquote
from mcp.shared.path_security import PathEscapeError, safe_join
from pydantic import AnyUrl
@ -283,7 +284,9 @@ class SkillProvider(Provider):
# Main skill file
resources.append(
SkillResource(
uri=AnyUrl(f"skill://{skill.name}/{self._main_file_name}"),
uri=AnyUrl(
f"skill://{skill.name}/{quote(self._main_file_name, safe='/')}"
),
name=f"{skill.name}/{self._main_file_name}",
description=skill.description,
mime_type="text/markdown",
@ -314,7 +317,9 @@ class SkillProvider(Provider):
mime_type, _ = mimetypes.guess_type(file_info.path)
resources.append(
SkillFileResource(
uri=AnyUrl(f"skill://{skill.name}/{file_info.path}"),
uri=AnyUrl(
f"skill://{skill.name}/{quote(file_info.path, safe='/')}"
),
name=f"{skill.name}/{file_info.path}",
description=f"File from {skill.name} skill",
mime_type=mime_type or "application/octet-stream",
@ -343,6 +348,7 @@ class SkillProvider(Provider):
skill_name, file_path = parts
if skill_name != skill.name:
return None
file_path = unquote(file_path)
if file_path == "_manifest":
return SkillResource(

View file

@ -12,6 +12,7 @@ from mcp.shared.exceptions import MCPError
from fastmcp import Client
from fastmcp.client.transports import PythonStdioTransport, StdioTransport
from fastmcp.exceptions import FastMCPError
# A pure-stdlib MCP server used by the process-lifecycle tests below. It starts
# in ~0.03s instead of the ~0.7s a real FastMCP server needs, which matters
@ -71,6 +72,59 @@ async def wait_for_process_exit(pid: int | None, timeout: float = 5.0) -> None:
pytest.fail(f"Subprocess {pid} was still alive after {timeout}s")
# Exceptions a call may raise while a crashed stdio session is being torn down
# and replaced. The direct-client path surfaces MCPError (session closed) or
# RuntimeError (reconnect failed); a proxy wraps the backend failure in a
# FastMCPError (e.g. ToolError).
CRASH_RECOVERY_EXCEPTIONS = (MCPError, RuntimeError, FastMCPError)
async def _recover_new_pid(call, old_pid: int, *, attempts: int = 10) -> int:
"""Call `call` until it succeeds on a subprocess other than `old_pid`.
`psutil.Process(pid).kill()` is asynchronous and crash recovery is
transparent, so a post-crash call may raise while the stale session is torn
down, briefly still reach the dying old process, or land on a fresh
subprocess the outcome depends on scheduling. Retrying until a call
succeeds on a *new* pid asserts eventual recovery instead of the exact
number of failed calls, which was never an actual contract.
"""
last_exc: BaseException | None = None
for _ in range(attempts):
try:
pid = await call()
except CRASH_RECOVERY_EXCEPTIONS as exc:
last_exc = exc
else:
if pid != old_pid:
return pid
await asyncio.sleep(0.05)
raise AssertionError(
f"stdio backend never recovered onto a new subprocess after {attempts} attempts"
) from last_exc
async def recover_client_pid(client: Client, old_pid: int, **kwargs) -> int:
"""Reconnect `client` and return the pid of the freshly spawned subprocess."""
async def call() -> int:
async with client:
result = await client.call_tool("pid")
return int(result.data)
return await _recover_new_pid(call, old_pid, **kwargs)
async def recover_proxy_pid(proxy, old_pid: int, **kwargs) -> int:
"""Call the proxy and return the pid of the freshly spawned backend subprocess."""
async def call() -> int:
result = await proxy.call_tool("pid")
return int(result.content[0].text)
return await _recover_new_pid(call, old_pid, **kwargs)
class TestDisconnect:
async def test_cancelled_connection_task_is_cleaned_up(self):
transport = StdioTransport(command="python", args=[])
@ -338,16 +392,10 @@ class TestSubprocessCrashRecovery:
# Kill the subprocess to simulate a crash
psutil.Process(pid1).kill()
# First attempt after crash fails — the stale session is
# detected and torn down so subsequent attempts succeed.
with pytest.raises(Exception):
async with client:
await client.call_tool("pid")
# Next connection starts a fresh subprocess
async with client:
result2 = await client.call_tool("pid")
pid2: int = result2.data
# Recovery is transparent: reconnecting eventually lands on a fresh
# subprocess with a new pid, regardless of how many attempts the
# stale-session teardown costs.
pid2 = await recover_client_pid(client, pid1)
assert pid1 != pid2
@ -382,18 +430,18 @@ class TestSubprocessCrashRecovery:
pids: list[int] = []
for _ in range(3):
async with client:
result = await client.call_tool("pid")
pid: int = result.data
pids.append(pid)
# Kill the subprocess
psutil.Process(pid).kill()
# Fail once to trigger cleanup
with pytest.raises(Exception):
if pids:
# After a crash, recovery is transparent — the next working
# call lands on a fresh subprocess with a new pid.
pid = await recover_client_pid(client, pids[-1])
else:
async with client:
await client.call_tool("pid")
result = await client.call_tool("pid")
pid = result.data
pids.append(pid)
# Kill the subprocess to force the next cycle to recover
psutil.Process(pid).kill()
# Each cycle should have started a new subprocess
assert len(set(pids)) == 3
@ -406,21 +454,23 @@ class TestSubprocessCrashRecovery:
)
pid1: int = 0
with pytest.raises(Exception):
try:
async with client:
result = await client.call_tool("pid")
pid1 = result.data
# Kill while the context is still open
psutil.Process(pid1).kill()
# This call hits the dead session
# This call races the asynchronous kill: it may hit the dead
# session and raise, or briefly still be served. Either outcome
# is fine — what matters is that recovery works afterward.
await client.call_tool("pid")
except CRASH_RECOVERY_EXCEPTIONS:
pass
assert pid1 != 0, "First call should have succeeded before the crash"
# Recovery: next connection starts a fresh subprocess
async with client:
result = await client.call_tool("pid")
pid2: int = result.data
pid2 = await recover_client_pid(client, pid1)
assert pid1 != pid2
@ -441,13 +491,9 @@ class TestSubprocessCrashRecovery:
# Kill the backend subprocess
psutil.Process(pid1).kill()
# First call after crash fails
with pytest.raises(Exception):
await proxy.call_tool("pid")
# Second call recovers with a new subprocess
result2 = await proxy.call_tool("pid")
pid2 = int(result2.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
# Recovery is transparent: a call after the crash eventually succeeds on
# a fresh backend subprocess with a new pid.
pid2 = await recover_proxy_pid(proxy, pid1)
assert pid1 != pid2
@ -523,11 +569,11 @@ class TestSubprocessCrashRecovery:
# stale session and fails with CONNECTION_CLOSED, which in turn tears
# the session down so the next attempt reconnects.
#
# The crash tests above encode this same one-failure-then-recover
# contract explicitly with `pytest.raises`. Here the failure is
# timing-dependent rather than guaranteed, so retry once instead: the
# invariant under test is that a cleanly-exited server is replaced by a
# fresh subprocess, not how many attempts EOF detection costs.
# Like the crash tests above, this asserts eventual recovery rather than
# a fixed number of failed attempts: the failure is timing-dependent, so
# retry instead. The invariant under test is that a cleanly-exited server
# is replaced by a fresh subprocess, not how many attempts EOF detection
# costs.
pid2: int | None = None
for _ in range(2):
try:

View file

@ -176,6 +176,26 @@ This is my skill content.
assert isinstance(result[0], TextResourceContents)
assert "# My Skill" in result[0].text
async def test_read_main_file_with_literal_percent_in_name(self, tmp_path: Path):
"""A custom main_file_name containing a literal '%' must round-trip
through the same encode/decode path as supporting files (#4545)."""
skill_dir = tmp_path / "percent-main-skill"
skill_dir.mkdir()
(skill_dir / "MAIN%20FILE.md").write_text("# Demo\n")
mcp = FastMCP("Test")
mcp.add_provider(
SkillProvider(skill_path=skill_dir, main_file_name="MAIN%20FILE.md")
)
async with Client(mcp) as client:
resources = await client.list_resources()
main = next(
r for r in resources if r.name == "percent-main-skill/MAIN%20FILE.md"
)
result = await client.read_resource(main.uri)
assert "# Demo" in result[0].text
async def test_read_manifest(self, single_skill_dir: Path):
mcp = FastMCP("Test")
mcp.add_provider(SkillProvider(skill_path=single_skill_dir))
@ -230,6 +250,76 @@ This is my skill content.
result = await client.read_resource(AnyUrl("skill://my-skill/reference.md"))
assert "# Reference" in result[0].text
async def test_read_supporting_file_with_space_in_name(self, tmp_path: Path):
"""Percent-encoded resource URIs for supporting files must round-trip (#4545)."""
skill_dir = tmp_path / "space-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Skill\n")
(skill_dir / "setup guide.md").write_text("SPACE OK")
mcp = FastMCP("Test")
mcp.add_provider(
SkillProvider(skill_path=skill_dir, supporting_files="resources")
)
async with Client(mcp) as client:
resources = await client.list_resources()
supporting = next(
r for r in resources if r.name == "space-skill/setup guide.md"
)
assert str(supporting.uri) == "skill://space-skill/setup%20guide.md"
result = await client.read_resource(supporting.uri)
assert result[0].text == "SPACE OK"
async def test_read_supporting_file_with_utf8_name(self, tmp_path: Path):
skill_dir = tmp_path / "utf8-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Skill\n")
(skill_dir / "café.md").write_text("UTF8 OK", encoding="utf-8")
mcp = FastMCP("Test")
mcp.add_provider(
SkillProvider(skill_path=skill_dir, supporting_files="resources")
)
async with Client(mcp) as client:
resources = await client.list_resources()
supporting = next(r for r in resources if r.name == "utf8-skill/café.md")
result = await client.read_resource(supporting.uri)
assert result[0].text == "UTF8 OK"
async def test_percent_encoded_name_does_not_collide_with_space(
self, tmp_path: Path
):
"""A filename that already contains a literal '%20' must not be confused
with a space-containing filename once both are percent-encoded into
resource URIs (#4545)."""
skill_dir = tmp_path / "percent-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Skill\n")
(skill_dir / "setup guide.md").write_text("SPACE OK")
(skill_dir / "setup%20guide.md").write_text("LITERAL PERCENT OK")
mcp = FastMCP("Test")
mcp.add_provider(
SkillProvider(skill_path=skill_dir, supporting_files="resources")
)
async with Client(mcp) as client:
resources = await client.list_resources()
by_name = {r.name: r for r in resources}
space_uri = by_name["percent-skill/setup guide.md"].uri
literal_uri = by_name["percent-skill/setup%20guide.md"].uri
assert str(space_uri) != str(literal_uri)
space_result = await client.read_resource(space_uri)
literal_result = await client.read_resource(literal_uri)
assert space_result[0].text == "SPACE OK"
assert literal_result[0].text == "LITERAL PERCENT OK"
async def test_skill_resource_meta(self, single_skill_dir: Path):
"""SkillResource populates meta with skill name and is_manifest."""
provider = SkillProvider(skill_path=single_skill_dir)

View file

@ -51,6 +51,20 @@ class TestCamelCaseBridge:
with pytest.warns(FastMCPDeprecationWarning):
assert tool.outputSchema == {"type": "string"} # ty: ignore[unresolved-attribute]
@pytest.mark.parametrize(
("camel", "snake", "value"),
[
("readOnlyHint", "read_only_hint", True),
("destructiveHint", "destructive_hint", False),
("idempotentHint", "idempotent_hint", True),
("openWorldHint", "open_world_hint", False),
],
)
def test_tool_annotations_bridged(self, camel, snake, value):
annotations = mcp_types.ToolAnnotations(**{snake: value})
with pytest.warns(FastMCPDeprecationWarning):
assert getattr(annotations, camel) is value
def test_call_tool_result_is_error_bridged(self):
result = mcp_types.CallToolResult(content=[], is_error=True)
with pytest.warns(FastMCPDeprecationWarning):

View file

@ -0,0 +1,316 @@
"""Upgrade-reality tests: does a FastMCP 3.x server survive the move to v4?
These tests are the executable half of the `docs/getting-started/upgrading/from-fastmcp-3`
guide. They fall into three groups:
- `TestCommonServersUpgradeCleanly` builds servers the way the 3.x docs taught
and runs them end-to-end under v4 defaults. These are the "nothing to do"
cases a typical server upgrades untouched.
- `TestRemovedSurfacesFailLoudly` pins every hard removal to the exact error a
user hits, so the break is a clear signal rather than silent misbehavior.
Each case names its 4.0 replacement in a comment.
- `TestBehaviorChanges` covers the shifts that compile fine but behave
differently: the `mode="auto"` client default, path-traversal screening, and
the resource-not-found error code.
The camelCase field bridge and the `McpError` alias are covered in
`test_compat.py`; this file deliberately does not repeat them.
"""
import importlib
import inspect
import pytest
# Protocol types now live in mcp_types directly; fastmcp.types no longer
# re-exports them (it holds only FastMCP-defined types like Textarea).
from mcp_types import ErrorData, TextContent, Tool, ToolAnnotations
from fastmcp import Client, FastMCP, settings
# The canonical replacement symbols the upgrade guide points users to. Importing
# them here — the ordinary in-process path every other test in the suite uses —
# means this file fails at collection if the guide ever names a symbol that no
# longer resolves. `create_proxy`, `settings`, `McpError`, and
# `CacheableToolResult` above are part of the same set.
from fastmcp.apps import AppConfig
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.dependencies import Depends
from fastmcp.exceptions import McpError
from fastmcp.prompts.function_prompt import FunctionPrompt
from fastmcp.resources.function_resource import FunctionResource
from fastmcp.server import create_proxy
from fastmcp.server.middleware.caching import CacheableToolResult
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
from fastmcp.server.transforms import PromptsAsTools, ResourcesAsTools, ToolTransform
from fastmcp.tools.function_tool import FunctionTool
class TestCommonServersUpgradeCleanly:
"""Servers written against the 3.x API run unchanged on v4 defaults."""
async def test_basic_tool_resource_prompt_server(self):
mcp = FastMCP("Demo", instructions="A demo server")
@mcp.tool
def add(a: int, b: int) -> int:
return a + b
@mcp.resource("data://config")
def config() -> dict:
return {"version": "1.0"}
@mcp.prompt
def greet(who: str) -> str:
return f"Hello, {who}"
async with Client(mcp) as client: # default mode="auto"
tools = await client.list_tools()
resources = await client.list_resources()
prompts = await client.list_prompts()
result = await client.call_tool("add", {"a": 2, "b": 3})
assert {t.name for t in tools} == {"add"}
assert {str(r.uri) for r in resources} == {"data://config"}
assert {p.name for p in prompts} == {"greet"}
assert result.data == 5
async def test_templated_resource_server(self):
mcp = FastMCP("Templated")
@mcp.resource("files://{name}")
def get_file(name: str) -> str:
return f"contents of {name}"
async with Client(mcp) as client:
contents = await client.read_resource("files://report.txt")
assert contents[0].text == "contents of report.txt"
async def test_mounted_server(self):
parent = FastMCP("Parent")
child = FastMCP("Child")
@child.tool
def ping() -> str:
return "pong"
parent.mount(child, namespace="child")
async with Client(parent) as client:
tools = await client.list_tools()
result = await client.call_tool("child_ping", {})
assert "child_ping" in {t.name for t in tools}
assert result.data == "pong"
async def test_proxy_server(self):
backend = FastMCP("Backend")
@backend.tool
def ping() -> str:
return "pong"
proxy = create_proxy(backend)
async with Client(proxy) as client:
tools = await client.list_tools()
result = await client.call_tool("ping", {})
assert "ping" in {t.name for t in tools}
assert result.data == "pong"
# --- Canonical replacement surfaces the guide points users to ---
class TestCanonicalReplacementsResolve:
def test_replacement_symbols_are_bound(self):
# The imports at the top of this module already prove these resolve
# (a broken pointer would fail collection). This asserts each is bound
# so the guarantee is an explicit, named test rather than a side effect.
symbols = (
FunctionTool,
FunctionResource,
FunctionPrompt,
OpenAPIProvider,
FastMCPProxy,
ProxyClient,
create_proxy,
AppConfig,
ToolTransform,
PromptsAsTools,
ResourcesAsTools,
Depends,
McpError,
CacheableToolResult,
TextContent,
Tool,
ToolAnnotations,
ErrorData,
)
assert all(sym is not None for sym in symbols)
# --- Hard removals: modules that no longer exist ---
REMOVED_MODULES = [
"fastmcp.server.proxy", # -> fastmcp.server.providers.proxy
"fastmcp.server.openapi", # -> fastmcp.server.providers.openapi
"fastmcp.experimental.server.openapi", # -> fastmcp.server.providers.openapi
"fastmcp.experimental.utilities.openapi", # -> fastmcp.utilities.openapi
"fastmcp.server.apps", # -> fastmcp.apps
"fastmcp.server.app", # -> fastmcp.apps / fastmcp
"mcp.types", # -> mcp_types
]
# Names that were re-export shims and are gone; import them from the canonical
# module (named in each comment) instead.
REMOVED_NAMES = [
# deprecated 3.1 -> fastmcp.server.transforms.PromptsAsTools / ResourcesAsTools
("fastmcp.server.middleware.tool_injection", "PromptToolMiddleware"),
("fastmcp.server.middleware.tool_injection", "ResourceToolMiddleware"),
# old misspelled names renamed to Cacheable* (no alias) codespell:ignore
("fastmcp.server.middleware.caching", "CachableToolResult"), # codespell:ignore
("fastmcp.server.middleware.caching", "CachablePromptResult"), # codespell:ignore
# component-import shims -> fastmcp.tools.function_tool, etc.
("fastmcp.tools.tool", "FunctionTool"),
("fastmcp.resources.resource", "FunctionResource"),
("fastmcp.prompts.prompt", "FunctionPrompt"),
]
class TestRemovedSurfacesFailLoudly:
@pytest.mark.parametrize("module_path", REMOVED_MODULES)
def test_removed_module_raises_module_not_found(self, module_path):
with pytest.raises(ModuleNotFoundError):
importlib.import_module(module_path)
@pytest.mark.parametrize(
"module_path, name",
REMOVED_NAMES,
ids=[f"{m}:{n}" for m, n in REMOVED_NAMES],
)
def test_removed_name_is_gone(self, module_path, name):
# `from <module_path> import <name>` raises ImportError as a result.
module = importlib.import_module(module_path)
assert not hasattr(module, name)
def test_cacheable_rename_new_name_resolves(self):
assert CacheableToolResult is not None
@pytest.mark.parametrize(
"method_name",
[
"as_proxy", # -> create_proxy()
"import_server", # -> mount()
"add_tool_transformation", # -> add_transform(ToolTransform(...))
"remove_tool_transformation", # removed no-op
"remove_tool", # -> mcp.local_provider.remove_tool()
],
)
def test_removed_fastmcp_method_is_gone(self, method_name):
assert not hasattr(FastMCP, method_name)
def test_mount_prefix_kwarg_removed(self):
parent = FastMCP("Parent")
child = FastMCP("Child")
# prefix= -> namespace=
with pytest.raises(TypeError):
parent.mount(child, prefix="child") # ty: ignore[unknown-argument]
def test_mount_as_proxy_kwarg_removed(self):
parent = FastMCP("Parent")
child = FastMCP("Child")
# as_proxy= removed; wrap with create_proxy() before mounting
with pytest.raises(TypeError):
parent.mount(child, as_proxy=True) # ty: ignore[unknown-argument]
def test_tool_serializer_kwarg_removed(self):
mcp = FastMCP("S")
# serializer= -> return a ToolResult
with pytest.raises(TypeError):
@mcp.tool(serializer=str) # ty: ignore[no-matching-overload]
def f(x: int) -> int:
return x
def test_tool_exclude_args_kwarg_removed(self):
mcp = FastMCP("S")
# exclude_args= -> Depends() to hide parameters
with pytest.raises(TypeError):
@mcp.tool(exclude_args=["y"]) # ty: ignore[no-matching-overload]
def g(x: int, y: int = 1) -> int:
return x
def test_decorator_mode_setting_removed(self):
# FASTMCP_DECORATOR_MODE / settings.decorator_mode removed entirely
assert not hasattr(settings, "decorator_mode")
def test_streamable_http_sse_read_timeout_removed(self):
# sse_read_timeout= was a no-op under SDK v2; configure via
# read_timeout_seconds or the httpx2 client factory instead.
with pytest.raises(TypeError):
StreamableHttpTransport(
"https://example.com/mcp",
sse_read_timeout=5, # ty: ignore[unknown-argument]
)
def test_mcp_error_positional_construction_raises(self):
# Before: raise McpError(ErrorData(code=..., message=...))
with pytest.raises(TypeError):
McpError(ErrorData(code=-32000, message="boom")) # ty: ignore[missing-argument, invalid-argument-type]
def test_mcp_error_keyword_construction_works(self):
err = McpError(code=-32000, message="boom")
assert err.error.code == -32000
assert err.error.message == "boom"
class TestBehaviorChanges:
"""Changes that import fine but behave differently on v4."""
def test_client_defaults_to_auto_mode(self):
default = inspect.signature(Client.__init__).parameters["mode"].default
assert default == "auto"
async def test_templated_resource_blocks_path_traversal(self):
mcp = FastMCP("Guarded")
@mcp.resource("files://{path}")
def guarded(path: str) -> str:
return f"read:{path}"
# Same template with screening disabled — the control that proves the
# rejection below is the path screen, not an unrelated URI mismatch.
@mcp.resource("open://{path}", security=None)
def unguarded(path: str) -> str:
return f"read:{path}"
async with Client(mcp) as client:
ok = await client.read_resource("files://hello.txt")
assert ok[0].text == "read:hello.txt"
# With screening off, a `..` value reaches the handler...
control = await client.read_resource("open://..")
assert control[0].text == "read:.."
# ...but under the default policy it is screened before the handler
# runs and surfaces a non-leaky INVALID_PARAMS error.
with pytest.raises(McpError) as exc_info:
await client.read_resource("files://..")
assert exc_info.value.error.code == -32602
assert "not found" in exc_info.value.error.message.lower()
async def test_resource_not_found_uses_invalid_params_code(self):
mcp = FastMCP("NF")
# Pin the handshake era so we read the code off the wire error directly.
async with Client(mcp, mode="legacy") as client:
with pytest.raises(McpError) as exc_info:
await client.read_resource("missing://nope")
# SEP-2164: resource-not-found is INVALID_PARAMS (-32602), was -32002.
assert exc_info.value.error.code == -32602