mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Move to the stable MCP Python SDK 2.0.0 (#4655)
This commit is contained in:
parent
81b1e818e5
commit
8b76710e66
41 changed files with 1505 additions and 1234 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -412,7 +412,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")
|
||||
|
||||
|
|
@ -549,7 +549,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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
@ -530,6 +546,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,
|
||||
|
|
@ -982,18 +1006,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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=="
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue