Merge remote-tracking branch 'origin/main' into claude/sdk-resolve-annotation-1d4770

This commit is contained in:
Jeremiah Lowin 2026-07-28 17:12:56 -04:00
commit 42d8f50499
No known key found for this signature in database
41 changed files with 1505 additions and 1234 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](index.md) 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 29 `_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 were the two predicted breaks, user `mcp.types` imports and positional `McpError(ErrorData(...))` construction — and the first of those went away when the stable SDK restored `mcp.types` (below). 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
@ -18,14 +18,31 @@ The SDK v2 raises FastMCP's dependency floors. Projects pinning an older pydanti
## Types and imports
The SDK v2 split protocol types into a standalone `mcp_types` package and renamed every field from camelCase to snake_case. This is the single largest source of user-facing change, and FastMCP absorbs nearly all of it.
The SDK v2 moved protocol types into a standalone `mcp_types` package — still importable as `mcp.types` and renamed every model field from camelCase to snake_case in Python. The wire format is unchanged: the models keep their camelCase aliases and the SDK serializes with `by_alias=True`, so this renames the attributes code reads, not the JSON on the connection. This is the single largest source of user-facing change, and FastMCP absorbs nearly all of it.
### `mcp.types` split into `mcp_types` — Breaking (by omission)
<Note>
Superseded by the stable SDK — see "`mcp.types` restored as a permanent alias" below. The betas this section was written against had no `mcp.types`; `2.0.0` brought it back, so the break never reached a release.
</Note>
The `mcp.types` module no longer exists. Any `from mcp.types import X` or `import mcp.types` in user code raises `ImportError`. This is the one import change users cannot avoid.
*Verify:* `fastmcp_slim/fastmcp/types.py`, and grep the diff for the doc migration `from mcp.types import``from fastmcp.types import` (30 sites).
### `mcp.types` restored as a permanent alias — Absorbed (stable-SDK change)
The SDK betas removed `mcp.types` outright, which made user imports the one unavoidable break in the migration. SDK `2.0.0` reintroduced it as a permanent alias for `mcp_types`: a wildcard mirror where every name is the *same object* (`mcp.types.Tool is mcp_types.Tool`), with matching `__all__` and the same snake_case fields. It is not a v1 restoration — only the import path came back. So `from mcp.types import X` keeps working, and the break is gone.
This leaves the two spellings pointing at one package, and FastMCP uses each in a different place on purpose:
- **User-facing docs and examples use `mcp.types`.** Anyone installing `fastmcp` gets the full SDK (`fastmcp``fastmcp-slim[client,server]``[mcp]``mcp`), so the aliased path always resolves and is the spelling the SDK prefers. It also means a user's own dependency list needs only `mcp`, without naming `mcp-types` to satisfy a linter.
- **FastMCP's own source uses `mcp_types`.** `mcp.types` is a submodule of `mcp`, so importing it requires the whole SDK. `mcp-types` is a *core* `fastmcp-slim` dependency while `mcp` sits behind the `[mcp]` extra, and a bare `fastmcp-slim` install must import without the SDK present — a guarantee `test_bare_slim_import_needs_only_mcp_types` pins. Reaching for `mcp.types` in core modules (`exceptions.py`, `_compat.py`, `tools/`, `resources/`) would pull the full SDK into the slim floor and break it.
The rule of thumb: import `mcp_types` in library code, write `mcp.types` in anything a user copies. Both resolve to the same objects, so neither choice constrains the other.
*Verify:* `.venv/.../mcp/types/__init__.py` (the wildcard mirror), `fastmcp_slim/pyproject.toml` (`mcp-types` core vs `mcp` in the `[mcp]` extra), `tests/client/test_slim_package_boundaries.py::test_bare_slim_import_needs_only_mcp_types`, and `tests/test_upgrade_from_v3.py::TestRemovedSurfacesFailLoudly::test_mcp_types_import_path_restored_by_stable_sdk`.
### `fastmcp.types` is the stable home — Bridged
<Note>

View file

@ -220,7 +220,7 @@ import qrcode
from fastmcp import FastMCP
from fastmcp.apps import AppConfig, ResourceCSP
from fastmcp.tools import ToolResult
from mcp_types import ImageContent
from mcp.types import ImageContent
mcp = FastMCP("QR Code Server")

View file

@ -47,7 +47,7 @@ For fine-grained targeting, subclass `MessageHandler` to use specific hooks:
```python
from fastmcp import Client
from fastmcp.client.messages import MessageHandler
import mcp_types
import mcp.types as mcp_types
class MyMessageHandler(MessageHandler):
async def on_tool_list_changed(
@ -78,7 +78,7 @@ client = Client(
```python
from fastmcp.client.messages import MessageHandler
import mcp_types
import mcp.types as mcp_types
class MyMessageHandler(MessageHandler):
async def on_message(self, message) -> None:
@ -141,7 +141,7 @@ A practical example of maintaining a tool cache that refreshes when tools change
```python
from fastmcp import Client
from fastmcp.client.messages import MessageHandler
import mcp_types
import mcp.types as mcp_types
class ToolCacheHandler(MessageHandler):
def __init__(self):

View file

@ -20,7 +20,7 @@ The handler receives the conversation the server wants completed, the parameters
```python
from fastmcp import Client
from fastmcp.client.sampling import SamplingMessage, SamplingParams, RequestContext
from mcp_types import TextContent
from mcp.types import TextContent
async def sampling_handler(
@ -173,7 +173,7 @@ Registering any `sampling_handler` advertises full sampling support, tools inclu
```python
from fastmcp import Client
from mcp_types import SamplingCapability
from mcp.types import SamplingCapability
async def text_only_handler(messages, params, context) -> str:

View file

@ -5,7 +5,7 @@ description: What changes when you upgrade to FastMCP 4, which builds on the MCP
icon: up
---
FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it splits the protocol types out of `mcp.types` into a standalone `mcp_types` package, and it renames every protocol field from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`, and so on).
FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it moves the protocol types into a standalone `mcp_types` package (still importable as `mcp.types`), and it renames every protocol field from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`, and so on).
FastMCP 4 absorbs almost all of this for you. Field access is bridged so your existing reads keep working, and the imports you were taught have a stable home in FastMCP itself. What the SDK cannot hide is the protocol's own direction: the new sessionless era removes the server's ability to call back into a client mid-request, and background tasks moved out of the core spec into an extension. Those two shape the changes a working server is most likely to feel.
@ -13,21 +13,23 @@ The sections below cover what FastMCP handles for you, the changes you must make
## Install the v4 Prerelease
While FastMCP 4 is in prerelease, pin the beta and its prerelease protocol dependencies explicitly. For a uv project, add the following to `pyproject.toml`:
While FastMCP 4 is in prerelease, pin the beta explicitly. The `fastmcp` package is a thin wrapper that depends on `fastmcp-slim` at the same version, so asking for a prerelease of one means asking for a prerelease of the other. pip infers that on its own:
```bash
pip install "fastmcp==4.0.0b1"
```
uv is stricter: it allows prereleases only for packages you name, and `fastmcp-slim` arrives transitively. Constrain it alongside the requirement in `pyproject.toml`:
```toml
[project]
dependencies = ["fastmcp==4.0.0b1"]
[tool.uv]
constraint-dependencies = [
"fastmcp-slim==4.0.0b1",
"mcp==2.0.0b2",
"mcp-types==2.0.0b2",
]
constraint-dependencies = ["fastmcp-slim==4.0.0b1"]
```
Then run `uv lock` or `uv sync` normally. The constraints opt only these transitive packages into their prerelease versions; you do not need `--prerelease allow`, which permits prereleases throughout the dependency graph.
Then run `uv lock` or `uv sync` normally. Naming the one package keeps the rest of your graph on stable releases, where `--prerelease allow` would opt every dependency into prereleases. The MCP SDK needs no constraint at all now that it ships stable releases — pinning `mcp==2.0.0b2` here would in fact break the resolution, since a prerelease does not satisfy FastMCP's own `mcp>=2.0.0` requirement.
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
You are upgrading an MCP server or client from FastMCP 3.x to FastMCP 4, which is built on the MCP Python SDK v2.
@ -41,7 +43,6 @@ ENVIRONMENT
- a FastAPI pin below 0.133.0, the first release admitting Starlette 1.x (earlier ones cap it, e.g. 0.115.12 requires `starlette<0.47.0`), or any direct Starlette pin below 1.0.1
IMPORTS THAT NO LONGER RESOLVE
- `mcp.types` (anywhere, in any form)
- `fastmcp.server.proxy`, `fastmcp.server.openapi`, `FastMCPOpenAPI`
- `fastmcp.experimental.server.openapi`, `fastmcp.experimental.utilities.openapi`
- `fastmcp.experimental.sampling.handlers`
@ -131,12 +132,14 @@ See [Settings](/more/settings) for the full reference.
### Protocol Types
The `mcp.types` module no longer exists. Every protocol type — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Icon`, `PromptMessage`, `SamplingMessage`, `ToolAnnotations`, notification and request wrapper types like `ToolListChangedNotification`, and everything else — now lives in the standalone `mcp_types` package. Update your imports to point there:
Every protocol type — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Icon`, `PromptMessage`, `SamplingMessage`, `ToolAnnotations`, notification and request wrapper types like `ToolListChangedNotification`, and everything else — now lives in a standalone `mcp_types` package. The SDK re-exports that package as `mcp.types`, so existing imports keep working and stay the preferred spelling:
```python
from mcp_types import TextContent, Tool, ToolAnnotations
from mcp.types import TextContent, Tool, ToolAnnotations
```
Both names resolve to the same objects, so `from mcp_types import X` is equally valid — useful if you depend on the types without the rest of the SDK. What did change is the fields on those types: they are snake_case now (`input_schema`, not `inputSchema`), which the [compatibility bridge](#legacy-camelcase-field-access-keeps-working) covers for the objects FastMCP hands you.
`fastmcp.types` still exists, but holds only types FastMCP defines itself (currently just `Textarea`, used to render a multiline textarea in form-based UIs) — it does not re-export protocol types.
### The `McpError` Alias
@ -162,15 +165,7 @@ A few client behaviors that touch the SDK are preserved so you don't have to cha
## What You Must Change
Everything above, FastMCP handled for you. What remains lives in your own code, where FastMCP can't reach it — your imports, how you construct errors, the custom HTTP clients you hand to a transport, and any place you reach past FastMCP's surfaces into the raw SDK objects. Each surfaces as a clear failure at import or call time, and each is a mechanical fix.
**Your own `mcp.types` imports.** FastMCP can re-export types, but it can't rewrite imports in your code. Any `from mcp.types import X` or `import mcp.types` in your server or client fails at import time with:
```
ModuleNotFoundError: No module named 'mcp.types'
```
The raw message gives no hint toward the fix, so if you see it after upgrading, this is why. Switch to `from mcp_types import X`.
Everything above, FastMCP handled for you. What remains lives in your own code, where FastMCP can't reach it — how you construct errors, the custom HTTP clients you hand to a transport, and any place you reach past FastMCP's surfaces into the raw SDK objects. Each surfaces as a clear failure at import or call time, and each is a mechanical fix.
**`McpError` construction.** The v1 pattern of wrapping an `ErrorData` and passing it positionally fails under SDK v2 with:
@ -445,7 +440,7 @@ The client side is unaffected. `sampling_handler=` and `roots=` mean what they a
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).
2. **Fix imports that moved out.** `from mcp.types import X` still works, but 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).
4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; ask for roots through the guard pattern, or take file paths as tool arguments. A server whose purpose is to use the caller's model should stay on FastMCP 3.x rather than migrate.
5. **Find every `ctx.elicit()` call.** It raises on modern connections, which is what a default client now negotiates. Rewrite the tool as a guard tool, branch on `ctx.request_context.protocol_version`, or keep its clients on `mode="legacy"` — see [the era gate](#behavior-changes).

View file

@ -14,7 +14,6 @@ The core idea: instead of telling the SDK what your tools look like and then sep
MCP SDK v2 is a substantial, deliberate modernization of the protocol layer. Protocol types moved into a standalone `mcp_types` package, wire fields moved from camelCase to snake_case, and the low-level `Server` was rebuilt so handlers are passed to the constructor as `on_*` callables taking `(ctx, params)` rather than registered with decorators. A v1 server meets that change the moment its environment resolves `mcp` to v2:
```
ModuleNotFoundError: No module named 'mcp.types'
AttributeError: 'Server' object has no attribute 'list_tools'
```
@ -66,7 +65,7 @@ TYPES THAT DISAPPEAR FROM YOUR CODE
- `types.TextContent` wrappers around return values — return plain Python values instead
- `types.ImageContent`, `types.EmbeddedResource`
- `types.PromptMessage`, `types.GetPromptResult`
- Note that `mcp.types` no longer exists at all in the SDK v2 that FastMCP 4 builds on; any type that genuinely survives the rewrite comes from `mcp_types` now.
- Note that in the SDK v2 that FastMCP 4 builds on, `mcp.types` aliases the standalone `mcp_types` package; the import path still works, but the fields are snake_case now.
CONTEXT AND SIDE CHANNELS
- `server.request_context`
@ -92,7 +91,7 @@ uv add "fastmcp==4.0.0b1"
An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
FastMCP depends on the `mcp` package, so the SDK stays installed. Note that FastMCP 4 builds on SDK v2, where `mcp.types` no longer exists — protocol types live in the standalone `mcp_types` package now. Most of your `mcp.types` imports disappear entirely in the rewrite below, since FastMCP derives the protocol types from your function signatures. For the few you still need, import them from `mcp_types`.
FastMCP depends on the `mcp` package, so the SDK stays installed. FastMCP 4 builds on SDK v2, where the protocol types live in a standalone `mcp_types` package that stays importable as `mcp.types`. Most of your `mcp.types` imports disappear entirely in the rewrite below, since FastMCP derives the protocol types from your function signatures.
## Server and Transport

View file

@ -61,7 +61,7 @@ uv add "fastmcp==4.0.0b1"
An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
FastMCP depends on the `mcp` package, so the SDK stays installed and importable. What changes is which parts of it you reach for. FastMCP 4 builds on SDK v2, where `mcp.server.fastmcp` and `mcp.types` are both gone — anything you imported from those two modules needs a new home, and the sections below cover both. Update your import, run your server, and if your tools work, you're done.
FastMCP depends on the `mcp` package, so the SDK stays installed and importable. What changes is which parts of it you reach for. FastMCP 4 builds on SDK v2, where `mcp.server.fastmcp` is gone — anything you imported from it needs a new home, and the sections below cover that. `mcp.types` still resolves (it aliases the standalone `mcp_types` package), though its fields are snake_case now. Update your import, run your server, and if your tools work, you're done.
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
You are upgrading an MCP server from FastMCP 1.0 (bundled in v1 of the `mcp` package) to standalone FastMCP 4.
@ -98,9 +98,9 @@ PROMPT RETURN VALUES
- prompt functions returning raw dicts with "role"/"content" keys — FastMCP 1.0 coerced these silently, standalone FastMCP does not
OTHER mcp.* IMPORTS
- anything from `mcp.types` — the module does not exist in the SDK v2 that FastMCP 4 builds on; protocol types moved to `mcp_types` with camelCase fields renamed to snake_case
- anything from `mcp.types` — the import path still works in the SDK v2 that FastMCP 4 builds on, but the fields were renamed from camelCase to snake_case
- `from mcp.server.stdio import stdio_server` and any transport boilerplate around it
- `mcp.types.TextContent` / `ImageContent` used to wrap tool return values — FastMCP has friendlier equivalents, so prefer those over a mechanical `mcp_types` swap
- `mcp.types.TextContent` / `ImageContent` used to wrap tool return values — FastMCP has friendlier equivalents, so prefer those over keeping the raw protocol types
DECORATOR RETURN VALUES
- any code reading `.name`, `.description`, or other component attributes off a `@mcp.tool` / `@mcp.resource` / `@mcp.prompt` decorated function. Decorators return the original function now.
@ -215,7 +215,7 @@ def debug(error: str) -> list[Message]:
### Other `mcp.*` Imports
FastMCP 4 builds on MCP SDK v2, where the `mcp.types` module no longer exists. Protocol types moved to a standalone `mcp_types` package, and the field names were renamed from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, and so on). Update `from mcp.types import X` to `from mcp_types import X`. For everything else SDK v2 changed, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3), which covers the same protocol rebuild from the FastMCP side.
FastMCP 4 builds on MCP SDK v2, which moved the protocol types into a standalone `mcp_types` package and re-exports it as `mcp.types` — so `from mcp.types import X` keeps working. The field names did change, from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, and so on). For everything else SDK v2 changed, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3), which covers the same protocol rebuild from the FastMCP side.
Where FastMCP provides its own API for the same thing, it's worth switching over rather than importing the protocol type:

