diff --git a/docs/development/v4-notes/index.mdx b/docs/development/v4-notes/index.mdx index 4344d5328..7fd231713 100644 --- a/docs/development/v4-notes/index.mdx +++ b/docs/development/v4-notes/index.mdx @@ -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: `), 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: diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx index 960439783..dfe13c7b1 100644 --- a/docs/getting-started/upgrading/from-fastmcp-3.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx @@ -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 @@ -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. diff --git a/tests/test_upgrade_from_v3.py b/tests/test_upgrade_from_v3.py new file mode 100644 index 000000000..f324241ba --- /dev/null +++ b/tests/test_upgrade_from_v3.py @@ -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 import ` 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