View file

@ -228,6 +228,9 @@ if __name__ == "__main__":
| `token_verifier=`, `auth_server_provider=`, `auth=AuthSettings(...)` | a single `auth=` provider |
| `cache_hints={...}` | `cache_ttl=`, `cache_scope=` |
| `extensions=[...]` | `mcp.add_extension(...)` |
| `middleware=[ServerMiddleware, ...]` | `middleware=[Middleware, ...]` — same keyword, different class |
`middleware=` is the row most likely to be mistaken for a rename. Both constructors take a `middleware=` sequence, but an `MCPServer` wants the SDK's `ServerMiddleware` — one hook wrapping every raw JSON-RPC message — while FastMCP wants its own `Middleware`, which adds typed per-operation hooks (`on_call_tool`, `on_list_tools`, and the rest) on top of the same message-level pass. Keeping the keyword and swapping the base class is the migration; see [Middleware](/servers/middleware).
Authentication is the largest of these, and it consolidates rather than moves. `MCPServer` exposes the SDK's raw auth plumbing — a token verifier, an authorization-server provider, and an `AuthSettings` object, configured separately. FastMCP takes one `auth=` provider that carries the whole configuration, and ships providers for the common cases: `JWTVerifier` for validating tokens you already issue, `RemoteAuthProvider` for delegating to an external authorization server, `OAuthProxy` for wrapping a provider that lacks Dynamic Client Registration, and named providers for GitHub, Google, Auth0, Keycloak, WorkOS, and others. See [Authentication](/servers/auth/authentication).

View file

@ -13,7 +13,7 @@ FastMCP 4 is in **beta**. Pin an exact version and expect sharp edges. See [Inst
## Built on the MCP Python SDK v2
The defining change in FastMCP 4 is the one you mostly can't see. The MCP Python SDK v2 rewrote the protocol layer end to end: it split the protocol types into a standalone `mcp_types` package, renamed every wire field from camelCase to snake_case, replaced the server's request-handling model, and made server-side middleware and multi-era serving first-class. FastMCP absorbs nearly all of it — your reads stay working through a compatibility bridge, and the handful of changes left in your code are mechanical.
The defining change in FastMCP 4 is the one you mostly can't see. The MCP Python SDK v2 rewrote the protocol layer end to end: it moved the protocol types into a standalone `mcp_types` package that stays importable as `mcp.types`, renamed every model field from camelCase to snake_case in Python, replaced the server's request-handling model, and made server-side middleware and multi-era serving first-class. FastMCP absorbs nearly all of it — your reads stay working through a compatibility bridge, and the handful of changes left in your code are mechanical.
The major version is the signal. Even where your surface is unchanged, the behavior underneath is substantially different, and bumping to 4.0 is how we tell you that plainly rather than slipping a new engine in under a patch release.
@ -49,7 +49,7 @@ When a client offers autocomplete for a prompt argument or a resource-template p
```python
from fastmcp import FastMCP
from mcp_types import PromptReference
from mcp.types import PromptReference
mcp = FastMCP("Docs")

View file

@ -95,7 +95,7 @@ The connector must be explicitly enabled in each chat session through Developer
Use `annotations=ToolAnnotations(readOnlyHint=True)` to skip confirmation prompts for read-only tools:
```python
from mcp_types import ToolAnnotations
from mcp.types import ToolAnnotations
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
def get_status() -> str:

View file

@ -8,7 +8,7 @@ icon: circle-question
Most servers run untouched. The defining change in FastMCP 4 is its engine — the MCP Python SDK v2 — and FastMCP absorbs nearly all of it for you, including the wire-wide rename from camelCase to snake_case, which is bridged so your existing reads keep working.
Most of what does reach your code fails loudly at import or call time, and the fix is mechanical: your own `from mcp.types import X` becomes `from mcp_types import X`, `McpError(ErrorData(...))` becomes `McpError(code=..., message=...)`, custom `httpx` clients handed to a transport become `httpx2`, and `ctx.sample()` and `ctx.list_roots()` are gone.
Most of what does reach your code fails loudly at import or call time, and the fix is mechanical: `McpError(ErrorData(...))` becomes `McpError(code=..., message=...)`, custom `httpx` clients handed to a transport become `httpx2`, and `ctx.sample()` and `ctx.list_roots()` are gone.
One change is silent, so go looking for it: an `except httpx.ConnectError:` around a FastMCP call still imports and still type-checks, because `httpx` usually remains installed through some other dependency — but FastMCP now raises the `httpx2` exception, so the handler simply stops matching and your fallback quietly never runs. Grep for `except httpx.` and move those to `httpx2`. [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) covers each one and ends with a checklist.

View file

@ -19,7 +19,7 @@ A server has a single completion handler, registered with the `@mcp.completion`
```python
from fastmcp import FastMCP
from mcp_types import PromptReference
from mcp.types import PromptReference
mcp = FastMCP("Completion Server")
@ -56,7 +56,7 @@ The same handler answers completion for resource template parameters. A `Resourc
```python
from fastmcp import FastMCP
from mcp_types import ResourceTemplateReference
from mcp.types import ResourceTemplateReference
mcp = FastMCP("Completion Server")
@ -84,7 +84,7 @@ Completions often depend on values the user has already entered. A repository su
```python
from fastmcp import FastMCP
from mcp_types import ResourceTemplateReference
from mcp.types import ResourceTemplateReference
mcp = FastMCP("Completion Server")
@ -122,7 +122,7 @@ The MCP protocol caps a single response at 100 values. When more candidates exis
```python
from fastmcp import FastMCP
from mcp_types import Completion, PromptReference
from mcp.types import Completion, PromptReference
mcp = FastMCP("Completion Server")
@ -158,7 +158,7 @@ A completion handler may be sync or async, and it can reach the active request t
```python
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_context
from mcp_types import PromptReference
from mcp.types import PromptReference
mcp = FastMCP("Completion Server")

View file

@ -271,7 +271,7 @@ Tools can customize which components are visible to their current session using
FastMCP automatically sends list change notifications when components (such as tools, resources, or prompts) are added, removed, enabled, or disabled. In rare cases where you need to manually trigger these notifications, you can use the context's notification methods:
```python
import mcp_types
import mcp.types as mcp_types
@mcp.tool
async def custom_tool_management(ctx: Context) -> str:

View file

@ -634,7 +634,7 @@ The following tool books a flight across three rounds: it asks for a destination
```python
from fastmcp import FastMCP, Context
from mcp_types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams
from mcp.types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams
mcp = FastMCP("Booking Server")
@ -771,7 +771,7 @@ This prompt gathers the context it needs before rendering:
```python
from fastmcp import FastMCP, Context
from mcp_types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams
from mcp.types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams
mcp = FastMCP("Reporting Server")

View file

@ -15,7 +15,7 @@ Icons provide visual representations for your MCP servers and components, helpin
Icons use the standard MCP Icon type from the MCP protocol specification. Each icon specifies a source URL or data URI, and optionally includes MIME type, size, and theme information.
```python
from mcp_types import Icon
from mcp.types import Icon
icon = Icon(
src="https://example.com/icon.png",
@ -37,7 +37,7 @@ Add icons and a website URL to your server for display in client applications. M
```python
from fastmcp import FastMCP
from mcp_types import Icon
from mcp.types import Icon
mcp = FastMCP(
name="WeatherService",
@ -66,7 +66,7 @@ Icons can be added to individual tools, resources, resource templates, and promp
### Tool Icons
```python
from mcp_types import Icon
from mcp.types import Icon
@mcp.tool(
icons=[Icon(src="https://example.com/calculator-icon.png")]
@ -121,7 +121,7 @@ Supply two icons with complementary `theme` values and the client picks the one
```python
from fastmcp import FastMCP
from mcp_types import Icon
from mcp.types import Icon
mcp = FastMCP(
name="WeatherService",
@ -135,7 +135,7 @@ mcp = FastMCP(
The same field works on tools, resources, resource templates, and prompts:
```python
from mcp_types import Icon
from mcp.types import Icon
@mcp.tool(
icons=[
@ -155,7 +155,7 @@ Omitting `theme` means the icon is assumed suitable for any theme. That's the ri
For small icons or when you want to embed the icon directly without external dependencies, use data URIs. This approach eliminates the need for hosting and ensures the icon is always available.
```python
from mcp_types import Icon
from mcp.types import Icon
from fastmcp.utilities.types import Image
# SVG icon as data URI
@ -175,7 +175,7 @@ def my_tool() -> str:
FastMCP provides the `Image` utility class to convert local image files into data URIs.
```python
from mcp_types import Icon
from mcp.types import Icon
from fastmcp.utilities.types import Image
# Generate a data URI from a local image file

View file

@ -53,7 +53,7 @@ A tool asks for a completion by returning an `InputRequiredResult` whose `input_
```python
from fastmcp import Context, FastMCP
from mcp_types import (
from mcp.types import (
CreateMessageRequest,
CreateMessageRequestParams,
CreateMessageResult,

View file

@ -225,7 +225,7 @@ A tool can ask the client a question partway through — the same [guard pattern
```python
from fastmcp import Context, FastMCP
from fastmcp_tasks import TasksExtension
import mcp_types
import mcp.types as mcp_types
mcp = FastMCP("MyServer")
mcp.add_extension(TasksExtension())

View file

@ -723,7 +723,7 @@ For complete control over tool responses, return a `ToolResult` object. This giv
```python
from fastmcp.tools import ToolResult
from mcp_types import TextContent
from mcp.types import TextContent
@mcp.tool
def advanced_tool() -> ToolResult:
@ -944,7 +944,7 @@ Annotations serve several purposes in client applications:
You can add annotations to a tool using the `annotations` parameter in the `@mcp.tool` decorator. FastMCP accepts either a plain dict or `ToolAnnotations`; the examples below use `ToolAnnotations` for consistency and stronger editor/type support.
```python
from mcp_types import ToolAnnotations
from mcp.types import ToolAnnotations
@mcp.tool(
annotations=ToolAnnotations(
@ -983,7 +983,7 @@ Mark a tool as read-only when it retrieves data, performs calculations, or check
```python
from fastmcp import FastMCP
from mcp_types import ToolAnnotations
from mcp.types import ToolAnnotations
mcp = FastMCP("Data Server")

View file

@ -245,7 +245,7 @@ class ClientCredentialsOAuthProvider(_SDKClientCredentialsOAuthProvider):
client_id=self._client_id,
client_secret=self._client_secret,
token_endpoint_auth_method=self._token_endpoint_auth_method,
scopes=self._scopes,
scope=self._scopes,
)
self._bound = True
@ -371,7 +371,7 @@ class PrivateKeyJWTOAuthProvider(_SDKPrivateKeyJWTOAuthProvider):
),
client_id=self._client_id,
assertion_provider=self._assertion_provider,
scopes=self._scopes,
scope=self._scopes,
)
self._bound = True

View file

@ -344,7 +344,6 @@ class OAuth(OAuthClientProvider):
storage=self.token_storage_adapter,
redirect_handler=self.redirect_handler,
callback_handler=self.callback_handler,
timeout=self._callback_timeout,
client_metadata_url=self._client_metadata_url,
)

View file

@ -232,6 +232,22 @@ class ClientSessionState:
initialize_result: mcp_types.InitializeResult | None = None
def _connection_failure(exception: BaseException) -> BaseException:
"""Present a dead session the same way wherever it is noticed.
A failed session surfaces from two places: `_connect`, when the connection
never comes up, and `_await_with_session_monitoring`, when the session task
dies while a request is in flight. Which one wins is a matter of timing, so
both report the failure identically otherwise the same dead backend
reaches callers as either a `RuntimeError` naming the connection or the raw
transport error, depending on the race. Types callers reasonably branch on
are passed through untouched.
"""
if isinstance(exception, httpx2.HTTPStatusError | MCPError):
return exception
return RuntimeError(f"Client failed to connect: {exception}")
@dataclass
class CallToolResult:
"""Parsed result from a tool call.
@ -537,6 +553,14 @@ class Client(
"sampling_callback": None,
"list_roots_callback": None,
"logging_callback": create_log_callback(log_handler),
# Log delivery is opt-in per request on the modern protocol: the
# session stamps this level into each request's `_meta`, and a
# server sends nothing without it. FastMCP's contract is that a
# client receives everything unless it narrows the level itself, so
# request the most permissive level and let the server's own
# `client_log_level` (and legacy `set_logging_level`) do the
# filtering. Inert on the handshake eras, which have no such opt-in.
"log_level": "debug",
"message_handler": effective_message_handler,
"read_timeout_seconds": read_timeout_seconds,
"client_info": client_info,
@ -989,18 +1013,23 @@ class Client(
raise
if self._session_state.session_task.done():
exception = self._session_state.session_task.exception()
session_task = self._session_state.session_task
if not session_task.done() and self._session_state.session is None:
# `_session_runner` sets `ready_event` from its `finally`,
# so a failed connect can wake the wait above before the
# task is marked done. No session means the connect failed,
# so let the task settle and report the failure here rather
# than letting the raw transport error escape on the next
# request.
await asyncio.wait([session_task], timeout=3)
if session_task.done():
exception = session_task.exception()
if exception is None:
raise RuntimeError(
"Session task completed without exception but connection failed"
)
# Preserve specific exception types that clients may want to handle
if isinstance(exception, httpx2.HTTPStatusError | MCPError):
raise exception
raise RuntimeError(
f"Client failed to connect: {exception}"
) from exception
raise _connection_failure(exception) from exception
self._session_state.nesting_counter += 1

View file

@ -2,57 +2,32 @@ from typing import TypeAlias
import mcp_types
from mcp.client.session import MessageHandlerFnT
from mcp.shared.session import RequestResponder
Message: TypeAlias = (
RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult]
| mcp_types.ServerNotification
| Exception
)
Message: TypeAlias = mcp_types.ServerNotification | Exception
MessageHandlerT: TypeAlias = MessageHandlerFnT
class MessageHandler:
"""
This class is used to handle MCP messages sent to the client. It is used to handle all messages,
requests, notifications, and exceptions. Users can override any of the hooks
This class is used to handle MCP messages sent to the client: notifications
and transport-level exceptions. Users can override any of the hooks.
Server-initiated *requests* (ping, sampling, roots) never reach this
handler: the stable MCP SDK v2's `message_handler` contract only delivers
`ServerNotification | Exception`, so a request has no wire path here.
Those are answered through the `Client`'s dedicated callbacks instead —
`sampling_handler=`, `roots=`, and `elicitation_handler=`.
"""
async def __call__(
self,
message: RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult]
| mcp_types.ServerNotification
| Exception,
) -> None:
async def __call__(self, message: mcp_types.ServerNotification | Exception) -> None:
return await self.dispatch(message)
async def dispatch(self, message: Message) -> None:
# handle all messages
await self.on_message(message)
# SDK v2 delivers server-to-client requests wrapped in a
# RequestResponder (with the request unwrapped on `.request`) and
# notifications unwrapped (the monolith notification model itself, no
# `.root` wrapper). `ServerNotification`/`ServerRequest` are UnionTypes,
# so they can't appear in class match patterns — branch on the concrete
# models directly.
if isinstance(message, RequestResponder):
# handle all requests
# ty doesn't narrow the generic RequestResponder cleanly here.
await self.on_request(message) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
# handle specific requests
request = message.request
match request:
case mcp_types.PingRequest():
await self.on_ping(request)
case mcp_types.ListRootsRequest():
await self.on_list_roots(request)
case mcp_types.CreateMessageRequest():
await self.on_create_message(request)
elif isinstance(message, Exception):
if isinstance(message, Exception):
await self.on_exception(message)
else:
@ -79,20 +54,6 @@ class MessageHandler:
async def on_message(self, message: Message) -> None:
pass
async def on_request(
self, message: RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult]
) -> None:
pass
async def on_ping(self, message: mcp_types.PingRequest) -> None:
pass
async def on_list_roots(self, message: mcp_types.ListRootsRequest) -> None:
pass
async def on_create_message(self, message: mcp_types.CreateMessageRequest) -> None:
pass
async def on_notification(self, message: mcp_types.ServerNotification) -> None:
pass

View file

@ -1,6 +1,6 @@
from typing import TypeAlias
from mcp.shared.session import ProgressFnT
from mcp.shared.dispatcher import ProgressFnT
from fastmcp.utilities.logging import get_logger

View file

@ -29,6 +29,7 @@ class ClientSessionKwargs(TypedDict, total=False):
sampling_capabilities: mcp_types.SamplingCapability | None
list_roots_callback: ListRootsFnT | None
logging_callback: LoggingFnT | None
log_level: mcp_types.LoggingLevel | None
elicitation_callback: ElicitationFnT | None
message_handler: MessageHandlerFnT | None
client_info: mcp_types.Implementation | None

View file

@ -983,9 +983,18 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
# builds this object, so prefer the value the HTTP route recovered from
# the raw request body. Fall back to the object's own field for direct
# (non-HTTP) callers. Write it back so the DCR response echoes the type.
#
# The SDK splits the registration *request* model from the registered
# *client record*: `OAuthClientMetadata.application_type` defaults to
# "native", while `OAuthClientInformationFull.application_type` is
# `str | None` and defaults to None. Normalize the unset case back to
# "native" so a client that omits the field gets the RFC 7591 default
# recorded explicitly, on both the HTTP and direct-call paths.
pending_application_type = _pending_application_type.get()
if pending_application_type is not None:
client_info.application_type = pending_application_type
elif client_info.application_type is None:
client_info.application_type = "native"
application_type = client_info.application_type
if client_info.redirect_uris:
@ -1161,7 +1170,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
# Store transaction data for IdP callback processing
if client.client_id is None:
raise AuthorizeError(
error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type]
error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type
error_description="Client ID is required",
)
# Clients may omit `scope` entirely, in which case OAuth lets the
@ -1254,7 +1263,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
# Create authorization code object with PKCE challenge
if client.client_id is None:
raise AuthorizeError(
error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type]
error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type
error_description="Client ID is required",
)
return AuthorizationCode(

View file

@ -368,7 +368,7 @@ def matches_allowed_pattern(uri: str, pattern: str) -> bool:
def is_redirect_uri_allowed_for_application_type(
redirect_uri: str | AnyUrl,
application_type: str,
application_type: str | None,
) -> bool:
"""Check a redirect URI against RFC 7591 / SEP-837 `application_type` rules.
@ -399,7 +399,9 @@ def is_redirect_uri_allowed_for_application_type(
The MCP SDK defaults `application_type` to `"native"` because MCP clients
typically register loopback redirect URIs, so omitting the field preserves
the behavior clients relied on before this check existed.
the behavior clients relied on before this check existed. `None` which a
registered-client record carries when the field was never set is treated
the same way.
"""
uri_str = str(redirect_uri)

View file

@ -892,7 +892,15 @@ class Context:
"""
# v2: ServerNotification is a union of concrete notification models;
# ServerSession.send_notification takes an instance directly (no wrapper).
await self.session.send_notification(notification)
#
# Relate the notification to the in-flight request so it rides that
# request's own stream. A sessionless (2026-07-28) connection has no
# standing server→client channel, so an unrelated notification is
# dropped; the request's stream is the only way out. Session-based eras
# deliver it either way.
await self.session.send_notification(
notification, related_request_id=self.request_id
)
async def close_sse_stream(self) -> None:
"""Close the current response stream to trigger client reconnection.

View file

@ -31,7 +31,7 @@ from mcp_types import (
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic.networks import AnyUrl
from fastmcp.client.client import Client, SDKServer
from fastmcp.client.client import Client, SDKServer, _connection_failure
from fastmcp.client.elicitation import ElicitResult, create_elicitation_callback
from fastmcp.client.logging import LogMessage, create_log_callback
from fastmcp.client.roots import RootsList, create_roots_callback
@ -116,9 +116,17 @@ _PROXY_TRANSPORT_ERRORS: tuple[type[Exception], ...] = (
def _proxy_upstream_error(error: Exception) -> MCPError:
"""Report an unreachable backend the same way however the failure arrived.
Depending on where the dead connection is noticed, the proxy sees either
FastMCP's own `RuntimeError("Client failed to connect: ...")` or the raw
transport error underneath it. Both describe one thing the proxy could
not reach its upstream so both are presented identically rather than
leaking the race into the message the front client reads.
"""
return MCPError(
code=mcp_types.INTERNAL_ERROR,
message=str(error),
message=str(_connection_failure(error)),
)
@ -1311,10 +1319,20 @@ class FastMCPProxy(FastMCP):
try:
async with client:
result.instructions = client.session.instructions
except MCPError:
raise
except _PROXY_TRANSPORT_ERRORS as error:
raise _proxy_upstream_error(error) from error
except (MCPError, *_PROXY_TRANSPORT_ERRORS) as error:
# Instructions are optional metadata, so an unreachable backend
# must not fail negotiation itself. Failing here would surface
# as a confusing protocol error: the client's auto-negotiation
# reads any `server/discover` error as "not a modern server"
# and retries with the initialize handshake, which this
# modern-serving proxy then rejects — hiding the real cause.
# Answer without upstream instructions instead and let the
# backend failure surface on the first real operation, where
# the proxy reports it as an upstream connection error.
logger.debug(
"Could not read upstream instructions for server/discover: %r",
error,
)
return result
self._mcp_server.add_request_handler(

View file

@ -391,10 +391,12 @@ async def inspect_fastmcp_v1(mcp: SDKServer) -> FastMCPInfo:
# SDK v2's MCPServer (FastMCP 1.x) exposes name/instructions/version
# directly; the v1 `_mcp_server` low-level wrapper attribute is gone.
# It defaults `version` to an empty string rather than None, so report
# an unset version as absent instead of blank.
return FastMCPInfo(
name=mcp.name,
instructions=mcp.instructions,
version=mcp.version,
version=mcp.version or None,
website_url=server_website_url,
icons=server_icons,
fastmcp_version=fastmcp.__version__, # Version generating this manifest

View file

@ -4,7 +4,7 @@ dynamic = ["version", "optional-dependencies"]
description = "The dependency-slim FastMCP package."
authors = [{ name = "Jeremiah Lowin" }]
dependencies = [
"mcp-types==2.0.0b2",
"mcp-types>=2.0.0,<3.0.0",
"platformdirs>=4.0.0",
"pydantic[email]>=2.12.0",
"pydantic-settings>=2.0.0",
@ -82,7 +82,7 @@ mcp = [
# client auth) requires it, and all FastMCP-owned HTTP (server auth provider
# upstream calls, OpenAPI provider, version check, etc.) uses it too.
"httpx2>=2.5.0",
"mcp==2.0.0b2",
"mcp>=2.0.0,<3.0.0",
"opentelemetry-api>=1.28.0",
# starlette floor: transitive via mcp (which only requires >=0.27).
# Pin past CVE-2026-48710, which was patched in 1.0.1.

View file

@ -72,7 +72,11 @@ members = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"]
[tool.uv]
default-groups = ["dev"]
exclude-newer = "1 week"
exclude-newer-package = { fastmcp = false, fastmcp-slim = false, fastmcp-remote = false, prefab-ui = false, mcp = false, mcp-types = false, httpx2 = false, httpcore2 = false, truststore = false }
# The cooldown above refuses anything published in the last week. Exempt the
# first-party packages, whose fresh releases we install deliberately, and the
# MCP SDK, where a new major is the only version satisfying our floor and so
# has nothing older to fall back to.
exclude-newer-package = { fastmcp = false, fastmcp-slim = false, fastmcp-remote = false, prefab-ui = false, mcp = false, mcp-types = false }
[dependency-groups]
dev = [

View file

@ -18,6 +18,7 @@ from fastmcp.client.transports import (
FastMCPTransport,
)
from fastmcp.server.server import FastMCP
from tests.conftest import user_meta
async def test_list_tools(fastmcp_server):
@ -848,7 +849,7 @@ async def test_client_unwraps_result_using_meta():
result = await client.call_tool("list_tool", {})
assert result.structured_content == {"result": [1, 2, 3]}
assert result.data == [1, 2, 3]
assert result.meta == {"fastmcp": {"wrap_result": True}}
assert user_meta(result.meta) == {"fastmcp": {"wrap_result": True}}
async def test_client_does_not_unwrap_dict_result():
@ -864,7 +865,7 @@ async def test_client_does_not_unwrap_dict_result():
result = await client.call_tool("dict_tool", {})
assert result.structured_content == {"a": 1}
assert result.data == {"a": 1}
assert result.meta is None
assert user_meta(result.meta) is None
async def test_client_list_dict_return_type():

View file

@ -244,12 +244,11 @@ class TestNonConformantModernPeer:
class TestPinnedMode:
async def test_pinned_modern_adopts_without_probe(self, fastmcp_server):
"""Pinning the modern version adopts it directly; a synthesized
DiscoverResult leaves server_info empty."""
DiscoverResult carries no identity, so server_info is absent."""
async with Client(fastmcp_server, mode=LATEST_MODERN_VERSION) as client:
assert client.protocol_version == LATEST_MODERN_VERSION
assert client.initialize_result is None
assert client.server_info is not None
assert client.server_info.name == ""
assert client.server_info is None
assert client.instructions is None
async def test_pinned_modern_call_tool(self, fastmcp_server):

View file

@ -28,7 +28,7 @@ EXPECTED_FAILURES = CONFORMANCE_DIR / "expected-failures.yml"
HOST = "127.0.0.1"
#: Pinned version of `@modelcontextprotocol/conformance`. Bump deliberately.
CONFORMANCE_VERSION = "0.2.0-alpha.9"
CONFORMANCE_VERSION = "0.2.0-alpha.10"
def _get_free_port() -> int:

View file

@ -8,6 +8,7 @@ from pathlib import Path
from typing import Any
import pytest
from mcp_types import SERVER_INFO_META_KEY
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
@ -23,6 +24,21 @@ if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
def user_meta(meta: dict[str, Any] | None) -> dict[str, Any] | None:
"""Strip the SDK's `serverInfo` stamp from a result's `_meta`.
Every 2026-era result carries `io.modelcontextprotocol/serverInfo` (spec
#3002), stamped by the SDK runner rather than by the component that
produced the result. Tests asserting on the meta a tool or resource set
itself use this to ignore the stamp, and get `None` back when the stamp was
the only entry.
"""
if meta is None:
return None
remaining = {k: v for k, v in meta.items() if k != SERVER_INFO_META_KEY}
return remaining or None
def make_server_request_context(
*,
method: str = "tools/list",

View file

@ -5,6 +5,7 @@ from pydantic import AnyUrl, BaseModel
from fastmcp import Client, FastMCP
from fastmcp.resources import Resource, ResourceContent, ResourceResult
from fastmcp.resources.function_resource import FunctionResource
from tests.conftest import user_meta
class TestResourceValidation:
@ -323,7 +324,7 @@ class TestResourceMetaPropagation:
async with Client(mcp) as client:
result = await client.read_resource_mcp("test://with-meta")
assert result.meta == {"version": "2.0", "source": "test"}
assert user_meta(result.meta) == {"version": "2.0", "source": "test"}
async def test_resource_content_meta_received_by_client(self):
"""Meta set on ResourceContent is received by MCP client."""
@ -355,7 +356,7 @@ class TestResourceMetaPropagation:
async with Client(mcp) as client:
result = await client.read_resource_mcp("test://both-meta")
assert result.meta == {"result_key": "result_val"}
assert user_meta(result.meta) == {"result_key": "result_val"}
assert result.contents[0].meta == {"item_key": "item_val"}
async def test_json_native_return_preserves_component_meta(self):

View file

@ -35,6 +35,7 @@ from fastmcp.tools.tool_transform import (
)
from fastmcp.utilities.http import find_available_port
from fastmcp.utilities.tests import run_server_async
from tests.conftest import user_meta
USERS = [
{"id": "1", "name": "Alice", "active": True},
@ -888,7 +889,12 @@ class TestPrompts:
result = await client.get_prompt("welcome", {"name": "Alice"})
async with Client(proxy_server) as client:
proxy_result = await client.get_prompt("welcome", {"name": "Alice"})
assert proxy_result == result
# Each server stamps its own `serverInfo` into `_meta` (spec #3002), so
# the proxy's stamp naturally differs from the origin's. Compare the
# relayed payload.
assert proxy_result.model_copy(
update={"meta": user_meta(proxy_result.meta)}
) == result.model_copy(update={"meta": user_meta(result.meta)})
async def test_render_prompt_calls_prompt(self, proxy_server):
async with Client(proxy_server) as client:
@ -942,8 +948,11 @@ class TestPrompts:
async with Client(proxy_server) as client:
proxy_result = await client.get_prompt("image_prompt")
# The proxy result should match the original exactly
assert proxy_result == result
# The proxy relays the original payload; only the per-server
# `serverInfo` `_meta` stamp differs.
assert proxy_result.model_copy(
update={"meta": user_meta(proxy_result.meta)}
) == result.model_copy(update={"meta": user_meta(result.meta)})
# Verify the image content is preserved as ImageContent, not JSON text
assert isinstance(proxy_result.messages[1].content, mcp_types.ImageContent)
assert proxy_result.messages[1].content.data == "iVBORw0KGgoAAAANSUhEUg=="

View file

@ -172,6 +172,7 @@ async def test_legacy_uses_initialize_handshake(dual_era_server):
"""
async with SDKClient(_server(dual_era_server), mode="legacy") as client:
assert client.protocol_version == "2025-11-25"
assert client.server_info is not None
assert client.server_info.name == "dual-era"
@ -182,14 +183,16 @@ async def test_auto_negotiates_modern_via_discover(dual_era_server):
async with SDKClient(_server(dual_era_server), mode="auto") as client:
assert client.protocol_version == "2026-07-28"
# server/discover carries identity, unlike the synthesized pin below.
assert client.server_info is not None
assert client.server_info.name == "dual-era"
assert client.server_capabilities is not None
async def test_pinned_modern_adopts_without_probe(dual_era_server):
"""Pinning `mode='2026-07-28'` adopts the version directly. With no
`prior_discover`, the SDK synthesizes a minimal DiscoverResult, so
server_info is empty even though the protocol version is modern.
`prior_discover`, the SDK synthesizes a minimal DiscoverResult that carries
no identity, so server_info is absent even though the protocol version is
modern.
Characterization of the SDK's synthesize-discover path (mcp.client.client
`_synthesize_discover`): a pin without prior_discover trades identity for
@ -197,7 +200,7 @@ async def test_pinned_modern_adopts_without_probe(dual_era_server):
"""
async with SDKClient(_server(dual_era_server), mode="2026-07-28") as client:
assert client.protocol_version == "2026-07-28"
assert client.server_info.name == ""
assert client.server_info is None
# ---------------------------------------------------------------------------

View file

@ -211,7 +211,6 @@ REMOVED_MODULES = [
"fastmcp.experimental.utilities.openapi", # -> fastmcp.utilities.openapi
"fastmcp.server.apps", # -> fastmcp.apps
"fastmcp.server.app", # -> fastmcp.apps / fastmcp
"mcp.types", # -> mcp_types
# The pre-rename component modules. `tool.py`/`resource.py`/`prompt.py` are
# now `base.py`; import the types from the package itself (`from
# fastmcp.tools import Tool`) rather than naming the private module.
@ -244,6 +243,20 @@ class TestRemovedSurfacesFailLoudly:
with pytest.raises(ModuleNotFoundError):
importlib.import_module(module_path)
def test_mcp_types_import_path_restored_by_stable_sdk(self):
# The MCP Python SDK beta (2.0.0b2, what v4 was built against) dropped
# `mcp.types` entirely, so `from mcp.types import X` was documented as a
# hard break requiring a switch to `from mcp_types import X`. The stable
# SDK release (2.0.0) reintroduced `mcp.types` as a deliberate mirror of
# `mcp_types` — same objects, same snake_case fields, not a v1 API
# restoration — specifically so old import paths keep working. Both
# spellings resolve to the identical class.
import mcp.types
import mcp_types
assert mcp.types.Tool is mcp_types.Tool
assert set(mcp.types.__all__) == set(mcp_types.__all__)
@pytest.mark.parametrize(
"module_path, name",
REMOVED_NAMES,

View file

@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field
from fastmcp import Client, FastMCP
from fastmcp.tools.base import Tool, ToolResult
from tests.conftest import user_meta
class TestToolResultCasting:
@ -39,7 +40,7 @@ class TestToolResultCasting:
assert result.content[0].type == "text"
assert result.content[0].text == "test data"
assert result.structured_content is None
assert result.meta is None
assert user_meta(result.meta) is None
async def test_neither_unstructured_or_structured_content(self, client):
from fastmcp.exceptions import ToolError
@ -56,7 +57,7 @@ class TestToolResultCasting:
assert result.content[0].type == "text"
assert result.content[0].text == "test data"
assert result.structured_content == {"data_type": "test"}
assert result.meta is None
assert user_meta(result.meta) is None
async def test_structured_unstructured_and_meta_content(self, client):
result = await client.call_tool(
@ -71,7 +72,7 @@ class TestToolResultCasting:
assert result.content[0].type == "text"
assert result.content[0].text == "test data"
assert result.structured_content == {"data_type": "test"}
assert result.meta == {"some": "metadata"}
assert user_meta(result.meta) == {"some": "metadata"}
class TestToolResultIsError:
@ -153,7 +154,12 @@ class TestToolResultIsError:
async with Client(mcp) as client:
result = await client.call_tool_mcp("failing", {})
assert result.model_dump(by_alias=True) == raw_result.model_dump(by_alias=True)
received = result.model_dump(by_alias=True)
# The SDK stamps `serverInfo` into every 2026-era result's `_meta`
# (spec #3002). Strip it so the assertion covers the protocol fields
# the tool itself set, which is what FastMCP is responsible for.
received["_meta"] = user_meta(received["_meta"])
assert received == raw_result.model_dump(by_alias=True)
class TestUnionReturnTypes:

2296
uv.lock generated

File diff suppressed because it is too large Load diff