mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
Merge remote-tracking branch 'origin/main' into claude/sdk-resolve-annotation-1d4770
# Conflicts: # docs/servers/elicitation.mdx
This commit is contained in:
commit
3ad16cd578
39 changed files with 3663 additions and 499 deletions
|
|
@ -100,9 +100,10 @@ uv pip install fastmcp
|
|||
For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation).
|
||||
|
||||
**Upgrading?** We have guides for:
|
||||
- [Upgrading from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2)
|
||||
- [Upgrading from the MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk)
|
||||
- [Upgrading from the low-level SDK](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk)
|
||||
- [Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3)
|
||||
- [Upgrading from FastMCP 2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2)
|
||||
- [Upgrading from MCP SDK v1](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2)
|
||||
- [Upgrading from the low-level SDK v1](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2)
|
||||
|
||||
> [!NOTE]
|
||||
> If `import fastmcp` fails right after a `pip` upgrade from FastMCP 3.2 or earlier, run `pip install --force-reinstall fastmcp`. See [Troubleshooting](https://gofastmcp.com/getting-started/installation#troubleshooting) for why this happens (`uv` is unaffected).
|
||||
|
|
|
|||
|
|
@ -61,17 +61,43 @@ The final tool result has two parts: `content` (a list of `TextContent` blocks f
|
|||
|
||||
## Tool call routing
|
||||
|
||||
Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters) before resolving by name. App UI calls need a different path.
|
||||
A tool has two things that behave very differently. Its **name** is unstable by design — namespace transforms rename it, so `save_contact` becomes `contacts_save_contact` in one composition and something else in another. Its **identity** is a hash of the app name and the registered tool name, written once at registration and never changed.
|
||||
|
||||
### The hashed lookup bypass
|
||||
A UI is serialized during the entry tool's call, deep inside whatever composition the server happens to have, so it cannot know what its backend tools will be called by the time the payload reaches a host.
|
||||
|
||||
Backend tools are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — while the renderer needs a stable way to call the original backend.
|
||||
### Late-bound tool names
|
||||
|
||||
Hashed lookup solves both problems. FastMCP first tries normal tool resolution. If no visible tool matches and the requested name looks like `<hash>_<local_name>`, FastMCP calls `get_tool_by_hash(hash, local_name)`. That lookup walks the provider tree directly, skipping transforms. It finds an app-visible tool by its original registered name and verifies that its stored `meta["fastmcp"]["_tool_hash"]` matches the requested hash.
|
||||
The payload leaves the app addressed by identity, and every FastMCP server rewrites those references on the way out to whatever it lists that tool as. Servers unwind innermost-first, so the outermost server rewrites last — and its names are the only ones a client can actually invoke.
|
||||
|
||||
That's why `CallTool(save_contact)` keeps working when the server is mounted under a namespace. The renderer sends a deterministic hashed backend name; the server uses `get_tool_by_hash` to find the original tool without transforms in the way.
|
||||
Rewriting a name in place would destroy the identity for the next layer up, so the payload carries a name-to-identity map under `_meta.fastmcp.toolNames`. Each layer resolves through the map and updates it. The action objects keep the exact shape `prefab_ui` defines: only the value of `tool` changes, and only ever to another valid tool name.
|
||||
|
||||
Authorization still applies. The hashed bypass skips name and visibility transforms, but auth checks still run against the tool's `auth` config before execution.
|
||||
The result is that a renderer receives names that exist in the listing the host is looking at. Under three layers of namespacing the button calls `c_b_a_save`; behind a gateway it calls whatever the gateway lists. No intermediary has to understand a FastMCP-specific convention.
|
||||
|
||||
A reference this server cannot resolve is left alone rather than corrupted. This is what keeps apps working behind [tool search](/servers/transforms/tool-search) and code mode, which replace `tools/list` with a handful of synthetic tools: there is no better name to bind to, so the reference stays identity-addressed and the fallback below carries it.
|
||||
|
||||
### One copy of an app per server
|
||||
|
||||
**An app name must be unique within a server.** Composing the same app twice breaks its UI, and no namespace or mount arrangement makes it work.
|
||||
|
||||
The reason is structural. Identity is derived from the app name and the tool's registered name, and deliberately nothing else — that is what makes it survive renaming. Two copies of one app therefore produce two tools claiming a single identity, and no fact anywhere in the listing says which copy a given button belongs to. The information needed to choose was never recorded.
|
||||
|
||||
FastMCP declines to bind rather than picking a copy, so buttons stop working instead of quietly invoking the wrong tenant's tool. Expect a message naming the cause:
|
||||
|
||||
```
|
||||
Ambiguous app tool 'save': 2 components share the identity '10c0803009ff'.
|
||||
The same app is composed more than once, so this call cannot be routed to a
|
||||
single tool.
|
||||
```
|
||||
|
||||
Give each copy its own app name. Two tenants running the same product want `FastMCPApp("contacts-acme")` and `FastMCPApp("contacts-globex")` — not two instances of `FastMCPApp("contacts")` under different namespaces, since namespaces rename tools and identity is immune to renaming by design.
|
||||
|
||||
### The hashed lookup fallback
|
||||
|
||||
The identity-addressed form `<hash>_<local_name>` remains callable. FastMCP first tries normal tool resolution; if no tool matches and the name has that shape, it calls `get_tool_by_hash(hash, local_name)`, which walks the provider tree directly, skipping transforms.
|
||||
|
||||
When one identity is claimed by more than one tool — which happens when the same app is composed into two branches — the call is refused rather than resolved, since picking either one would silently route into the wrong branch.
|
||||
|
||||
Authorization still applies. The hashed path skips name and visibility transforms, but auth checks still run against the tool's `auth` config before execution.
|
||||
|
||||
### Provider delegation
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,11 @@ A fair question. Any [Interactive Tool](/apps/prefab) can call a server tool —
|
|||
- What happens to `CallTool("add_note")` when you mount this server under a namespace and the tool becomes `notes_add_note`?
|
||||
- How do you keep it all wired correctly as you compose servers?
|
||||
|
||||
`FastMCPApp` owns these concerns. Entry points register as model-visible. Backend tools register as UI-only by default. Backend tools get globally stable identifiers that survive namespacing, and `CallTool` accepts function references, so references stay valid when you compose servers.
|
||||
`FastMCPApp` owns these concerns. Entry points register as model-visible, backend tools register as UI-only, and hosts act on those declarations to decide what the model sees.
|
||||
|
||||
Composition is handled by never writing the name down. `CallTool` takes a function reference, and FastMCP resolves it when the UI is serialized — to whatever that tool is actually called by then. Mount the server under a namespace and the button calls `notes_add_note`; put a gateway in front and it calls whatever the gateway lists. Since you never wrote a name, renaming cannot break it. [The architecture page](/apps/architecture) covers how that resolution works.
|
||||
|
||||
The one rule that comes with this: **an app name must be unique within a server.** Composing the same app twice breaks its UI — two copies of `FastMCPApp("notes")` are indistinguishable no matter what namespaces you mount them under, so FastMCP declines to bind rather than picking one. Name apps for what they serve: `FastMCPApp("notes-acme")` and `FastMCPApp("notes-globex")`. [The architecture page](/apps/architecture) explains why identity works this way.
|
||||
|
||||
The rest of this page covers each piece in turn.
|
||||
|
||||
|
|
|
|||
|
|
@ -70,11 +70,15 @@ def my_tool() -> str:
|
|||
The `visibility` field controls where a tool appears:
|
||||
|
||||
- `["model"]` — visible to the LLM (the default behavior)
|
||||
- `["app"]` — only callable from within the app UI, hidden from the LLM
|
||||
- `["app"]` — callable from within the app UI, kept out of the LLM's tool list
|
||||
- `["model", "app"]` — both
|
||||
|
||||
This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions.
|
||||
|
||||
Visibility is a declaration, and on `tools/list` the host does the filtering — the division the MCP Apps specification defines. Every tool is advertised carrying its `visibility` metadata, which is also what lets a proxy or gateway forward it: an intermediary can only route to a tool it can see.
|
||||
|
||||
That division assumes a host stands between the server and the model. Where one doesn't, FastMCP applies the declaration itself. [Tool search](/servers/transforms/tool-search) and code mode reach the model as ordinary tool output rather than as an advertised listing, and their call-tool proxies execute a name the model supplies — nothing downstream can filter either, so app-only tools are excluded from both. The app's own UI still reaches its backends, because a UI calling by identity is not the model.
|
||||
|
||||
```python
|
||||
@mcp.tool(
|
||||
app=AppConfig(
|
||||
|
|
|
|||
|
|
@ -358,10 +358,12 @@
|
|||
"group": "Upgrading",
|
||||
"icon": "up",
|
||||
"pages": [
|
||||
"getting-started/upgrading/from-fastmcp-2",
|
||||
"getting-started/upgrading/from-fastmcp-3",
|
||||
"getting-started/upgrading/from-mcp-sdk",
|
||||
"getting-started/upgrading/from-low-level-sdk"
|
||||
"getting-started/upgrading/from-fastmcp-2",
|
||||
"getting-started/upgrading/from-mcp-sdk-v1",
|
||||
"getting-started/upgrading/from-mcp-sdk-v2",
|
||||
"getting-started/upgrading/from-low-level-sdk-v1",
|
||||
"getting-started/upgrading/from-low-level-sdk-v2"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -509,13 +511,21 @@
|
|||
"source": "/development/upgrade-guide"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-mcp-sdk",
|
||||
"destination": "/getting-started/upgrading/from-mcp-sdk-v1",
|
||||
"source": "/getting-started/upgrading-from-sdk"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-low-level-sdk",
|
||||
"destination": "/getting-started/upgrading/from-mcp-sdk-v1",
|
||||
"source": "/getting-started/upgrading/from-mcp-sdk"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-low-level-sdk-v1",
|
||||
"source": "/getting-started/low-level-sdk"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-low-level-sdk-v1",
|
||||
"source": "/getting-started/upgrading/from-low-level-sdk"
|
||||
},
|
||||
{
|
||||
"destination": "/getting-started/upgrading/from-fastmcp-3",
|
||||
"source": "/getting-started/upgrading/to-mcp-sdk-v2"
|
||||
|
|
|
|||
|
|
@ -68,13 +68,17 @@ See the [Upgrade Guide](/getting-started/upgrading/from-fastmcp-2) for a complet
|
|||
|
||||
### From the MCP SDK
|
||||
|
||||
#### From FastMCP 1.0
|
||||
Which guide you want depends on which `mcp` version you're on and which of its two server APIs you used.
|
||||
|
||||
If you're using FastMCP 1.0 via the `mcp` package (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward — for most servers, it's a single import change. See the [full upgrade guide](/getting-started/upgrading/from-mcp-sdk) for details.
|
||||
#### From the high-level server
|
||||
|
||||
#### From the Low-Level Server API
|
||||
If you're using FastMCP 1.0 via SDK v1 (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward — for most servers it's a single import change. See [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1), which also explains why that route is usually easier than moving to MCP SDK v2.
|
||||
|
||||
If you built your server directly on the `mcp` package's `Server` class — with `list_tools()`/`call_tool()` handlers and hand-written JSON Schema — see the [migration guide](/getting-started/upgrading/from-low-level-sdk) for a full walkthrough.
|
||||
If you already moved to SDK v2 and write against `MCPServer`, see [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2) — that migration is mostly renaming.
|
||||
|
||||
#### From the low-level server
|
||||
|
||||
If you built your server directly on the `mcp` package's `Server` class, the guide you want depends on how its handlers are registered. Decorators like `@server.list_tools()` mean SDK v1 — see [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1). Handlers passed to the constructor as `on_list_tools=` mean SDK v2 — see [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
---
|
||||
title: Upgrading from FastMCP 2
|
||||
sidebarTitle: "From FastMCP 2"
|
||||
description: Migration instructions for upgrading between FastMCP versions
|
||||
description: What changed in FastMCP 3 for servers written against FastMCP 2
|
||||
icon: up
|
||||
---
|
||||
|
||||
This guide covers breaking changes and migration steps when upgrading FastMCP.
|
||||
This guide covers the breaking changes a FastMCP 2 server meets on its way to FastMCP 3, newest release first.
|
||||
|
||||
<Note>
|
||||
**Going all the way to FastMCP 4?** You need this page and [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3), in that order. The two describe different transitions: this one covers the v3 API changes, while the FastMCP 3 guide covers the MCP Python SDK v2 rebuild underneath v4. Where a v3 deprecation was later removed outright, this page marks it **Removed in v4**.
|
||||
</Note>
|
||||
|
||||
## v3.0.0
|
||||
|
||||
|
|
@ -101,7 +105,7 @@ For each issue found, show the original line, explain why it breaks, and provide
|
|||
|
||||
In v2, you could configure transport settings directly in the `FastMCP()` constructor. In v3, `FastMCP()` is purely about your server's identity and behavior — transport configuration happens when you actually start serving. Passing any of the old kwargs now raises `TypeError` with a migration hint.
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
mcp = FastMCP("server", host="0.0.0.0", port=8080)
|
||||
mcp.run()
|
||||
|
|
@ -140,7 +144,7 @@ Keeping `DiskStore` requires `pip install 'py-key-value-aio[disk]'`, which re-in
|
|||
|
||||
In v2, you could enable or disable individual components by calling methods on the component object itself. In v3, visibility is controlled through the server (or provider), which lets you target components by name, tag, or type without needing a reference to the object:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
tool = await server.get_tool("my_tool")
|
||||
tool.disable()
|
||||
|
|
@ -155,7 +159,7 @@ Calling `.enable()` or `.disable()` on a component object now raises `NotImpleme
|
|||
|
||||
The `get_tools()`, `get_resources()`, `get_prompts()`, and `get_resource_templates()` methods have been renamed to `list_tools()`, `list_resources()`, `list_prompts()`, and `list_resource_templates()`. More importantly, they now return lists instead of dicts — so code that indexes by name needs to change:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
tools = await server.get_tools()
|
||||
tool = tools["my_tool"]
|
||||
|
|
@ -169,7 +173,7 @@ tool = next((t for t in tools if t.name == "my_tool"), None)
|
|||
|
||||
Prompt functions now use FastMCP's `Message` class instead of `mcp.types.PromptMessage`. The new class is simpler — it accepts a plain string and defaults to `role="user"`, so most prompts become one-liners:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
from mcp.types import PromptMessage, TextContent
|
||||
|
||||
|
|
@ -187,7 +191,7 @@ def my_prompt() -> Message:
|
|||
|
||||
If your prompt functions return raw dicts with `role` and `content` keys, those also need to change. v2 silently coerced dicts into prompt messages, but v3 requires typed `Message` objects (or plain strings for single user messages):
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before (v2 accepted this)
|
||||
@mcp.prompt
|
||||
def my_prompt():
|
||||
|
|
@ -211,7 +215,7 @@ def my_prompt() -> list[Message]:
|
|||
|
||||
`ctx.set_state()` and `ctx.get_state()` are now async because state in v3 is session-scoped and backed by a pluggable storage backend (rather than a simple dict). This means state persists across multiple tool calls within the same session:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
ctx.set_state("key", "value")
|
||||
value = ctx.get_state("key")
|
||||
|
|
@ -223,7 +227,7 @@ value = await ctx.get_state("key")
|
|||
|
||||
State values must also be JSON-serializable by default (dicts, lists, strings, numbers, etc.). If you need to store non-serializable values like an HTTP client, pass `serializable=False` — these values are request-scoped and only available during the current tool call:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
await ctx.set_state("client", my_http_client, serializable=False)
|
||||
```
|
||||
|
||||
|
|
@ -245,7 +249,7 @@ parent.mount(child, namespace="child")
|
|||
|
||||
In v2, auth providers like `GitHubProvider` could auto-load configuration from environment variables with a `FASTMCP_SERVER_AUTH_*` prefix. This magic has been removed — pass values explicitly:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before (v2) — client_id and client_secret loaded automatically
|
||||
# from FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID, etc.
|
||||
auth = GitHubProvider()
|
||||
|
|
@ -278,7 +282,7 @@ transport = StreamableHttpTransport("http://localhost:8000/mcp")
|
|||
|
||||
`OpenAPIProvider` no longer accepts a `timeout` parameter. Configure timeout on the httpx2 client directly. The `client` parameter is also now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
provider = OpenAPIProvider(spec, client, timeout=60)
|
||||
|
||||
|
|
@ -291,7 +295,7 @@ provider = OpenAPIProvider(spec, client)
|
|||
|
||||
The FastMCP metadata key in component `meta` dicts changed from `_fastmcp` to `fastmcp`. If you read metadata from tool or resource objects, update the key:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
tags = tool.meta.get("_fastmcp", {}).get("tags", [])
|
||||
|
||||
|
|
@ -309,7 +313,7 @@ Metadata is now always included — the `include_fastmcp_meta` parameter has bee
|
|||
|
||||
In v2, `@mcp.tool` transformed your function into a `FunctionTool` object. In v3, decorators return your original function unchanged — which means decorated functions stay callable for testing, reuse, and composition:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
|
@ -335,7 +339,7 @@ These were deprecated in v3. Items marked **Removed in v4** no longer work at al
|
|||
|
||||
**mount() prefix → namespace** (Removed in v4)
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Removed in v4
|
||||
main.mount(subserver, prefix="api")
|
||||
|
||||
|
|
@ -345,7 +349,7 @@ main.mount(subserver, namespace="api")
|
|||
|
||||
**import_server() → mount()** (Removed in v4)
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Removed in v4
|
||||
main.import_server(subserver)
|
||||
|
||||
|
|
@ -382,7 +386,7 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)])
|
|||
|
||||
**add_tool_transformation() → add_transform()** (Removed in v4)
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Removed in v4
|
||||
mcp.add_tool_transformation("name", config)
|
||||
|
||||
|
|
@ -395,7 +399,7 @@ mcp.add_transform(ToolTransform({"name": config}))
|
|||
|
||||
The proxy target is passed positionally in both APIs, so most calls migrate unchanged. If you passed the target by keyword, note that the parameter was renamed from `backend=` to `target=`.
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Removed in v4
|
||||
proxy = FastMCP.as_proxy("http://example.com/mcp")
|
||||
proxy = FastMCP.as_proxy(backend="http://example.com/mcp") # keyword form
|
||||
|
|
@ -424,12 +428,18 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)])
|
|||
|
||||
### Removed Deprecated Features
|
||||
|
||||
- `BearerAuthProvider` → use `JWTVerifier`
|
||||
- `Context.get_http_request()` → use `get_http_request()` from dependencies
|
||||
- `from fastmcp import Image` → use `from fastmcp.utilities.types import Image`
|
||||
- `FastMCP(dependencies=[...])` → use `fastmcp.json` configuration
|
||||
- `FastMCPProxy(client=...)` → use `client_factory=lambda: ...`
|
||||
- `output_schema=False` → use `output_schema=None`
|
||||
A batch of long-deprecated surfaces came out in 2.14. Each fails loudly at import or call time, and each has a direct replacement:
|
||||
|
||||
| Removed | Replacement |
|
||||
|---|---|
|
||||
| `BearerAuthProvider` | `JWTVerifier` — the same JWT validation under a name that says what it does |
|
||||
| `Context.get_http_request()` | `get_http_request()` from [dependency injection](/servers/dependency-injection) |
|
||||
| `from fastmcp import Image` | `from fastmcp.utilities.types import Image` |
|
||||
| `FastMCP(dependencies=[...])` | a [`fastmcp.json`](/deployment/server-configuration) configuration file |
|
||||
| `FastMCPProxy(client=...)` | `client_factory=lambda: ...` |
|
||||
| `output_schema=False` | `output_schema=None` |
|
||||
|
||||
Two of these are worth understanding rather than just swapping. `FastMCPProxy` takes a factory instead of a client because a single shared client cannot serve concurrent proxied sessions safely — the factory gives each session its own backend connection. And `output_schema=False` became `output_schema=None` because `False` read as "this tool has a schema, and it is false"; `None` says plainly that there is no schema.
|
||||
|
||||
## v2.13.0
|
||||
|
||||
|
|
@ -437,7 +447,7 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)])
|
|||
|
||||
The OAuth proxy now issues its own JWT tokens. For production, provide explicit keys:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
auth = GitHubProvider(
|
||||
client_id=os.environ["GITHUB_CLIENT_ID"],
|
||||
client_secret=os.environ["GITHUB_CLIENT_SECRET"],
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
---
|
||||
title: Upgrading from FastMCP 3
|
||||
sidebarTitle: "From FastMCP 3.x"
|
||||
sidebarTitle: "From FastMCP 3"
|
||||
description: What changes when you upgrade to FastMCP 4, which builds on the MCP Python SDK v2
|
||||
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 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. The sections below describe what FastMCP handles for you, the small number of changes you must make in your own code, and the deprecation timeline for the compatibility shims.
|
||||
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.
|
||||
|
||||
## Install the v4 prerelease
|
||||
The sections below cover what FastMCP handles for you, the changes you must make in your own code, the surfaces removed outright in 4.0, the behavior shifts that compile fine but act differently, and the deprecation timeline for the compatibility shims.
|
||||
|
||||
## 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`:
|
||||
|
||||
|
|
@ -27,26 +29,92 @@ constraint-dependencies = [
|
|||
|
||||
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.
|
||||
|
||||
## Environment requirements
|
||||
<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.
|
||||
|
||||
FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3 — it explains every item below, with the replacement code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs.
|
||||
|
||||
Then search the provided code for each signal below. Most FastMCP 3 servers upgrade untouched, so report only what you actually find.
|
||||
|
||||
ENVIRONMENT
|
||||
- a pydantic pin below 2.12
|
||||
- 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`
|
||||
- `fastmcp.server.apps`, `fastmcp.server.app`
|
||||
- `fastmcp.tools.tool`, `fastmcp.resources.resource`, `fastmcp.prompts.prompt`
|
||||
- `fastmcp.server.tasks`, `fastmcp.server.sampling`
|
||||
- `fastmcp.server.auth.authorization`
|
||||
- `CurrentDocket` or `CurrentWorker` from `fastmcp.dependencies`
|
||||
- `SkillsProvider`
|
||||
- `CachableToolResult`, `CachablePromptResult`, and their siblings (the misspelling was corrected with no alias)
|
||||
- `PromptToolMiddleware`, `ResourceToolMiddleware`
|
||||
|
||||
REMOVED SERVER METHODS AND KEYWORDS
|
||||
- `FastMCP.as_proxy(...)`
|
||||
- `import_server(...)` ← flag this one loudly: `mount()` is the replacement but NOT an equivalent. `import_server` took a static snapshot and skipped the child's lifespan and middleware; `mount` is a live composition that runs both.
|
||||
- `mount(prefix=...)`, `mount(as_proxy=...)`
|
||||
- `add_tool_transformation(...)`, `remove_tool_transformation(...)`
|
||||
- `remove_tool(...)` ← its replacement raises KeyError where this raised NotFoundError, so check surrounding except clauses
|
||||
- tool `serializer=`, tool `exclude_args=`
|
||||
- `StreamableHttpTransport(sse_read_timeout=...)`
|
||||
- `FASTMCP_DECORATOR_MODE` / `settings.decorator_mode`
|
||||
- `FastMCP(sampling_handler=...)`, `sampling_handler_behavior=`
|
||||
|
||||
REMOVED CONTEXT METHODS
|
||||
- `ctx.sample(...)`, `ctx.sample_step(...)`, `ctx.list_roots(...)`
|
||||
- Note for the user: if borrowing the CALLER's model is the whole point of the server, the guide's recommendation is to stay on FastMCP 3.x rather than migrate.
|
||||
- The client side is NOT affected — `Client(sampling_handler=...)` and `Client(roots=...)` still mean what they meant.
|
||||
|
||||
RUNTIME BREAKS THAT STILL COMPILE — the ones most likely to reach production
|
||||
- `ctx.elicit(...)` anywhere. It is era-gated in 4.0 and raises on modern connections, which is what `Client` now negotiates by default. This is the single most likely runtime failure.
|
||||
- `ctx.elicit(...)` called without `response_type`
|
||||
- `except httpx.` around any FastMCP call. FastMCP raises httpx2 exceptions now, but httpx is usually still installed transitively, so the handler imports, type-checks, and silently never matches.
|
||||
- a custom `httpx.AsyncClient`, `httpx_client_factory=`, or `httpx.Auth` handed to a FastMCP transport, `OAuth`, or `from_openapi`
|
||||
- `Middleware.on_initialize` hooks, and `ctx.set_state` values read back in a later call — neither survives a modern connection
|
||||
- middleware assuming `on_message` only sees routable requests
|
||||
- camelCase field reads (`inputSchema`, `isError`, `mimeType`, `nextCursor`, `structuredContent`, `serverInfo`, and the rest) — these still work but warn, and are scheduled for removal
|
||||
- clients matching on the resource-not-found error code -32002
|
||||
- templated resources whose parameters legitimately carry `..` or absolute paths
|
||||
- an OAuth server (`OAuthProxy` or anything built on it) with `issuer_url` set to something other than `base_url` — this forces a one-time re-authorization of every client
|
||||
|
||||
BACKGROUND TASKS
|
||||
- `@mcp.tool(task=True)` or `TaskConfig` without `mcp.add_extension(TasksExtension())`
|
||||
- `task=` on a `@mcp.resource` or `@mcp.prompt` decorator (tools only now)
|
||||
- `client.call_tool(..., task=True)`, `read_resource(task=True)`, `get_prompt(task=True)`
|
||||
|
||||
ERRORS
|
||||
- `McpError(ErrorData(...))` positional construction. Catching and `err.error.code` are unchanged; only construction moved.
|
||||
|
||||
For each item found, show the original line, name what changed, and give the corrected code from the guide. Where you could not confirm a replacement in the docs, say so instead of guessing.
|
||||
</Prompt>
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
The SDK v2 raises FastMCP's dependency floors, which matters before any of your code runs.
|
||||
|
||||
**pydantic >= 2.12 is now the floor.** If your project pins an older pydantic (for example `pydantic==2.11.*`), installing this FastMCP release fails with an unsatisfiable-resolution error from your installer — bump your pin to `>=2.12` first. If you don't pin pydantic at all, installers upgrade it silently as part of the FastMCP upgrade.
|
||||
|
||||
**The server extra floors Starlette >= 1.0.1.** Modern FastAPI (0.11x and later) already runs on Starlette 1.x, so mounting a FastMCP server inside a FastAPI app coexists cleanly — verified with FastAPI 0.138.2. Only very old FastAPI versions pinned below Starlette 1.0.1 conflict; upgrade FastAPI if your resolver complains about Starlette.
|
||||
**The server extra floors Starlette >= 1.0.1.** This is the requirement most likely to force an unrelated upgrade, because FastAPI pinned Starlette to a sub-1.0 range for a long time — FastAPI 0.115.12, for example, requires `starlette<0.47.0`. **FastAPI 0.133.0 is the first release that admits Starlette 1.x**, so a project pinned below that gets an unsatisfiable resolution rather than a version bump. Raise your FastAPI pin to `>=0.133.0` before upgrading FastMCP. Mounting a FastMCP server inside a FastAPI app is otherwise unaffected — verified against FastAPI 0.135.2 on Starlette 1.3.1.
|
||||
|
||||
## What FastMCP absorbs
|
||||
## What FastMCP Absorbs
|
||||
|
||||
### Legacy camelCase field access keeps working
|
||||
### camelCase Field Access
|
||||
|
||||
Objects that FastMCP hands back to you — the results of `client.list_tools()`, `client.call_tool_mcp()`, `client.read_resource()`, and the parameter objects passed to your sampling and elicitation handlers — are SDK v2 objects with snake_case fields. FastMCP installs a compatibility bridge at import time that routes the old camelCase names to their new snake_case fields, so code written against FastMCP 2.x still reads correctly:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
async with Client("my_mcp_server.py") as client:
|
||||
tools = await client.list_tools()
|
||||
schema = tools[0].inputSchema # still works, warns once
|
||||
|
||||
async def read_schema():
|
||||
async with Client("my_mcp_server.py") as client:
|
||||
tools = await client.list_tools()
|
||||
return tools[0].inputSchema # still works, warns once
|
||||
```
|
||||
|
||||
Each bridged read emits a `FastMCPDeprecationWarning` pointing you at the snake_case name (`tools[0].input_schema` here). The bridge covers the fields users actually read: `inputSchema`/`outputSchema` on tools; `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` on tool annotations; `mimeType` on resources and content; `isError`/`structuredContent` on tool results; `nextCursor` on paginated results; `serverInfo`/`protocolVersion` on the initialize result; the sampling parameter fields (`systemPrompt`, `maxTokens`, `stopSequences`, `modelPreferences`, `toolChoice`); and `requestedSchema` on elicitation parameters.
|
||||
|
|
@ -61,7 +129,7 @@ fastmcp.settings.mcp_camelcase_compat = False
|
|||
|
||||
See [Settings](/more/settings) for the full reference.
|
||||
|
||||
### Protocol types moved to `mcp_types`
|
||||
### 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:
|
||||
|
||||
|
|
@ -71,7 +139,7 @@ from mcp_types import TextContent, Tool, ToolAnnotations
|
|||
|
||||
`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.
|
||||
|
||||
### `McpError` has an alias
|
||||
### The `McpError` Alias
|
||||
|
||||
`fastmcp.exceptions.McpError` is an alias of the SDK's `MCPError`. Catching errors is unchanged — `except McpError` still catches SDK-raised errors, and reading `err.error.code` still works:
|
||||
|
||||
|
|
@ -84,7 +152,7 @@ except McpError as err:
|
|||
print(err.error.code)
|
||||
```
|
||||
|
||||
### Behavior preserved across the SDK boundary
|
||||
### Preserved Behavior
|
||||
|
||||
A few client behaviors that touch the SDK are preserved so you don't have to change anything:
|
||||
|
||||
|
|
@ -92,7 +160,7 @@ A few client behaviors that touch the SDK are preserved so you don't have to cha
|
|||
- `client.ping()` returns a `bool`.
|
||||
- `client.transport.get_session_id()` returns `None` on protocol eras that have no session, rather than raising. (The SDK v2 removed session-id access from its streamable HTTP transport; FastMCP reconstructs it on the transport object.)
|
||||
|
||||
## What you must change
|
||||
## 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.
|
||||
|
||||
|
|
@ -112,7 +180,7 @@ TypeError: MCPError.__init__() missing 1 required positional argument: 'message'
|
|||
|
||||
Note the message prints the class as `MCPError` (uppercase) even though your code wrote `McpError` — the old name is an alias for the SDK's renamed class. Construct the error with keyword arguments instead:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
from fastmcp.exceptions import McpError
|
||||
|
||||
# Before (raises TypeError under SDK v2):
|
||||
|
|
@ -128,7 +196,7 @@ Catching and `err.error.code` are unchanged — only construction moved.
|
|||
|
||||
**FastMCP now uses httpx2 exclusively.** FastMCP has replaced `httpx` with [httpx2](https://pypi.org/project/httpx2/), a next-generation httpx fork, across its entire HTTP stack — client transports and every server-side path (auth providers, the OpenAPI integration, the version check). `httpx` is no longer a FastMCP dependency. If you pass a custom client or factory into a FastMCP client transport — `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, `OAuth(httpx_client_factory=...)`, or a custom `httpx.Auth` as `Client(auth=...)` — those objects must now be httpx2. httpx2 is a drop-in fork with the same public API, so the change is an import swap:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
import httpx
|
||||
|
||||
|
|
@ -153,10 +221,12 @@ The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvide
|
|||
```python
|
||||
import httpx # still installed transitively — this import works
|
||||
|
||||
try:
|
||||
result = await client.call_tool("fetch", {"url": url})
|
||||
except httpx.ConnectError: # dead code: FastMCP now raises httpx2.ConnectError
|
||||
return fallback()
|
||||
|
||||
async def fetch(client, url):
|
||||
try:
|
||||
return await client.call_tool("fetch", {"url": url})
|
||||
except httpx.ConnectError: # dead code: FastMCP now raises httpx2.ConnectError
|
||||
return fallback()
|
||||
```
|
||||
|
||||
Grep your codebase for `except httpx.` and move those handlers to `httpx2`. The exception hierarchies match name-for-name, so the fix is an import swap — the hard part is remembering to look. One place you are covered automatically: exceptions raised *inside your tools and resources* (for example, a tool whose own old-httpx call gets a 429) are still mapped to `ToolError`/`ResourceError` by FastMCP's error boundary, which recognizes both libraries' exceptions during the transition.
|
||||
|
|
@ -167,7 +237,7 @@ Two runtime behaviors shift with httpx2, and because the switch is now wholesale
|
|||
|
||||
Deprecations that warned throughout the 3.x line are removed in 4.0. Unlike the bridged changes above, these fail immediately at the call site — a `ModuleNotFoundError`, `ImportError`, `AttributeError`, or `TypeError` — so nothing degrades silently. Every one has a direct replacement, and the fix is mechanical.
|
||||
|
||||
### Moved imports
|
||||
### Moved Imports
|
||||
|
||||
The proxy, OpenAPI, and app integrations moved to their permanent homes, and the internal component classes are no longer re-exported from their old aliases:
|
||||
|
||||
|
|
@ -188,10 +258,13 @@ The proxy, OpenAPI, and app integrations moved to their permanent homes, and the
|
|||
| `AuthCheck` / `AuthContext` / `require_scopes` / `require_roles` / `restrict_tag` / `run_auth_checks` from `fastmcp.server.auth.authorization` | `fastmcp.server.auth` |
|
||||
| `run_auth_checks_with_shortfall` / `scope_requirements` from `fastmcp.server.auth.authorization` | `fastmcp.utilities.authorization` |
|
||||
| `SkillsProvider` | `SkillsDirectoryProvider` from `fastmcp.server.providers.skills` |
|
||||
| `TaskConfig` from `fastmcp.server.tasks` | `fastmcp.utilities.tasks` |
|
||||
| `CurrentDocket` / `CurrentWorker` from `fastmcp.dependencies` | `fastmcp_tasks.dependencies` |
|
||||
| `fastmcp.server.sampling` (and `SamplingTool`) | removed with [server-side sampling](#protocol-version-support) |
|
||||
|
||||
Two renames in the same family are worth calling out because they have no compatibility alias. The response-caching wrapper models lost a spelling typo — `CachableToolResult`, `CachablePromptResult`, and their siblings became `CacheableToolResult`, `CacheablePromptResult`, etc. — so an import of the old spelling from `fastmcp.server.middleware.caching` raises `ImportError`. And `PromptToolMiddleware` / `ResourceToolMiddleware` are gone in favor of the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` (the `ToolInjectionMiddleware` base class is retained).
|
||||
|
||||
### Removed server methods and `mount()` keywords
|
||||
### Removed Server Methods
|
||||
|
||||
These `FastMCP` methods and keywords have warned since 3.0 and are now removed:
|
||||
|
||||
|
|
@ -211,7 +284,7 @@ Two of these replacements are not exact behavioral swaps. `create_proxy` takes i
|
|||
|
||||
`import_server` → `mount` is the one row here that is not a mechanical swap, because the two never had the same semantics. `import_server` took a **one-time static snapshot** — it copied the child's tools, resources, and prompts at call time, with no live link, and did not run the child's lifespan or middleware. `mount` is a **live composition** — it holds a live link to the child and runs the child's lifespan and middleware. After switching, later changes to the child become visible through the parent, the child's lifespan runs with the parent's (entered when the server starts, held until it stops — not per request), and the child's middleware runs on the operations delegated to it. If you depended on the frozen-copy behavior (a stable snapshot, no child lifecycle), there is no drop-in replacement: register the child's components on the parent directly instead of composing the two servers.
|
||||
|
||||
### Removed parameters and settings
|
||||
### Removed Parameters
|
||||
|
||||
Several parameters and settings that warned in 3.x are gone:
|
||||
|
||||
|
|
@ -221,7 +294,7 @@ Several parameters and settings that warned in 3.x are gone:
|
|||
- **`StreamableHttpTransport(sse_read_timeout=...)`** is removed — it was a no-op under the SDK v2 client. Set the read timeout through the public `Client(transport, timeout=...)` (a `timedelta` or float seconds), or reach for a custom `httpx_client_factory` when you need finer control. (`SSETransport` still accepts `sse_read_timeout`.)
|
||||
- **`ctx.elicit()` now requires `response_type`.** Omitting it (or passing `None`) has warned since 3.2 and now raises `TypeError`. The empty-object schema it produced gave clients nothing to render, and some showed an empty, non-functional form. Pass a type describing what you expect back — `bool` is the right answer for a confirmation:
|
||||
|
||||
```python
|
||||
```python test="skip"
|
||||
# Before
|
||||
result = await ctx.elicit("Approve this action?")
|
||||
|
||||
|
|
@ -231,9 +304,91 @@ Several parameters and settings that warned in 3.x are gone:
|
|||
|
||||
This is the server-authoring API only. Client elicitation handlers still receive `response_type=None` for URL requests and for empty schemas sent by other servers — that contract is unchanged.
|
||||
|
||||
## Behavior changes to verify
|
||||
### Background Tasks
|
||||
|
||||
Three server-side behaviors changed in ways that compile fine but can surface at runtime.
|
||||
Background tasks left the core MCP spec during the SDK v2 rebuild and came back as the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP follows the protocol: what was a built-in server feature in 3.x is now a registered extension, and the authoring surface changed on both sides of the connection.
|
||||
|
||||
The extension ships in a separate package, so the pin from [Install the v4 Prerelease](#install-the-v4-prerelease) needs one more entry before any of this imports:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
dependencies = ["fastmcp[tasks]==4.0.0b1"]
|
||||
|
||||
[tool.uv]
|
||||
constraint-dependencies = [
|
||||
"fastmcp-slim==4.0.0b1",
|
||||
"fastmcp-tasks==4.0.0b1",
|
||||
"mcp==2.0.0b2",
|
||||
"mcp-types==2.0.0b2",
|
||||
]
|
||||
```
|
||||
|
||||
On the server, `task=True` still marks a tool as capable of running in the background, but it no longer runs anything by itself — the extension does. Register it, or the server refuses to start:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
mcp = FastMCP("MyServer")
|
||||
mcp.add_extension(TasksExtension())
|
||||
|
||||
@mcp.tool(task=True)
|
||||
async def slow_computation(duration: int) -> str:
|
||||
"""A long-running operation."""
|
||||
return "done"
|
||||
```
|
||||
|
||||
Without the registration, a `task=True` tool raises at startup rather than the first time a client calls the tool:
|
||||
|
||||
```
|
||||
RuntimeError: Task-enabled tools (slow_computation) require the tasks extension,
|
||||
but no extension with identifier 'io.modelcontextprotocol/tasks' is registered.
|
||||
```
|
||||
|
||||
`TaskConfig` moved from `fastmcp.server.tasks` to `fastmcp.utilities.tasks`, and the `CurrentDocket` and `CurrentWorker` dependencies moved to `fastmcp_tasks.dependencies`.
|
||||
|
||||
`task=` is now a tool-only keyword. FastMCP 3 accepted it on resource, resource-template, and prompt decorators as well; passing it to `@mcp.resource` or `@mcp.prompt` now raises `TypeError`, and there is no replacement — the extension tasks tool calls only.
|
||||
|
||||
The client API changed shape entirely. In 3.x you opted a single call into background execution with `task=True` and got a handle back. In 4.0 `call_tool` handles a tasked call transparently: if the server runs the call in the background, the client polls it to completion and returns the same result a synchronous call would have produced.
|
||||
|
||||
```python
|
||||
import fastmcp_tasks # noqa: F401 — importing anywhere enables client task support
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
async def run(server):
|
||||
async with Client(server) as client:
|
||||
return await client.call_tool("slow_computation", {"duration": 10})
|
||||
```
|
||||
|
||||
When you want the handle — to do other work while the task runs, check on it, or cancel it — `call_tool_task` returns one immediately:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp_tasks import call_tool_task
|
||||
|
||||
|
||||
async def run(server):
|
||||
async with Client(server) as client:
|
||||
task = await call_tool_task(client, "slow_computation", {"duration": 10})
|
||||
return await task.result()
|
||||
```
|
||||
|
||||
Three things follow from this. `client.call_tool(name, args, task=True)` raises `TypeError`, as do `read_resource(task=True)` and `get_prompt(task=True)` — and those last two have no replacement. Client task support requires `fastmcp_tasks` to be imported somewhere in the process, since that import is what makes a `Client` advertise the capability. And tasks are negotiated only on modern connections, so a `mode="legacy"` client never gets them. See [Background Tasks](/servers/tasks) for the full picture.
|
||||
|
||||
## Behavior Changes
|
||||
|
||||
These changes compile fine and can surface at runtime. The first is the one most likely to bite a working 3.x server.
|
||||
|
||||
**`ctx.elicit()` no longer reaches a default client.** Elicitation is era-gated in 4.0: `ctx.elicit()` works on handshake-era connections (≤ 2025-11-25) and raises on the modern `2026-07-28` protocol, which has no back-channel for a running tool to push a request down. Because `fastmcp.Client` now defaults to `mode="auto"`, an ordinary client negotiates the modern era against a FastMCP server — so a tool that elicited happily in 3.x now fails the call:
|
||||
|
||||
```
|
||||
ToolError: elicitation via server-initiated requests is unavailable on 2026-07-28 connections.
|
||||
```
|
||||
|
||||
The gate is strict in both directions, which is what makes it debuggable: a guard tool that returns an input request on a handshake connection raises the mirror-image error rather than misbehaving quietly. You have three ways forward. Rewrite the tool as a guard tool that *returns* a description of the input it needs, which is the form that works on modern connections. Branch on `ctx.request_context.protocol_version` and keep both paths if you serve both eras. Or keep this server's clients on the handshake era with `Client(server, mode="legacy")`, which leaves `ctx.elicit()` working as written. See [Elicitation](/servers/elicitation#which-approach-to-use) for the two shapes side by side.
|
||||
|
||||
**Middleware sees traffic it never saw before.** Dispatch now begins in the SDK's middleware layer, the single point every inbound message passes through, so `on_message`, `on_request`, and `on_notification` observe *every* message a client sends — including `notifications/cancelled`, `notifications/initialized`, and `notifications/progress`, and including requests that fail before reaching a handler, such as an unknown method or a `tools/call` whose params fail validation. In 3.x those never reached your hooks. Middleware that assumed every message it saw was a routable request, or that counted messages to measure tool traffic, needs a guard on the message type. The operation hooks (`on_call_tool`, `on_list_tools`, and the rest) are unaffected: they still fire exactly once per request and still receive typed component results. See [What middleware sees](/servers/middleware#what-middleware-sees).
|
||||
|
||||
**Templated resources are path-screened by default.** Every templated resource now has its extracted parameter values checked for path-traversal (`..` segments), absolute paths, and null bytes *before your handler runs*, at the server's read chokepoint. A rejected read returns a non-leaky "resource not found" error. Only a standalone `..` segment counts as traversal, so values that merely contain dots (`file.tar.gz`, `HEAD~3..HEAD`) and dotfiles (`.env`) still pass. If a template legitimately accepts `..`-bearing or absolute values, exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable the check per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security).
|
||||
|
||||
|
|
@ -245,11 +400,11 @@ The cost of the correction is the `iss` on tokens already in the wild, so it fal
|
|||
|
||||
Servers that leave `issuer_url` unset, or set it to the same value as `base_url`, are unaffected. It defaults to `base_url`, and the metadata and minted `iss` are byte-identical to what 3.x produced.
|
||||
|
||||
## Deprecation timeline
|
||||
## Deprecation Timeline
|
||||
|
||||
The camelCase bridge is a migration aid, not a permanent fixture. It works today and warns on every bridged read so you can find and update the affected call sites. Plan to migrate your reads to snake_case: the shims will be removed in a future release, after which only the snake_case names resolve — the same state you get today by setting `mcp_camelcase_compat = False`. Turning the setting off is a good way to surface every remaining camelCase read in your code as a hard `AttributeError` before the shims go away.
|
||||
|
||||
## SDK deprecation warnings you may see
|
||||
## SDK Deprecation Warnings
|
||||
|
||||
Ordinary use of `ctx.info` (client logging) emits an SDK-level `MCPDeprecationWarning`:
|
||||
|
||||
|
|
@ -259,7 +414,7 @@ The logging capability is deprecated as of 2026-07-28 (SEP-2577)
|
|||
|
||||
The warning comes from the MCP SDK, not from FastMCP, and it is benign. `ctx.info` and the rest of the logging methods keep working on every era, including the modern one — a log message is a *notification*, which rides the response stream the caller already opened. The SDK is signaling the protocol's direction for the capability declaration, not the notification itself.
|
||||
|
||||
## Protocol version support
|
||||
## Protocol Version Support
|
||||
|
||||
FastMCP servers built on the SDK v2 serve multiple protocol eras from the same server. The SDK negotiates the era each client speaks: the sessionless `2026-07-28` era (which discovers capabilities through `server/discover`) and earlier session-based handshake versions are all handled simultaneously. This formally supersedes FastMCP's earlier "latest protocol only" stance — a single server now works with clients across the protocol transition.
|
||||
|
||||
|
|
@ -273,7 +428,7 @@ Migrating differs by capability. For **roots**, the guard pattern is the direct
|
|||
| --- | --- | --- |
|
||||
| `ctx.info` / logging notifications | Supported | Supported |
|
||||
| Tools, resources, prompts, completions | Supported | Supported |
|
||||
| `ctx.elicit` | Supported | Use the guard pattern (return `InputRequiredResult`) |
|
||||
| `ctx.elicit` | Supported | Raises — use the guard pattern (return `InputRequiredResult`) |
|
||||
| `ctx.sample` / `ctx.sample_step` | Method removed — call an LLM server-side | Method removed — call an LLM server-side, or ask via the guard pattern |
|
||||
| `ctx.list_roots` | Method removed — take paths as tool arguments | Method removed — ask via the guard pattern, or take paths as tool arguments |
|
||||
| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` needs session state the era lacks |
|
||||
|
|
@ -281,23 +436,25 @@ Migrating differs by capability. For **roots**, the guard pattern is the direct
|
|||
| Session state (`ctx.set_state` across calls) | Persists for the session | Does not persist — every request is a fresh connection |
|
||||
| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension |
|
||||
|
||||
Two of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; and each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next. A server that gates access in `on_initialize` or relies on per-session state must keep its clients on the session-based era. The narrow escape is per-client: `Client(server, mode="legacy")`. The durable, server-side answer is to declare the versions the server actually serves so a modern client is refused at connect time rather than silently losing those features — see the server's protocol-version restriction (added alongside this change).
|
||||
Several of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next; and a tool that calls [`ctx.elicit()`](#behavior-changes) raises. A server that gates access in `on_initialize`, relies on per-session state, or elicits mid-tool must keep its clients on the session-based era. The control is per-client: `Client(server, mode="legacy")`. There is no server-side setting that restricts which protocol versions a server offers, so a server whose behavior depends on the handshake era depends on its callers opting into it — which is only practical when you control them. If you don't, port the behavior instead: a guard tool for elicitation, [session state](/servers/sessions) for what `ctx.set_state` held, and per-request auth checks for what `on_initialize` gated.
|
||||
|
||||
The client side is unaffected. `sampling_handler=` and `roots=` mean what they always did — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — and one registration serves both routes, since a handshake-era server's pushed request and a modern server's returned one dispatch to the same handler.
|
||||
|
||||
## Upgrade checklist
|
||||
## Upgrade Checklist
|
||||
|
||||
Most servers upgrade untouched. Work down this list to find the ones that don't:
|
||||
|
||||
1. **Bump your environment.** Raise any pin below `pydantic>=2.12`; upgrade FastAPI if your resolver complains about Starlette `<1.0.1`.
|
||||
2. **Fix imports that moved out.** Replace `from mcp.types import X` with `from mcp_types import X`, and update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims).
|
||||
3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods-and-mount-keywords).
|
||||
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. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
|
||||
6. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
|
||||
7. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
|
||||
8. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize` or per-session state, keep its clients on `mode="legacy"` or restrict the server's served protocol versions.
|
||||
9. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, update any client that matched the old `-32002` resource-not-found code, and if your server mints its own OAuth tokens (`OAuthProxy` and the providers built on it) under an `issuer_url` that differs from its `base_url`, schedule the [one-time re-authorization](#behavior-changes-to-verify) its clients now need.
|
||||
10. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed.
|
||||
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).
|
||||
6. **Register the tasks extension.** A `task=True` tool needs `mcp.add_extension(TasksExtension())` or the server won't start. Drop `task=` from resource and prompt decorators, move `TaskConfig` to `fastmcp.utilities.tasks`, and replace client-side `call_tool(..., task=True)` with plain `call_tool` or `call_tool_task`.
|
||||
7. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
|
||||
8. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
|
||||
9. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
|
||||
10. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize`, per-session state, or `ctx.elicit()`, keep its clients on `mode="legacy"`, or port the behavior forward — there is no server-side protocol-version restriction.
|
||||
11. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, guard any middleware that now sees notifications and unroutable requests, update any client that matched the old `-32002` resource-not-found code, and if your server mints its own OAuth tokens (`OAuthProxy` and the providers built on it) under an `issuer_url` that differs from its `base_url`, schedule the [one-time re-authorization](#behavior-changes) its clients now need.
|
||||
12. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed.
|
||||
|
||||
The executable version of this checklist lives in [`tests/test_upgrade_from_v3.py`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/test_upgrade_from_v3.py): it builds representative 3.x-style servers and asserts they run unchanged, and pins every removed surface to the exact error it now raises.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Upgrading from the MCP Low-Level SDK
|
||||
sidebarTitle: "From MCP Low-Level SDK"
|
||||
description: Upgrade your MCP server from the low-level Python SDK's Server class to FastMCP
|
||||
title: Upgrading from the Low-Level SDK v1
|
||||
sidebarTitle: "From Low-Level SDK v1"
|
||||
description: Upgrade your MCP server from v1 of the low-level Python SDK's Server class to FastMCP
|
||||
icon: up
|
||||
---
|
||||
|
||||
|
|
@ -9,78 +9,90 @@ If you've been building MCP servers directly on the `mcp` package's `Server` cla
|
|||
|
||||
The core idea: instead of telling the SDK what your tools look like and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The plumbing you wrote to satisfy the protocol just disappears.
|
||||
|
||||
## Why now is the moment to switch
|
||||
## The SDK v2 Transition
|
||||
|
||||
MCP SDK v2 landed sweeping breaking changes on the low-level `Server`: the protocol types moved out of `mcp.types` into a separate `mcp_types` package, every field was renamed from camelCase to snake_case, the `Server` class was rebuilt, `McpError` was renamed, and sessions were removed on the new sessionless protocol era. If you build directly on the low-level SDK, all of that lands on you — you have to rewrite your imports, your handler signatures, and your error construction to match the new surface.
|
||||
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:
|
||||
|
||||
Adopting FastMCP is the easier path. FastMCP 4 runs on SDK v2 and hides that entire surface behind a high-level API that did not change. You write `@mcp.tool` and never touch the renamed internals — FastMCP derives the protocol layer from your function signatures, so the SDK v2 rename simply isn't something your code has to know about. Migrating low-level-SDK-v1 code to FastMCP is less work than migrating it to raw SDK v2, and you come out the other side with the whole framework: composition, middleware, proxies, authentication, and testing. The SDK v2 break is the natural moment to make the jump.
|
||||
```
|
||||
ModuleNotFoundError: No module named 'mcp.types'
|
||||
AttributeError: 'Server' object has no attribute 'list_tools'
|
||||
```
|
||||
|
||||
Often nobody chose that moment. An unpinned `mcp` dependency, a fresh lockfile, or a rebuilt container picks up the new major version. Nothing is wrong with your code, and nothing is wrong with the SDK — major versions are exactly where a change like this belongs. Your build just crossed it earlier than you planned to.
|
||||
|
||||
Pinning the SDK back restores the decorator API immediately, with no code changes, and buys you time to choose deliberately:
|
||||
|
||||
```bash
|
||||
pip install "mcp<2"
|
||||
```
|
||||
|
||||
## Two Upgrade Paths
|
||||
|
||||
Both directions are reasonable, and the choice is about which code you'd rather maintain.
|
||||
|
||||
**Porting the low-level `Server` to SDK v2** keeps you in direct control of the protocol surface, which is the point of the low-level API and the right call for some servers. The work is real: your imports, every handler signature, every handler's return type, and your error construction all move.
|
||||
|
||||
**Adopting FastMCP** is what the rest of this page walks through. What makes it less work is not that FastMCP is better — it's that the code most affected by the SDK v2 changes is precisely the code FastMCP doesn't ask you to write. Your `list_tools`/`call_tool` pair, hand-written JSON Schema, and content-block wrappers aren't ported to new signatures; they're deleted, and FastMCP derives all of it from your function signatures instead. FastMCP 4 runs on MCP SDK v2 underneath, so both paths land you on the same modern protocol layer.
|
||||
|
||||
<Note>
|
||||
Already using FastMCP 1.0 via `from mcp.server.fastmcp import FastMCP`? Your upgrade is simpler — see the [FastMCP 1.0 upgrade guide](/getting-started/upgrading/from-mcp-sdk) instead.
|
||||
Already on SDK v2's rebuilt `Server` class, with constructor-registered `on_*` handlers? See [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2) instead — the before-and-after code is different enough to warrant its own guide.
|
||||
|
||||
Using FastMCP 1.0 via `from mcp.server.fastmcp import FastMCP`? Your upgrade is a single import — see [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1).
|
||||
</Note>
|
||||
|
||||
<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 the `mcp` package's low-level Server class (v1) to FastMCP 4. The server currently uses `mcp.server.Server` (or `mcp.server.lowlevel.server.Server`) with manual handler registration. Analyze the provided code and rewrite it using FastMCP's high-level API. The full guide is at https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context.
|
||||
You are rewriting an MCP server built on v1 of the `mcp` package's low-level `Server` class (`mcp.server.Server` or `mcp.server.lowlevel.server.Server`, with decorator-registered handlers) using FastMCP 4's high-level API.
|
||||
|
||||
UPGRADE RULES:
|
||||
FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1 — it explains every item below, with before-and-after code for each handler group. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs.
|
||||
|
||||
1. IMPORTS: Replace all `mcp.*` imports with FastMCP equivalents.
|
||||
- `from mcp.server import Server` or `from mcp.server.lowlevel.server import Server` → `from fastmcp import FastMCP`
|
||||
- `import mcp.types as types` → remove (not needed for most code)
|
||||
- `from mcp.server.stdio import stdio_server` → remove (handled by mcp.run())
|
||||
- `from mcp.server.sse import SseServerTransport` → remove (handled by mcp.run())
|
||||
Then work through the provided code. This is a rewrite, not a patch: most of what you find gets deleted rather than translated.
|
||||
|
||||
2. SERVER: Replace `Server("name")` with `FastMCP("name")`.
|
||||
CONSTRUCTION AND TRANSPORT
|
||||
- `Server("name")`
|
||||
- `async with stdio_server() as (r, w): await server.run(r, w, server.create_initialization_options())`
|
||||
- `SseServerTransport` / `StreamableHTTPSessionManager` and any Starlette wiring around them
|
||||
- `asyncio.run(main())` boilerplate
|
||||
- `lifespan=` — carries over directly: pass the same async context manager to `FastMCP(lifespan=...)`, and read what it yields from `ctx.lifespan_context` in any tool. Do not drop it — the tools that depended on it (a DB connection, a client pool) lose their dependency silently if you do.
|
||||
|
||||
3. TOOLS: Replace the list_tools + call_tool handler pair with individual @mcp.tool decorators.
|
||||
- Delete the `@server.list_tools()` handler entirely
|
||||
- Delete the `@server.call_tool()` handler entirely
|
||||
- For each tool that was listed in list_tools and dispatched in call_tool, create a new function:
|
||||
- Decorate it with `@mcp.tool`
|
||||
- Use the tool name as the function name (or pass name= to the decorator)
|
||||
- Use the docstring for the description (or pass description= to the decorator)
|
||||
- Convert the inputSchema JSON Schema into typed Python parameters (e.g., `{"type": "integer"}` → `int`, `{"type": "string"}` → `str`, `{"type": "array", "items": {"type": "string"}}` → `list[str]`)
|
||||
- Return plain Python values (`str`, `int`, `dict`, etc.) instead of `list[types.TextContent(...)]`
|
||||
- If the tool returned `types.ImageContent` or `types.EmbeddedResource`, use `from fastmcp.utilities.types import Image` or return the appropriate type
|
||||
HANDLERS TO DELETE (each becomes one or more decorated functions)
|
||||
- `@server.list_tools()` + `@server.call_tool()` — note the `if name == ...` dispatch chain inside call_tool; each branch becomes its own `@mcp.tool`
|
||||
- `@server.list_resources()` + `@server.list_resource_templates()` + `@server.read_resource()` — note any manual URI parsing, which the `{placeholder}` syntax replaces
|
||||
- `@server.list_prompts()` + `@server.get_prompt()`
|
||||
- any other `@server.*()` handler in the file — completion, resource subscribe/unsubscribe, logging level, progress. Look these up in the FastMCP docs rather than assuming a decorator name maps one-to-one.
|
||||
|
||||
4. RESOURCES: Replace the list_resources + list_resource_templates + read_resource handler trio with individual @mcp.resource decorators.
|
||||
- Delete all three handlers
|
||||
- For each static resource, create a function decorated with `@mcp.resource("uri://...")`
|
||||
- For each resource template, use `@mcp.resource("uri://{param}/path")` with `{param}` in the URI and a matching function parameter
|
||||
- Return str for text content, bytes for binary content
|
||||
- Set `mime_type=` in the decorator if needed
|
||||
TYPES THAT DISAPPEAR FROM YOUR CODE
|
||||
- hand-written `inputSchema` JSON Schema dicts — these come from type hints now
|
||||
- `types.Tool`, `types.Resource`, `types.ResourceTemplate`, `types.Prompt`, `types.PromptArgument`
|
||||
- `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.
|
||||
|
||||
5. PROMPTS: Replace the list_prompts + get_prompt handler pair with individual @mcp.prompt decorators.
|
||||
- Delete both handlers
|
||||
- For each prompt, create a function decorated with `@mcp.prompt`
|
||||
- Convert PromptArgument definitions into typed function parameters
|
||||
- Return str for simple single-message prompts (auto-wrapped as user message)
|
||||
- Return `list[Message]` for multi-message prompts: `from fastmcp.prompts import Message`
|
||||
- `Message("text")` defaults to `role="user"`; use `Message("text", role="assistant")` for assistant messages
|
||||
CONTEXT AND SIDE CHANNELS
|
||||
- `server.request_context`
|
||||
- `session.send_log_message(...)`, `session.send_progress_notification(...)`
|
||||
- direct session use for anything else — a FastMCP `Context` has a `ctx.session` property returning the underlying SDK session, so this still works; prefer a `Context` method where one exists, and note the remaining uses as SDK-coupled
|
||||
|
||||
6. TRANSPORT: Replace all transport boilerplate with mcp.run().
|
||||
- `async with stdio_server() as (r, w): await server.run(r, w, ...)` → `mcp.run()` (`stdio` is the default)
|
||||
- SSE/Starlette setup → `mcp.run(transport="sse", host="...", port=...)`
|
||||
- Streamable HTTP setup → `mcp.run(transport="http", host="...", port=...)`
|
||||
- Delete asyncio.run(main()) boilerplate — use `if __name__ == "__main__": mcp.run()`
|
||||
ERRORS
|
||||
- `raise ValueError(f"Unknown tool: ...")` and other dispatch fallbacks — these become unnecessary
|
||||
- `McpError` construction and any error-code mapping
|
||||
|
||||
7. CONTEXT: Replace `server.request_context` with FastMCP's Context parameter.
|
||||
- Add `from fastmcp import Context` and add a `ctx: Context` parameter to any tool that needs it
|
||||
- `server.request_context.session.send_log_message(...)` → `await ctx.info("message")` or `await ctx.warning("message")`
|
||||
- Progress reporting → `await ctx.report_progress(current, total)`
|
||||
|
||||
For each change, show the original code, explain what it did, and provide the FastMCP equivalent.
|
||||
For each item found, show the original code, say what it did, and give the FastMCP equivalent. Where several handlers collapse into one decorated function, show the collapse rather than a line-by-line mapping. Call out anything you could not find a documented FastMCP replacement for instead of inventing one.
|
||||
</Prompt>
|
||||
|
||||
## Install
|
||||
|
||||
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
|
||||
|
||||
```bash
|
||||
pip install --upgrade fastmcp
|
||||
pip install "fastmcp==4.0.0b1"
|
||||
# or
|
||||
uv add fastmcp
|
||||
uv add "fastmcp==4.0.0b1"
|
||||
```
|
||||
|
||||
FastMCP includes the `mcp` package as a transitive dependency, so you don't lose access to anything.
|
||||
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`.
|
||||
|
||||
## Server and Transport
|
||||
|
||||
|
|
@ -88,7 +100,7 @@ The `Server` class requires you to choose a transport, connect streams, build in
|
|||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
```python Before test="skip"
|
||||
import asyncio
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
|
@ -124,7 +136,12 @@ if __name__ == "__main__":
|
|||
Need HTTP instead of stdio? With the `Server` class, you'd wire up Starlette routes and `SseServerTransport` or `StreamableHTTPSessionManager`. With FastMCP:
|
||||
|
||||
```python
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8000)
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8000)
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
|
@ -133,7 +150,7 @@ This is where the difference is most dramatic. The `Server` class requires two h
|
|||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
```python Before test="skip"
|
||||
import mcp.types as types
|
||||
from mcp.server import Server
|
||||
|
||||
|
|
@ -240,7 +257,7 @@ The `Server` class uses three handlers for resources: `list_resources()` to enum
|
|||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
```python Before test="skip"
|
||||
import json
|
||||
import mcp.types as types
|
||||
from mcp.server import Server
|
||||
|
|
@ -333,7 +350,7 @@ Same pattern: the `Server` class uses `list_prompts()` and `get_prompt()` with m
|
|||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
```python Before test="skip"
|
||||
import mcp.types as types
|
||||
from mcp.server import Server
|
||||
|
||||
|
|
@ -420,7 +437,7 @@ The `Server` class exposes request context through `server.request_context`, whi
|
|||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
```python Before test="skip"
|
||||
import mcp.types as types
|
||||
from mcp.server import Server
|
||||
|
||||
|
|
@ -458,13 +475,31 @@ async def process_data(ctx: Context) -> str:
|
|||
|
||||
The `Context` object provides logging (`ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`), progress reporting (`ctx.report_progress()`), resource subscriptions, session state, and more. See [Context](/servers/context) for the full API.
|
||||
|
||||
## Errors
|
||||
|
||||
Most of the errors a low-level server raises disappear with the dispatch that raised them: the `ValueError(f"Unknown tool: {name}")` fallback is unnecessary once FastMCP routes calls, and an exception from your function body is converted to a tool error for you.
|
||||
|
||||
Deliberate protocol errors are the exception, and they need a small rewrite. The v1 pattern wrapped an `ErrorData` and passed it positionally; FastMCP's `McpError` takes the fields directly:
|
||||
|
||||
```python test="skip"
|
||||
from fastmcp.exceptions import McpError
|
||||
|
||||
# Before (SDK v1):
|
||||
# raise McpError(ErrorData(code=-32000, message="Upstream unavailable"))
|
||||
|
||||
# After:
|
||||
raise McpError(code=-32000, message="Upstream unavailable")
|
||||
```
|
||||
|
||||
An optional third argument, `data=`, carries the structured payload `ErrorData` used to hold. Catching is unchanged — `except McpError` still works, and `err.error.code` still reads the code — so only construction sites need touching.
|
||||
|
||||
## Complete Example
|
||||
|
||||
A full server upgrade, showing how all the pieces fit together:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before expandable
|
||||
```python Before expandable test="skip"
|
||||
import asyncio
|
||||
import json
|
||||
import mcp.types as types
|
||||
|
|
@ -580,15 +615,10 @@ if __name__ == "__main__":
|
|||
|
||||
</CodeGroup>
|
||||
|
||||
## What's Next
|
||||
## What You Gain
|
||||
|
||||
Once you've upgraded, you have access to everything FastMCP provides beyond the basics:
|
||||
Deleting the handler machinery is the immediate payoff, but the reason to make this move is what becomes available once your server is a FastMCP server.
|
||||
|
||||
- **[Server composition](/servers/composition)** — Mount sub-servers to build modular applications
|
||||
- **[Middleware](/servers/middleware)** — Add logging, rate limiting, error handling, and caching
|
||||
- **[Proxy servers](/servers/providers/proxy)** — Create a proxy to any existing MCP server
|
||||
- **[OpenAPI integration](/integrations/openapi)** — Generate an MCP server from an OpenAPI spec
|
||||
- **[Authentication](/servers/auth/authentication)** — Built-in OAuth and token verification
|
||||
- **[Testing](/servers/testing)** — Test your server directly in Python without running a subprocess
|
||||
[Server composition](/servers/composition) mounts one server inside another, so a surface that grew unwieldy as a single `call_tool` dispatch splits into modules developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching — the cross-cutting concerns that, on the low-level `Server`, meant threading the same code through every handler. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control, and the [OpenAPI integration](/integrations/openapi) generates an entire server from an API specification you already have. [Authentication](/servers/auth/authentication) arrives as a single `auth=` provider covering token verification, OAuth, and named providers for GitHub, Google, Auth0, and others.
|
||||
|
||||
Explore the full documentation at [gofastmcp.com](https://gofastmcp.com).
|
||||
The change most likely to affect your daily work is [testing](/servers/testing). FastMCP ships a client that connects to a server object in the same Python process, so a test calls your tools directly — no subprocess, no stdio pipes, no transport to stand up.
|
||||
622
docs/getting-started/upgrading/from-low-level-sdk-v2.mdx
Normal file
622
docs/getting-started/upgrading/from-low-level-sdk-v2.mdx
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
---
|
||||
title: Upgrading from the Low-Level SDK v2
|
||||
sidebarTitle: "From Low-Level SDK v2"
|
||||
description: Move a server built on v2 of the low-level Python SDK's Server class to FastMCP
|
||||
icon: up
|
||||
---
|
||||
|
||||
If your server builds on the `mcp` package's low-level `Server` class as SDK v2 rebuilt it — handlers passed to the constructor as `on_list_tools`, `on_call_tool`, and their siblings, each taking `(ctx, params)` and returning a wrapped result object — this guide is for you. FastMCP replaces that machinery with a declarative API where your functions *are* the protocol surface.
|
||||
|
||||
The core idea: instead of describing your tools to the SDK and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The dispatch you wrote to route a call by name, and the schemas you wrote by hand to describe it, both disappear.
|
||||
|
||||
Migrating from SDK v2 is the most direct of the four upgrade paths, because you and FastMCP already share a protocol layer. FastMCP 4 is built on SDK v2, so `mcp_types` imports keep working, field names are already snake_case, and the era negotiation you get is the one you have. Almost nothing about the wire changes — the one exception is [argument strictness](#stricter-arguments), covered below.
|
||||
|
||||
<Note>
|
||||
On SDK v1's decorator-registered `Server` — `@server.list_tools()`, `@server.call_tool()` — instead? See [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1), where the before-and-after code matches that API.
|
||||
|
||||
Using SDK v2's high-level `MCPServer` class? See [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2) — that migration is mostly renaming.
|
||||
</Note>
|
||||
|
||||
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
|
||||
You are rewriting an MCP server built on the MCP Python SDK v2's low-level `Server` class (`mcp.server.lowlevel.server.Server`, with `on_*` handlers passed to the constructor) using FastMCP 4's high-level API.
|
||||
|
||||
FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2 — it explains every item below in full, with before-and-after code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not guess at a FastMCP API you have not confirmed in the docs.
|
||||
|
||||
Then work through the provided code looking for each of these. The guide has the replacement for every one:
|
||||
|
||||
CONSTRUCTION AND TRANSPORT
|
||||
- `Server(name, on_list_tools=..., on_call_tool=..., ...)` — the whole constructor, including every handler passed to it
|
||||
- `server.run(read_stream, write_stream, server.create_initialization_options())` and its `stdio_server()` context manager
|
||||
- `server.streamable_http_app()` and any Starlette app assembled around it
|
||||
- `asyncio.run(main())` boilerplate
|
||||
- `lifespan=` — carries over directly: pass the same async context manager to `FastMCP(lifespan=...)`, and read what it yields from `ctx.lifespan_context` in any tool. Do not drop it — the tools that depended on it (a DB connection, a client pool) lose their dependency silently if you do.
|
||||
|
||||
HANDLERS TO DELETE, EACH REPLACED BY ONE DECORATOR (not simply removed)
|
||||
- `on_list_tools` + `on_call_tool` → one `@mcp.tool` function per branch of the `if params.name == ...` dispatch chain inside `on_call_tool`
|
||||
- `on_list_resources` + `on_list_resource_templates` + `on_read_resource` → one `@mcp.resource` function per resource/template
|
||||
- `on_list_prompts` + `on_get_prompt` → one `@mcp.prompt` function per prompt
|
||||
- `on_completion` → one `@mcp.completion` function. This one is easy to drop by mistake: skipping it does not just remove autocomplete cleanly, it silently stops FastMCP from advertising the completions capability at all, since that capability is only advertised when a handler is registered.
|
||||
- `on_subscribe_resource` / `on_unsubscribe_resource` / `on_subscriptions_listen` — flag for the user, no single-decorator equivalent
|
||||
- `on_set_logging_level`, `on_progress`, `on_roots_list_changed`, `on_ping` — flag for the user, these are protocol-level hooks with no direct FastMCP surface
|
||||
|
||||
TYPES THAT DISAPPEAR FROM YOUR CODE
|
||||
- Hand-written `input_schema` / `output_schema` JSON Schema dicts — these come from type hints now
|
||||
- `types.ListToolsResult`, `types.CallToolResult`, `types.ListResourcesResult`, `types.ListResourceTemplatesResult`, `types.ReadResourceResult`, `types.ListPromptsResult`, `types.GetPromptResult` — result wrappers FastMCP builds for you
|
||||
- `types.TextContent`, `types.TextResourceContents`, `types.BlobResourceContents` — return plain Python values instead
|
||||
- `types.ImageContent` / `types.AudioContent` — `fastmcp.utilities.types.Image` / `Audio`
|
||||
- `types.Tool`, `types.Resource`, `types.ResourceTemplate`, `types.Prompt`, `types.PromptArgument` — declaration types FastMCP derives
|
||||
- `types.PromptMessage` — `fastmcp.prompts.Message`
|
||||
- Note which `mcp_types` imports are still needed afterward; protocol types are unchanged in FastMCP, so surviving imports stay as they are.
|
||||
|
||||
CONTEXT AND SIDE CHANNELS
|
||||
- `ctx.session.send_log_message(...)` — `ctx.info()` / `ctx.debug()` / `ctx.warning()` / `ctx.error()` on a `fastmcp.Context` parameter
|
||||
- `ctx.session.report_progress(...)` — `ctx.report_progress()`
|
||||
- `ctx.request_id`, `ctx.meta`, `ctx.protocol_version` — these live on `ctx.request_context` in FastMCP (`ctx.request_context.request_id`, and so on); note that `ctx.protocol_version` directly on the Context does not exist
|
||||
- `ctx.params` — no equivalent, and none is needed: the raw request params were how a low-level handler read the tool's arguments, and those are now the decorated function's typed parameters. `ctx.request_context.params` does NOT exist and raises AttributeError.
|
||||
- Direct `ctx.session` use for anything else — `Context.session` exists in FastMCP too and returns the same raw SDK session, so this still works; prefer a `Context` method where one exists, and note the remaining uses as SDK-coupled
|
||||
|
||||
ERRORS AND AUTH
|
||||
- `raise ValueError(f"Unknown tool: ...")` dispatch fallbacks — these become unnecessary
|
||||
- `MCPError` construction and any error-code mapping
|
||||
- `auth=AuthSettings(...)`, `token_verifier=`, `auth_server_provider=` — one `auth=` provider in FastMCP
|
||||
- `TransportSecuritySettings`
|
||||
|
||||
For each item found, show the original code, say what it did, and give the FastMCP equivalent. Where several handlers collapse into one decorated function, show the collapse rather than a line-by-line mapping. Call out anything you could not find a documented FastMCP replacement for instead of inventing one.
|
||||
</Prompt>
|
||||
|
||||
## Install
|
||||
|
||||
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
|
||||
|
||||
```bash
|
||||
pip install "fastmcp==4.0.0b1"
|
||||
# or
|
||||
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 4 depends on the MCP SDK v2 you are already using, so `mcp_types` stays importable and every protocol type keeps its current name and fields. Most of those imports vanish from your code anyway — FastMCP derives them — but the ones you keep need no changes.
|
||||
|
||||
## Server and Transport
|
||||
|
||||
The `Server` class asks you to open a transport, connect its streams, build initialization options, and run an event loop. FastMCP collapses that into a constructor and a `run()` call.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before test="skip"
|
||||
import asyncio
|
||||
|
||||
from mcp.server.lowlevel.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
server = Server("my-server") # plus every on_* handler
|
||||
|
||||
async def main():
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
server.create_initialization_options(),
|
||||
)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
```python After
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
|
||||
# ... register tools, resources, prompts ...
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Serving HTTP is the same shape. Where the low-level class hands you a Starlette app from `server.streamable_http_app()` and leaves the hosting to you, FastMCP runs it directly:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8000)
|
||||
```
|
||||
|
||||
`mcp.http_app()` still returns a Starlette app when you need to mount the server inside a larger application.
|
||||
|
||||
## Tools
|
||||
|
||||
This is where the difference is largest. SDK v2 requires two handlers — one describing your tools with hand-written JSON Schema, one dispatching calls by name — and both are passed to the constructor, so the connection between a tool's declaration and its implementation lives only in your head. FastMCP derives both from the function.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
import mcp_types as types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params) -> types.ListToolsResult:
|
||||
number = {"type": "number"}
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"a": number, "b": number},
|
||||
"required": ["a", "b"],
|
||||
}
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(name="add", description="Add two numbers", input_schema=schema),
|
||||
types.Tool(
|
||||
name="multiply", description="Multiply two numbers", input_schema=schema
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def call_tool(
|
||||
ctx: ServerRequestContext, params: types.CallToolRequestParams
|
||||
) -> types.CallToolResult:
|
||||
arguments = params.arguments or {}
|
||||
if params.name == "add":
|
||||
result = arguments["a"] + arguments["b"]
|
||||
elif params.name == "multiply":
|
||||
result = arguments["a"] * arguments["b"]
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {params.name}")
|
||||
return types.CallToolResult(content=[types.TextContent(type="text", text=str(result))])
|
||||
|
||||
|
||||
server = Server("math", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
```
|
||||
|
||||
```python After
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("math")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def add(a: float, b: float) -> float:
|
||||
"""Add two numbers"""
|
||||
return a + b
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def multiply(a: float, b: float) -> float:
|
||||
"""Multiply two numbers"""
|
||||
return a * b
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Each `@mcp.tool` function is self-contained: its name becomes the tool name, its docstring becomes the description, its annotations become the JSON Schema, and its return value is serialized for you. The dispatch chain, the schema dicts, the `CallToolResult` wrapper, the `TextContent` wrapper, and the unknown-tool fallback all go away — a tool that doesn't exist is now the framework's problem, not a branch you maintain.
|
||||
|
||||
### Type Mapping
|
||||
|
||||
Your hand-written `input_schema` becomes the function's parameters:
|
||||
|
||||
| JSON Schema | Python type |
|
||||
|---|---|
|
||||
| `{"type": "string"}` | `str` |
|
||||
| `{"type": "number"}` | `float` |
|
||||
| `{"type": "integer"}` | `int` |
|
||||
| `{"type": "boolean"}` | `bool` |
|
||||
| `{"type": "array", "items": {"type": "string"}}` | `list[str]` |
|
||||
| `{"type": "object"}` | `dict` |
|
||||
| A property absent from `required` | `param: str \| None = None` |
|
||||
|
||||
Constraints carry over too. A schema with `"minimum"` and `"maximum"` becomes a Pydantic `Field`, and a nested object schema becomes a Pydantic model or dataclass used as the annotation — FastMCP generates the same schema back out of it.
|
||||
|
||||
### Return Values
|
||||
|
||||
The low-level class requires tools to return a `CallToolResult` wrapping a list of content blocks. FastMCP takes the value itself — strings, numbers, dicts, lists, dataclasses, Pydantic models — and handles both the content block and the structured output. For images and audio, FastMCP provides wrapper types that carry the format:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.types import Image
|
||||
|
||||
mcp = FastMCP("media")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def create_chart(data: list[float]) -> Image:
|
||||
"""Generate a chart from data."""
|
||||
png_bytes = render_png(data) # your logic
|
||||
return Image(data=png_bytes, format="png")
|
||||
```
|
||||
|
||||
When you need full control over the wire result — multiple content blocks, or structured content that differs from the content blocks — return a `ToolResult` from `fastmcp.tools` instead.
|
||||
|
||||
### Stricter Arguments
|
||||
|
||||
Deriving the schema from your signature also tightens what callers may send, and this is the one behavior change the migration introduces. Your `on_call_tool` handler reads `params.arguments` as a plain dict and never looks at keys it doesn't need, so a call carrying an unexpected key succeeds. FastMCP declares `"additionalProperties": false` on the generated schema and enforces it, so the same call fails:
|
||||
|
||||
```python test="skip"
|
||||
# Against the low-level handler: succeeds, "extra" never read.
|
||||
# Against FastMCP: raises, "extra" is not a parameter of greet().
|
||||
await client.call_tool("greet", {"name": "World", "extra": "surprise"})
|
||||
```
|
||||
|
||||
For most servers this is an improvement that costs nothing — a caller sending keys your handler never read was already a bug, and the hand-written schema never advertised that they were allowed. It matters if a client in your fleet attaches metadata alongside real arguments, since those calls start failing the moment you migrate. Accept them explicitly as optional parameters if you need to keep them working.
|
||||
|
||||
## Resources
|
||||
|
||||
Resources take three handlers on the low-level class: one to list static resources, one to list URI templates, and one to read whichever URI arrives, with routing you write by hand. FastMCP replaces all three with a decorator per resource, and detects templates from the URI itself.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
import json
|
||||
|
||||
import mcp_types as types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
|
||||
async def list_resources(ctx: ServerRequestContext, params) -> types.ListResourcesResult:
|
||||
return types.ListResourcesResult(
|
||||
resources=[
|
||||
types.Resource(
|
||||
uri="config://app",
|
||||
name="app_config",
|
||||
description="Application configuration",
|
||||
mime_type="application/json",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def list_resource_templates(
|
||||
ctx: ServerRequestContext, params
|
||||
) -> types.ListResourceTemplatesResult:
|
||||
return types.ListResourceTemplatesResult(
|
||||
resource_templates=[
|
||||
types.ResourceTemplate(
|
||||
uri_template="users://{user_id}/profile",
|
||||
name="user_profile",
|
||||
description="User profile by ID",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def read_resource(
|
||||
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
|
||||
) -> types.ReadResourceResult:
|
||||
uri = str(params.uri)
|
||||
if uri == "config://app":
|
||||
text = json.dumps({"debug": False, "version": "1.0"})
|
||||
elif uri.startswith("users://"):
|
||||
user_id = uri.split("/")[2]
|
||||
text = json.dumps({"id": user_id, "name": f"User {user_id}"})
|
||||
else:
|
||||
raise ValueError(f"Unknown resource: {uri}")
|
||||
return types.ReadResourceResult(
|
||||
contents=[
|
||||
types.TextResourceContents(
|
||||
uri=params.uri, mime_type="application/json", text=text
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
server = Server(
|
||||
"data",
|
||||
on_list_resources=list_resources,
|
||||
on_list_resource_templates=list_resource_templates,
|
||||
on_read_resource=read_resource,
|
||||
)
|
||||
```
|
||||
|
||||
```python After
|
||||
import json
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("data")
|
||||
|
||||
|
||||
@mcp.resource("config://app", mime_type="application/json")
|
||||
def app_config() -> str:
|
||||
"""Application configuration"""
|
||||
return json.dumps({"debug": False, "version": "1.0"})
|
||||
|
||||
|
||||
@mcp.resource("users://{user_id}/profile", mime_type="application/json")
|
||||
def user_profile(user_id: str) -> str:
|
||||
"""User profile by ID"""
|
||||
return json.dumps({"id": user_id, "name": f"User {user_id}"})
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
The URI does the routing. A `{placeholder}` in the URI makes the resource a template, and FastMCP matches the parameter to the function argument of the same name — so the `uri.split("/")[2]` parsing goes away along with the handler that held it. Return a `str` for text content and `bytes` for binary; FastMCP builds the `TextResourceContents` or `BlobResourceContents` wrapper.
|
||||
|
||||
Templated resources also gain a protection the low-level version left to you: FastMCP screens extracted parameter values for path traversal, absolute paths, and null bytes before your function runs. See [Path Security](/servers/resources#path-security) if a template legitimately accepts those values.
|
||||
|
||||
## Prompts
|
||||
|
||||
The same collapse, one more time: `on_list_prompts` declares arguments as `PromptArgument` objects, `on_get_prompt` routes by name and assembles a `GetPromptResult` of `PromptMessage` objects. FastMCP takes a function whose parameters are the arguments.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
import mcp_types as types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
|
||||
async def list_prompts(ctx: ServerRequestContext, params) -> types.ListPromptsResult:
|
||||
return types.ListPromptsResult(
|
||||
prompts=[
|
||||
types.Prompt(
|
||||
name="review_code",
|
||||
description="Review code for issues",
|
||||
arguments=[
|
||||
types.PromptArgument(
|
||||
name="code", description="The code to review", required=True
|
||||
),
|
||||
types.PromptArgument(
|
||||
name="language", description="Programming language", required=False
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def get_prompt(
|
||||
ctx: ServerRequestContext, params: types.GetPromptRequestParams
|
||||
) -> types.GetPromptResult:
|
||||
if params.name != "review_code":
|
||||
raise ValueError(f"Unknown prompt: {params.name}")
|
||||
arguments = params.arguments or {}
|
||||
language = arguments.get("language", "")
|
||||
note = f" (written in {language})" if language else ""
|
||||
text = f"Please review this code{note}:\n\n{arguments.get('code', '')}"
|
||||
return types.GetPromptResult(
|
||||
description="Code review prompt",
|
||||
messages=[
|
||||
types.PromptMessage(
|
||||
role="user", content=types.TextContent(type="text", text=text)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
server = Server("prompts", on_list_prompts=list_prompts, on_get_prompt=get_prompt)
|
||||
```
|
||||
|
||||
```python After
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("prompts")
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def review_code(code: str, language: str | None = None) -> str:
|
||||
"""Review code for issues"""
|
||||
note = f" (written in {language})" if language else ""
|
||||
return f"Please review this code{note}:\n\n{code}"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Returning a `str` wraps it as a single user message. Whether an argument is required is read from the signature: `code` has no default, so it's required; `language` defaults to `None`, so it isn't. Multi-turn prompts return a list of `Message` objects, which take their text positionally and default to the user role:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.prompts import Message
|
||||
|
||||
mcp = FastMCP("prompts")
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def debug_session(error: str) -> list[Message]:
|
||||
"""Start a debugging conversation"""
|
||||
return [
|
||||
Message(f"I'm seeing this error:\n\n{error}"),
|
||||
Message("I'll help you debug that. Can you share the relevant code?", role="assistant"),
|
||||
]
|
||||
```
|
||||
|
||||
## Request Context
|
||||
|
||||
The low-level class hands each handler a `ServerRequestContext` carrying the raw `ServerSession`, and you reach through it to send notifications. FastMCP injects a typed `Context` into any function that declares one, and puts the operations you actually want on it directly.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
import mcp_types as types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
|
||||
async def call_tool(
|
||||
ctx: ServerRequestContext, params: types.CallToolRequestParams
|
||||
) -> types.CallToolResult:
|
||||
if params.name == "process_data":
|
||||
await ctx.session.send_log_message(level="info", data="Starting processing...")
|
||||
await ctx.session.report_progress(1, 2)
|
||||
# ... do work ...
|
||||
await ctx.session.send_log_message(level="info", data="Done!")
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Processed")]
|
||||
)
|
||||
raise ValueError(f"Unknown tool: {params.name}")
|
||||
|
||||
|
||||
server = Server("worker", on_call_tool=call_tool)
|
||||
```
|
||||
|
||||
```python After
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP("worker")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def process_data(ctx: Context) -> str:
|
||||
"""Process data with progress logging"""
|
||||
await ctx.info("Starting processing...")
|
||||
await ctx.report_progress(1, 2)
|
||||
# ... do work ...
|
||||
await ctx.info("Done!")
|
||||
return "Processed"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
The `Context` parameter is injected by type annotation and never appears in the tool's schema, so clients see `process_data` as taking no arguments. Beyond logging and progress, it carries resource reads, [session state](/servers/sessions), elicitation, and component visibility — see [Context](/servers/context) for the full surface.
|
||||
|
||||
One thing to check as you migrate: `ctx.session` still exists on a FastMCP `Context` as an escape hatch, and it hands back the same raw SDK session your handlers use today. That makes it a working translation for anything with no `Context` equivalent — but it's also the one part of your server that stays coupled to SDK internals, so reach for the `Context` method first and keep the escape hatch for what genuinely has no equivalent.
|
||||
|
||||
## Complete Example
|
||||
|
||||
Everything above, applied at once:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before expandable
|
||||
import json
|
||||
|
||||
import mcp_types as types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="greet",
|
||||
description="Greet someone by name",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def call_tool(
|
||||
ctx: ServerRequestContext, params: types.CallToolRequestParams
|
||||
) -> types.CallToolResult:
|
||||
if params.name == "greet":
|
||||
name = (params.arguments or {})["name"]
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text=f"Hello, {name}!")]
|
||||
)
|
||||
raise ValueError(f"Unknown tool: {params.name}")
|
||||
|
||||
|
||||
async def list_resources(ctx: ServerRequestContext, params) -> types.ListResourcesResult:
|
||||
return types.ListResourcesResult(
|
||||
resources=[
|
||||
types.Resource(
|
||||
uri="info://version", name="version", description="Server version"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def read_resource(
|
||||
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
|
||||
) -> types.ReadResourceResult:
|
||||
if str(params.uri) != "info://version":
|
||||
raise ValueError(f"Unknown resource: {params.uri}")
|
||||
return types.ReadResourceResult(
|
||||
contents=[
|
||||
types.TextResourceContents(
|
||||
uri=params.uri, text=json.dumps({"version": "1.0.0"})
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def list_prompts(ctx: ServerRequestContext, params) -> types.ListPromptsResult:
|
||||
return types.ListPromptsResult(
|
||||
prompts=[
|
||||
types.Prompt(
|
||||
name="summarize",
|
||||
description="Summarize text",
|
||||
arguments=[types.PromptArgument(name="text", required=True)],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def get_prompt(
|
||||
ctx: ServerRequestContext, params: types.GetPromptRequestParams
|
||||
) -> types.GetPromptResult:
|
||||
if params.name != "summarize":
|
||||
raise ValueError(f"Unknown prompt: {params.name}")
|
||||
text = (params.arguments or {}).get("text", "")
|
||||
return types.GetPromptResult(
|
||||
description="Summarize text",
|
||||
messages=[
|
||||
types.PromptMessage(
|
||||
role="user",
|
||||
content=types.TextContent(type="text", text=f"Summarize:\n\n{text}"),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
server = Server(
|
||||
"demo",
|
||||
on_list_tools=list_tools,
|
||||
on_call_tool=call_tool,
|
||||
on_list_resources=list_resources,
|
||||
on_read_resource=read_resource,
|
||||
on_list_prompts=list_prompts,
|
||||
on_get_prompt=get_prompt,
|
||||
)
|
||||
```
|
||||
|
||||
```python After
|
||||
import json
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("demo")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
|
||||
@mcp.resource("info://version")
|
||||
def version() -> str:
|
||||
"""Server version"""
|
||||
return json.dumps({"version": "1.0.0"})
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def summarize(text: str) -> str:
|
||||
"""Summarize text"""
|
||||
return f"Summarize:\n\n{text}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## What You Gain
|
||||
|
||||
Deleting the handler machinery is the immediate payoff, but the reason to make this move is what becomes available once your server is a FastMCP server.
|
||||
|
||||
[Server composition](/servers/composition) mounts one server inside another, so a surface that grew unwieldy as a single dispatch chain splits into modules developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching, with hooks at whichever level of specificity you need — the cross-cutting concerns that, on the low-level class, meant threading the same code through every handler. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control, and the [OpenAPI integration](/integrations/openapi) generates an entire server from an API specification you already have. [Authentication](/servers/auth/authentication) consolidates the SDK's separate token verifier, authorization-server provider, and `AuthSettings` into a single `auth=` provider, with named providers for GitHub, Google, Auth0, Keycloak, and others.
|
||||
|
||||
The change most likely to affect your daily work is [testing](/servers/testing). FastMCP ships a client that connects to a server object in the same Python process, so a test calls your tools directly — no subprocess, no stdio pipes, no transport to stand up.
|
||||
264
docs/getting-started/upgrading/from-mcp-sdk-v1.mdx
Normal file
264
docs/getting-started/upgrading/from-mcp-sdk-v1.mdx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
---
|
||||
title: Upgrading from MCP SDK v1
|
||||
sidebarTitle: "From MCP SDK v1"
|
||||
description: Upgrade from FastMCP 1.0, bundled in v1 of the MCP Python SDK, to the standalone FastMCP framework
|
||||
icon: up
|
||||
---
|
||||
|
||||
If your server starts with `from mcp.server.fastmcp import FastMCP`, you're using FastMCP 1.0 — the version bundled with v1 of the `mcp` package. Upgrading to the standalone FastMCP framework is easy. **For most servers, it's a single import change.**
|
||||
|
||||
```python test="skip"
|
||||
# Before
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
# After
|
||||
from fastmcp import FastMCP
|
||||
```
|
||||
|
||||
That's it. Your `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` decorators, your `mcp.run()` call, and the rest of your server code all work as-is.
|
||||
|
||||
<Tip>
|
||||
**Why upgrade?** FastMCP 1.0 pioneered the Pythonic MCP server experience, and we're proud it was bundled into the `mcp` package. The standalone FastMCP project has since grown into a full framework for taking MCP servers from prototype to production — with composition, middleware, proxy servers, authentication, and much more. Upgrading gives you access to all of that, plus ongoing updates and fixes.
|
||||
</Tip>
|
||||
|
||||
## The SDK v2 Transition
|
||||
|
||||
MCP SDK v2 is a substantial, deliberate modernization of the protocol layer, and part of that work rebuilt the high-level server as `MCPServer` under `mcp.server.mcpserver`. `mcp.server.fastmcp` does not exist there — so a FastMCP 1.0 server meets the change the moment its environment resolves `mcp` to v2:
|
||||
|
||||
```
|
||||
ModuleNotFoundError: No module named 'mcp.server.fastmcp'
|
||||
```
|
||||
|
||||
Often nobody chose that moment. An unpinned `mcp` dependency, a fresh lockfile, or a rebuilt container picks up the new major version and the module your server imports on line one has moved. Nothing is wrong with your code, and nothing is wrong with the SDK — major versions are exactly where a change like this belongs. Your build just crossed it earlier than you planned to.
|
||||
|
||||
Pinning the SDK back restores the old module immediately, with no code changes, and buys you time to choose deliberately:
|
||||
|
||||
```bash
|
||||
pip install "mcp<2"
|
||||
```
|
||||
|
||||
## Two Upgrade Paths
|
||||
|
||||
From here, both directions are reasonable, and which is less work depends on which API you already write.
|
||||
|
||||
**`MCPServer`, the SDK's high-level server**, is a capable, well-designed API and the direct continuation of the SDK's own line. Because it was rebuilt rather than renamed, expect real work: a new class and import, a different decorator call style, and protocol types imported from the standalone `mcp_types` package with snake_case field names.
|
||||
|
||||
**FastMCP** is the import change at the top of this page. It is short for a specific, historical reason: FastMCP 1.0 *is* early FastMCP — it was contributed into the `mcp` package, and the standalone project kept developing that same high-level API. The surface you already write against is the surface FastMCP still offers. FastMCP 4 is itself built on MCP SDK v2, so both paths land you on the same modern protocol layer; FastMCP absorbs the adaptation internally rather than asking your code to do it.
|
||||
|
||||
The claim is narrower than it may sound. It holds for FastMCP 1.0 servers specifically, because of shared lineage — not because one library is better than the other. Both projects are moving the same direction on the same protocol.
|
||||
|
||||
If you have already moved to SDK v2 and write against `MCPServer` today, see [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2). If your server uses the low-level `Server` class rather than the high-level one, see [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1).
|
||||
|
||||
## Install
|
||||
|
||||
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
|
||||
|
||||
```bash
|
||||
pip install "fastmcp==4.0.0b1"
|
||||
# or
|
||||
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.
|
||||
|
||||
<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.
|
||||
|
||||
FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1 — it explains every item below, with the replacement code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs.
|
||||
|
||||
For most servers the entire upgrade is the first item. Work through the rest looking for signals, and report only what you actually find.
|
||||
|
||||
THE IMPORT (every server needs this)
|
||||
- `from mcp.server.fastmcp import FastMCP` → `from fastmcp import FastMCP`
|
||||
- `from mcp.server.fastmcp import Context`
|
||||
- `from mcp.server.fastmcp import Image`
|
||||
|
||||
CONSTRUCTOR ARGUMENTS THAT MOVED (all raise TypeError)
|
||||
- moved to run()/http_app(), and FastMCP names them in the error: host, port, log_level, debug, sse_path, message_path, streamable_http_path, json_response, stateless_http
|
||||
- moved but rejected with only a generic "unexpected keyword argument", so flag these explicitly: `event_store=` (→ `http_app(event_store=...)`; dropping it silently disables streamable-HTTP resumability), `mount_path=` (→ `http_app(path=...)`), `transport=` (→ `run(transport=...)`), `transport_security=` (→ host/origin settings on `http_app()`), `warn_on_duplicate_tools/_resources/_prompts=` (→ one `on_duplicate=`), `dependencies=` (→ a fastmcp.json file)
|
||||
- `name`, `instructions`, `website_url`, `icons`, `tools`, `lifespan` carry over unchanged
|
||||
- note when reporting: FastMCP names the streamable HTTP transport "http", not "streamable-http"
|
||||
|
||||
CONTEXT METHODS WITH CHANGED SIGNATURES (compile fine, fail at runtime)
|
||||
- `ctx.log(level, data)` → `ctx.log(message, level=...)`, message first
|
||||
- `ctx.info(data)` / `debug` / `warning` / `error` → take a str message, not arbitrary JSON-serializable data
|
||||
- `ctx.elicit(..., schema=Model)` → `response_type=Model`
|
||||
- `ctx.read_resource(uri)` → returns a `ResourceResult`; read `.contents` rather than iterating the return value
|
||||
- `ctx.report_progress`, `ctx.request_id`, `ctx.client_id` are unchanged
|
||||
|
||||
AUTHENTICATION (the one case where the single import change is NOT enough)
|
||||
- `token_verifier=` and `auth_server_provider=` — both raise TypeError on FastMCP 4
|
||||
- `auth=AuthSettings(...)` — the keyword survives but the value does not: FastMCP's `auth=` takes a FastMCP `AuthProvider`, not the SDK settings object
|
||||
Report these as a real migration, not a rename: FastMCP consolidates all three into one provider, and ships `JWTVerifier` for tokens you already issue, `RemoteAuthProvider` for delegating to an external authorization server, `OAuthProxy` for wrapping a provider without Dynamic Client Registration, and named providers for GitHub, Google, Auth0, Keycloak, and others. Look up the right one at https://gofastmcp.com/servers/auth/authentication rather than guessing.
|
||||
|
||||
PROMPT RETURN VALUES
|
||||
- prompt functions returning `PromptMessage`, or `TextContent`-wrapped content
|
||||
- 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
|
||||
- `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
|
||||
|
||||
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.
|
||||
|
||||
For each item found, show the original line, name what changed, and give the corrected code from the guide. If the only change needed is the import, say so plainly rather than manufacturing work.
|
||||
</Prompt>
|
||||
|
||||
## What Might Need Updating
|
||||
|
||||
Most servers need nothing beyond the import change. Skim the sections below to see if any apply.
|
||||
|
||||
### Constructor Settings
|
||||
|
||||
If you passed transport settings like `host` or `port` directly to `FastMCP()`, those now belong on `run()`. This keeps your server definition independent of how it's deployed:
|
||||
|
||||
```python test="skip"
|
||||
from fastmcp import FastMCP
|
||||
|
||||
# Before
|
||||
mcp = FastMCP("my-server", host="0.0.0.0", port=8080)
|
||||
mcp.run()
|
||||
|
||||
# After
|
||||
mcp = FastMCP("my-server")
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8080)
|
||||
```
|
||||
|
||||
Nine arguments move this way, and each raises a `TypeError` naming its own replacement, so you can also just run the server and follow the errors: `host`, `port`, `log_level`, `debug`, `sse_path`, `message_path`, `streamable_http_path`, `json_response`, and `stateless_http`.
|
||||
|
||||
A second group is rejected with only a generic "unexpected keyword argument" and no hint, which makes these the ones worth reading in advance:
|
||||
|
||||
| SDK v1 `FastMCP(...)` | FastMCP 4 |
|
||||
|---|---|
|
||||
| `event_store=` | `mcp.http_app(event_store=...)` |
|
||||
| `mount_path=` | `mcp.http_app(path=...)` |
|
||||
| `transport=` | `mcp.run(transport=...)` |
|
||||
| `transport_security=` | `host_origin_protection=`, `allowed_hosts=`, `allowed_origins=` on `http_app()` |
|
||||
| `warn_on_duplicate_tools=`, `_resources=`, `_prompts=` | a single `on_duplicate=` |
|
||||
| `dependencies=[...]` | a [`fastmcp.json`](/deployment/server-configuration) configuration file |
|
||||
| `auth_server_provider=`, `token_verifier=` | a single `auth=` provider — see [Authentication](#authentication) below |
|
||||
|
||||
Dropping `event_store=` rather than moving it is the one to watch: it silently disables streamable-HTTP resumability, so a client that reconnects loses the events it missed instead of replaying them.
|
||||
|
||||
`name`, `instructions`, `website_url`, `icons`, `tools`, and `lifespan` carry over to the constructor unchanged.
|
||||
|
||||
### Authentication
|
||||
|
||||
This is the one case where the import change alone won't do. FastMCP 1.0 exposed the SDK's auth plumbing as three separate constructor arguments — `token_verifier=`, `auth_server_provider=`, and `auth=AuthSettings(...)`. The first two raise `TypeError` on FastMCP 4, and while `auth=` survives as a keyword, its value doesn't: FastMCP expects one of its own `AuthProvider` objects rather than the SDK's settings object.
|
||||
|
||||
The replacement is a single provider carrying the whole configuration, chosen by what you're actually doing:
|
||||
|
||||
| What you were doing | FastMCP provider |
|
||||
|---|---|
|
||||
| Validating JWTs you already issue | `JWTVerifier` |
|
||||
| Delegating to an external authorization server | `RemoteAuthProvider` |
|
||||
| Wrapping a provider without Dynamic Client Registration | `OAuthProxy` |
|
||||
| GitHub, Google, Auth0, Keycloak, WorkOS, … | the matching named provider |
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import JWTVerifier
|
||||
|
||||
mcp = FastMCP("my-server", auth=JWTVerifier(jwks_uri="https://example.com/.well-known/jwks.json"))
|
||||
```
|
||||
|
||||
See [Authentication](/servers/auth/authentication) for the full set and their configuration.
|
||||
|
||||
### Context Methods
|
||||
|
||||
`from fastmcp import Context` gets you the injected context object, but four of its methods took a different shape in FastMCP 1.0, and a bare import swap leaves calls that compile and then fail:
|
||||
|
||||
| SDK v1 | FastMCP 4 |
|
||||
|---|---|
|
||||
| `ctx.log(level, data)` | `ctx.log(message, level=...)` — message is first now |
|
||||
| `ctx.info(data)` and its `debug`/`warning`/`error` siblings | take a `str` message, where v1 accepted any JSON-serializable value |
|
||||
| `ctx.elicit(message, schema=Model)` | `ctx.elicit(message, response_type=Model)` |
|
||||
| `ctx.read_resource(uri)` | returns a `ResourceResult`; the payload is under `.contents` rather than being iterable directly |
|
||||
|
||||
`ctx.report_progress()`, `ctx.request_id`, and `ctx.client_id` are unchanged.
|
||||
|
||||
### Prompts
|
||||
|
||||
If your prompt functions return `mcp.types.PromptMessage` objects or raw dicts with `role`/`content` keys, upgrade them to FastMCP's `Message` class. Or just return a plain string — it's automatically wrapped as a user message. FastMCP 1.0 silently coerced dicts into messages; standalone FastMCP requires typed `Message` objects or strings.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("prompts")
|
||||
|
||||
@mcp.prompt
|
||||
def review(code: str) -> str:
|
||||
"""Review code for issues"""
|
||||
return f"Please review this code:\n\n{code}"
|
||||
```
|
||||
|
||||
Multi-turn prompts return a list of messages. `Message` takes the text positionally and defaults to the user role, so only the assistant turns need a `role`:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.prompts import Message
|
||||
|
||||
mcp = FastMCP("prompts")
|
||||
|
||||
@mcp.prompt
|
||||
def debug(error: str) -> list[Message]:
|
||||
"""Start a debugging session"""
|
||||
return [
|
||||
Message(f"I'm seeing this error:\n\n{error}"),
|
||||
Message("I'll help debug that. Can you share the relevant code?", role="assistant"),
|
||||
]
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
Where FastMCP provides its own API for the same thing, it's worth switching over rather than importing the protocol type:
|
||||
|
||||
| MCP SDK v1 | FastMCP equivalent |
|
||||
|---|---|
|
||||
| `mcp.types.TextContent(type="text", text=str(x))` | Just return `x` from your tool |
|
||||
| `mcp.types.ImageContent(...)` | `from fastmcp.utilities.types import Image` |
|
||||
| `mcp.types.PromptMessage(...)` | `from fastmcp.prompts import Message` |
|
||||
| `mcp.server.fastmcp.Context` | `from fastmcp import Context` |
|
||||
| `from mcp.server.stdio import stdio_server` | Not needed — `mcp.run()` handles transport |
|
||||
|
||||
For protocol types without a FastMCP equivalent, import them from `mcp_types` directly.
|
||||
|
||||
### Decorated Functions
|
||||
|
||||
In FastMCP 1.0, `@mcp.tool` replaced your function with a `FunctionTool` object. Now decorators return your original function unchanged, so decorated functions stay callable for testing, reuse, and composition:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("greeter")
|
||||
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# This works now — the function is still a regular function
|
||||
assert greet("World") == "Hello, World!"
|
||||
```
|
||||
|
||||
Code that reads `.name`, `.description`, or other component attributes off the decorated result needs updating. This is uncommon — most servers never touch the tool object. When you do need the component itself, reach it through the server with `await mcp.get_tool("greet")`.
|
||||
|
||||
## Verifying the Upgrade
|
||||
|
||||
Run your server the way you always have. To confirm every component came across, inspect the server with the FastMCP CLI:
|
||||
|
||||
```bash
|
||||
fastmcp inspect my_server.py
|
||||
```
|
||||
|
||||
The output lists every tool, resource, template, and prompt your server exposes, so a component that failed to register shows up here rather than at the first client call.
|
||||
|
||||
## Looking Ahead
|
||||
|
||||
The MCP ecosystem is evolving fast. Part of FastMCP's job is to absorb that complexity on your behalf — as the protocol and its tooling grow, we do the work so your server code doesn't have to change. The SDK v1 to v2 transition is the clearest example so far: an entire protocol layer was rewritten underneath FastMCP 4, and the servers on this page cross it with one line.
|
||||
325
docs/getting-started/upgrading/from-mcp-sdk-v2.mdx
Normal file
325
docs/getting-started/upgrading/from-mcp-sdk-v2.mdx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
---
|
||||
title: Upgrading from MCP SDK v2
|
||||
sidebarTitle: "From MCP SDK v2"
|
||||
description: Move a server built on the MCP Python SDK v2's MCPServer class to FastMCP
|
||||
icon: up
|
||||
---
|
||||
|
||||
If your server starts with `from mcp.server.mcpserver import MCPServer`, you're using the high-level server API introduced in v2 of the `mcp` package. Moving to FastMCP is a mechanical migration: the two APIs share a lineage, so most of your code carries over with a rename.
|
||||
|
||||
```python
|
||||
# Before
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
server = MCPServer("my-server")
|
||||
|
||||
@server.tool()
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# After
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name"""
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
That resemblance is not a coincidence. `MCPServer` is the SDK's successor to FastMCP 1.0, the high-level server that shipped inside SDK v1; FastMCP is the standalone framework that grew from the same starting point. Both derive the protocol layer from your function signatures — type hints become JSON Schema, docstrings become descriptions, return values are serialized for you. What separates them is scope: `MCPServer` is the SDK's ergonomic surface over the protocol, while FastMCP builds on that same SDK v2 and adds the machinery a server needs in production — composition, middleware, proxying, authentication providers, tool transformation, a client, and a testing story.
|
||||
|
||||
<Note>
|
||||
Building on the low-level `Server` class instead? See [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2). Still on SDK v1's `mcp.server.fastmcp.FastMCP`? Your upgrade is a single import — see [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1).
|
||||
</Note>
|
||||
|
||||
<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance.">
|
||||
You are migrating an MCP server from the MCP Python SDK v2's high-level `MCPServer` class (`mcp.server.mcpserver`) to FastMCP 4. The two APIs are close relatives, so most of this is mechanical renaming.
|
||||
|
||||
FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2 — it carries the full mapping table and before-and-after code for everything below. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs.
|
||||
|
||||
Then work through the provided code looking for each of these.
|
||||
|
||||
IMPORTS AND CONSTRUCTION
|
||||
- `MCPServer`, and `Context`, `Image`, `Audio`, `Message` imported from `mcp.server.mcpserver`
|
||||
- `mcp_types` imports — these are UNCHANGED. FastMCP 4 builds on the same SDK v2, so leave them alone and say so.
|
||||
|
||||
DECORATORS
|
||||
- `@server.tool()`, `@server.prompt()` — FastMCP takes a bare `@mcp.tool` / `@mcp.prompt` (and still accepts the called form)
|
||||
- `@server.resource(...)`, `@server.completion()`, `@server.custom_route(...)`
|
||||
|
||||
TRANSPORT
|
||||
- `run(transport="streamable-http")` — FastMCP names this transport "http"
|
||||
- `streamable_http_app()`, `sse_app()`
|
||||
|
||||
CONSTRUCTOR ARGUMENTS THAT DO NOT CARRY OVER
|
||||
- `debug=`, `log_level=`
|
||||
- `warn_on_duplicate_tools=` / `_resources=` / `_prompts=`
|
||||
- `dependencies=`
|
||||
- `title=`, `description=`
|
||||
- `token_verifier=`, `auth_server_provider=`, `auth=AuthSettings(...)` — FastMCP consolidates all three into one `auth=` provider
|
||||
- `cache_hints=`
|
||||
- `extensions=`
|
||||
- `tools=[...]` (rare — the SDK's `Tool` type is not exported): FastMCP takes plain callables, so pass the underlying functions
|
||||
These raise TypeError, most naming their replacement. `name`, `version`, `instructions`, `icons`, `website_url`, `lifespan`, `resource_security`, and `request_state_security` carry over unchanged.
|
||||
|
||||
CONTEXT — these ten properties do NOT exist on FastMCP's Context and raise AttributeError if you only swap the import:
|
||||
- `ctx.mcp_server` → `ctx.fastmcp`
|
||||
- `ctx.headers` → `get_http_headers()` from `fastmcp.server.dependencies` (a function, not a property)
|
||||
- `ctx.protocol_version` → `ctx.request_context.protocol_version`
|
||||
- `ctx.client_capabilities` → read it off `ctx.session` / `ctx.request_context`
|
||||
- `ctx.notify_tools_changed()`, `notify_resources_changed()`, `notify_prompts_changed()`, `notify_resource_updated()` → `ctx.send_notification(...)` with the matching `mcp_types` notification. FastMCP emits the list-changed ones for you when components change visibility through `ctx.enable_components` / `ctx.disable_components`.
|
||||
- `ctx.elicit_url` → not the same thing as `ctx.elicit` (that one is form elicitation, with a different signature and wire behavior). The URL flow survives on the raw session as `ctx.session.elicit_url(...)` — use that rather than deleting an OAuth or payment handoff.
|
||||
- `ctx.close_standalone_sse_stream` → no public FastMCP equivalent, and NOT on `ctx.request_context`. Flag it for the user.
|
||||
These four exist on both but with DIFFERENT signatures, so a bare import swap compiles and then fails at runtime:
|
||||
- `ctx.log(level, data)` → `ctx.log(message, level=...)` — the first positional argument is now the message, not the level
|
||||
- `ctx.info(data)` / `debug` / `warning` / `error` → these take `message` as a string, where the SDK accepted any JSON-serializable `data`
|
||||
- `ctx.elicit(message, schema=Model)` → `ctx.elicit(message, response_type=Model)` — the keyword was renamed
|
||||
- `ctx.read_resource(uri)` → still takes a URI, but returns a `ResourceResult` whose payload is under `.contents`, where the SDK returned an iterable of content objects directly. Code that iterates or indexes the return value needs updating.
|
||||
|
||||
Genuinely unchanged: `report_progress`, `request_id`, `client_id`, `input_responses`, `request_state`, `session`, and `request_context`.
|
||||
|
||||
RESOLVERS — the one part that is not a rename, so check for it first
|
||||
- any `Annotated[T, Resolve(fn)]` parameter, and the resolvers behind it
|
||||
- resolvers returning `Elicit[...]`, `Sample`, or `ListRoots`
|
||||
FastMCP has no resolver injection, but the underlying requests survive in a different shape: on a modern connection `Elicit`, `Sample`, and `ListRoots` all ride the guard pattern, where the tool returns an `InputRequiredResult` and the client answers on the next call. Do not tell the user these capabilities are simply unavailable. Flag every resolver with the guide's per-capability reasoning (server-side LLM call is usually better than guard-routed sampling; roots are often simplest as ordinary tool arguments) rather than picking a rewrite yourself. Also note that a resolved parameter is hidden from the tool's input schema, so replacing it with an ordinary argument changes the schema clients see.
|
||||
|
||||
For each item found, show the original code, name what changed, and give the FastMCP equivalent from the guide. Call out anything you could not find a documented replacement for instead of inventing one.
|
||||
</Prompt>
|
||||
|
||||
## Install
|
||||
|
||||
FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
|
||||
|
||||
```bash
|
||||
pip install "fastmcp==4.0.0b1"
|
||||
# or
|
||||
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 4 depends on the MCP SDK v2, so nothing you already import from `mcp_types` moves. That is the practical benefit of migrating at this version rather than an earlier one: you and FastMCP are on the same protocol layer, with the same snake_case field names and the same type package, so the migration touches only the server API.
|
||||
|
||||
## The Mechanical Part
|
||||
|
||||
Most of the work is renaming. This table covers the surfaces a typical `MCPServer` server touches:
|
||||
|
||||
| MCP SDK v2 | FastMCP |
|
||||
|---|---|
|
||||
| `from mcp.server.mcpserver import MCPServer` | `from fastmcp import FastMCP` |
|
||||
| `from mcp.server.mcpserver import Context` | `from fastmcp import Context` |
|
||||
| `from mcp.server.mcpserver import Image, Audio` | `from fastmcp.utilities.types import Image, Audio` |
|
||||
| `from mcp.server.mcpserver.prompts.base import Message` | `from fastmcp.prompts import Message` |
|
||||
| `@server.tool()` | `@mcp.tool` |
|
||||
| `@server.prompt()` | `@mcp.prompt` |
|
||||
| `@server.resource("uri://x")` | `@mcp.resource("uri://x")` |
|
||||
| `@server.completion()` | `@mcp.completion` |
|
||||
| `@server.custom_route(path, methods)` | `@mcp.custom_route(path, methods)` |
|
||||
| `server.run(transport="streamable-http")` | `mcp.run(transport="http")` |
|
||||
| `server.streamable_http_app()` | `mcp.http_app()` |
|
||||
| `server.sse_app()` | `mcp.http_app(transport="sse")` |
|
||||
| `ctx.mcp_server` | `ctx.fastmcp` |
|
||||
| `ctx.headers` | `get_http_headers()` from `fastmcp.server.dependencies` |
|
||||
| `ctx.protocol_version` | `ctx.request_context.protocol_version` |
|
||||
| `ctx.client_capabilities` | read it off `ctx.session` |
|
||||
| `from mcp_types import X` | unchanged |
|
||||
|
||||
Two of these are worth a sentence each. The decorators lose their parentheses: `MCPServer` required `@server.tool()` and raised a `TypeError` telling you so if you wrote `@server.tool`, while FastMCP accepts both forms, so `@mcp.tool` is the idiomatic spelling and `@mcp.tool()` keeps working if you'd rather not touch every line. And the streamable HTTP transport is named `"http"` in FastMCP rather than `"streamable-http"` — the transport is the same, and `mcp.run()` still defaults to stdio.
|
||||
|
||||
Here is a complete server before and after. Nothing in the logic changes:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Before
|
||||
import json
|
||||
from mcp.server.mcpserver import MCPServer, Context
|
||||
|
||||
server = MCPServer("demo")
|
||||
|
||||
@server.tool()
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
@server.tool()
|
||||
async def process(items: list[str], ctx: Context) -> str:
|
||||
"""Process a batch of items"""
|
||||
for i, item in enumerate(items):
|
||||
await ctx.report_progress(i, len(items))
|
||||
return f"Processed {len(items)} items"
|
||||
|
||||
@server.resource("config://app", mime_type="application/json")
|
||||
def app_config() -> str:
|
||||
"""Application configuration"""
|
||||
return json.dumps({"debug": False})
|
||||
|
||||
@server.resource("users://{user_id}/profile")
|
||||
def profile(user_id: str) -> str:
|
||||
"""User profile by ID"""
|
||||
return json.dumps({"id": user_id})
|
||||
|
||||
@server.prompt()
|
||||
def summarize(text: str) -> str:
|
||||
"""Summarize text"""
|
||||
return f"Summarize:\n\n{text}"
|
||||
|
||||
if __name__ == "__main__":
|
||||
server.run(transport="streamable-http")
|
||||
```
|
||||
|
||||
```python After
|
||||
import json
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP("demo")
|
||||
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone by name"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
@mcp.tool
|
||||
async def process(items: list[str], ctx: Context) -> str:
|
||||
"""Process a batch of items"""
|
||||
for i, item in enumerate(items):
|
||||
await ctx.report_progress(i, len(items))
|
||||
return f"Processed {len(items)} items"
|
||||
|
||||
@mcp.resource("config://app", mime_type="application/json")
|
||||
def app_config() -> str:
|
||||
"""Application configuration"""
|
||||
return json.dumps({"debug": False})
|
||||
|
||||
@mcp.resource("users://{user_id}/profile")
|
||||
def profile(user_id: str) -> str:
|
||||
"""User profile by ID"""
|
||||
return json.dumps({"id": user_id})
|
||||
|
||||
@mcp.prompt
|
||||
def summarize(text: str) -> str:
|
||||
"""Summarize text"""
|
||||
return f"Summarize:\n\n{text}"
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="http")
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Constructor Arguments
|
||||
|
||||
`FastMCP()` describes your server's identity and behavior; how it gets deployed is decided when you serve it. Several `MCPServer` constructor arguments move accordingly, and each raises a `TypeError` naming its replacement rather than being silently ignored.
|
||||
|
||||
`name`, `version`, `instructions`, `icons`, `website_url`, `lifespan`, `resource_security`, and `request_state_security` all mean what they meant before. The rest map like this:
|
||||
|
||||
| `MCPServer(...)` | FastMCP |
|
||||
|---|---|
|
||||
| `debug=True` | `FASTMCP_DEBUG` environment variable |
|
||||
| `log_level="DEBUG"` | `run_http_async(log_level=...)` or `FASTMCP_LOG_LEVEL` |
|
||||
| `warn_on_duplicate_tools`, `_resources`, `_prompts` | a single `on_duplicate=` |
|
||||
| `dependencies=[...]` | a [`fastmcp.json`](/deployment/server-configuration) configuration file |
|
||||
| `title=`, `description=` | `instructions=` |
|
||||
| `tools=[Tool, ...]` | `tools=[callable, ...]`, or FastMCP's own `Tool` |
|
||||
| `resources=[Resource, ...]` | no constructor keyword — register with `@mcp.resource` or `mcp.add_resource()` |
|
||||
| `subscriptions=<SubscriptionBus>` | no equivalent — see below |
|
||||
| `token_verifier=`, `auth_server_provider=`, `auth=AuthSettings(...)` | a single `auth=` provider |
|
||||
| `cache_hints={...}` | `cache_ttl=`, `cache_scope=` |
|
||||
| `extensions=[...]` | `mcp.add_extension(...)` |
|
||||
|
||||
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).
|
||||
|
||||
Two rows are worth reading before you delete the argument. `resources=` has no constructor equivalent, so pre-built `Resource` objects need registering through `@mcp.resource` or `mcp.add_resource()` instead — dropping the keyword silently drops the resources with it. And `subscriptions=`, which an `MCPServer` uses to plug in an external pub/sub bus so resource-update notifications reach clients across replicas, has no FastMCP equivalent at all. A multi-replica deployment that relies on it should confirm it can live without cross-replica subscription fan-out before migrating, because a mechanical rename removes that behavior without any error to warn you.
|
||||
|
||||
### Serving HTTP
|
||||
|
||||
Renaming `streamable_http_app()` to `http_app()` is only mechanical for a call with no arguments. The keywords were renamed and regrouped, so an existing call carries arguments `http_app()` does not accept:
|
||||
|
||||
| SDK v2 | FastMCP |
|
||||
|---|---|
|
||||
| `streamable_http_app(streamable_http_path=...)` | `http_app(path=...)` |
|
||||
| `sse_app(sse_path=...)` | `http_app(path=..., transport="sse")` |
|
||||
| `sse_app(message_path=...)` | no equivalent |
|
||||
| `transport_security=TransportSecuritySettings(...)` | `host_origin_protection=`, `allowed_hosts=`, `allowed_origins=` |
|
||||
| `host=...` | pass to `mcp.run(host=...)` instead |
|
||||
|
||||
`json_response`, `stateless_http`, `event_store`, and `retry_interval` keep their names. See [Deploying HTTP servers](/deployment/http) for the host and origin settings.
|
||||
|
||||
### Stricter Arguments
|
||||
|
||||
One behavior change survives the rename and is worth knowing before you migrate. `MCPServer` binds the arguments it recognizes and ignores the rest, so a call carrying an unexpected key succeeds. FastMCP declares `"additionalProperties": false` on every generated schema and enforces it, so the same call fails:
|
||||
|
||||
```python test="skip"
|
||||
# Against MCPServer: succeeds, "extra" ignored.
|
||||
# Against FastMCP: raises, "extra" is not a parameter of greet().
|
||||
await client.call_tool("greet", {"name": "World", "extra": "surprise"})
|
||||
```
|
||||
|
||||
For most servers this is an improvement that costs nothing — a caller sending keys your tool never reads was already a bug. It matters if a client in your fleet passes extra metadata alongside real arguments, since those calls start failing the moment you migrate. Accept the extras explicitly as optional parameters if you need to keep them working.
|
||||
|
||||
## Asking for Input
|
||||
|
||||
This is the one part of the migration that is not a rename, so read it before you start if your tools use resolvers.
|
||||
|
||||
`MCPServer` asks the client for things through dependency-injection resolvers. A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body, and the resolver can return a request marker — `Elicit[T]` to ask the user, `Sample` to borrow the client's model, `ListRoots` to fetch its roots — which the framework turns into the right wire interaction for whichever protocol era the connection negotiated:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from pydantic import BaseModel
|
||||
from mcp.server.mcpserver import MCPServer, Resolve, Elicit
|
||||
|
||||
server = MCPServer("booking")
|
||||
|
||||
|
||||
class Destination(BaseModel):
|
||||
destination: str
|
||||
|
||||
|
||||
def ask_destination() -> Elicit[Destination]:
|
||||
return Elicit("Where would you like to fly?", Destination)
|
||||
|
||||
|
||||
@server.tool()
|
||||
def book_flight(dest: Annotated[Destination, Resolve(ask_destination)]) -> str:
|
||||
"""Book a flight"""
|
||||
return f"Booked to {dest.destination}"
|
||||
```
|
||||
|
||||
FastMCP has no equivalent annotation, and it makes the protocol era explicit instead of hiding it. Which replacement you want depends on which era your clients speak.
|
||||
|
||||
On **handshake-era connections** (≤ 2025-11-25), a running tool asks the user directly with `ctx.elicit()`, and the call blocks until the answer arrives. Where the resolver returned a value or aborted the call, `ctx.elicit()` hands you the outcome to branch on, so declining and cancelling become cases your tool answers for itself:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP("booking")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def book_flight(ctx: Context) -> str:
|
||||
"""Book a flight"""
|
||||
result = await ctx.elicit("Where would you like to fly?", response_type=str)
|
||||
if result.action == "accept":
|
||||
return f"Booked to {result.data}"
|
||||
return "Booking cancelled"
|
||||
```
|
||||
|
||||
On the **modern protocol** (2026-07-28), server-initiated requests are gone from the wire, so a tool asks by *returning* a description of what it needs. The client answers and calls the tool again with the answer attached, and the tool re-runs from the top. This is the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), and it reads the answers off `ctx.input_responses`.
|
||||
|
||||
The two are era-gated in both directions: `ctx.elicit()` raises on a modern connection, and a guard result raises on a handshake one. A server that must serve both branches on `ctx.request_context.protocol_version`. See [Elicitation](/servers/elicitation#which-approach-to-use) for both shapes side by side.
|
||||
|
||||
Resolvers that return `Sample` or `ListRoots` have no *injected* equivalent — FastMCP has no `ctx.sample()` or `ctx.list_roots()` — but the underlying request survives, so this is a change of shape rather than a loss of capability. On a modern connection both ride the same guard pattern as elicitation: the tool returns an `InputRequiredResult` describing the sampling or roots request, and the client answers on the next call.
|
||||
|
||||
Which shape you want differs by capability. For **roots**, the guard route is the natural replacement, since one round buys the whole answer — and taking the paths as ordinary tool arguments is simpler still whenever the caller can supply them. For **generation**, prefer [calling an LLM from your server](/servers/sampling) with your own API key: your tool then behaves identically for every client, including the many that never implemented sampling, and you avoid paying a full request-response cycle per generation step. Reach for the guard route when using the *caller's* model is specifically the point.
|
||||
|
||||
One schema detail is easy to miss during the rewrite. A resolved parameter never appears in the tool's input schema — `book_flight` above advertises no arguments at all. When you replace a resolver with an explicit tool argument, the schema the client sees gains a field, which is usually what you want but is a visible change to your tool's contract.
|
||||
|
||||
## What You Gain
|
||||
|
||||
The migration is worth doing for what sits on the other side of it. FastMCP is a framework rather than a protocol surface, and these are the capabilities that most often motivate the move:
|
||||
|
||||
[Server composition](/servers/composition) mounts one server inside another, so a large surface splits into modules that are developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching, with hooks at whichever level of specificity you need. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control. The [OpenAPI integration](/integrations/openapi) generates a whole server from an existing API specification. [Tool transformation](/servers/transforms/transforms) rewrites the tools a server exposes — renaming, hiding, and reshaping arguments — without touching the code that defines them.
|
||||
|
||||
FastMCP also ships a [client](/clients/client), which `MCPServer` has no counterpart for. It speaks every transport, drives both protocol eras, and connects to a server object in-process — so [testing](/servers/testing) a server means calling its tools in the same Python process, with no subprocess and no network.
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
---
|
||||
title: Upgrading from the MCP SDK
|
||||
sidebarTitle: "From MCP SDK"
|
||||
description: Upgrade from FastMCP in the MCP Python SDK to the standalone FastMCP framework
|
||||
icon: up
|
||||
---
|
||||
|
||||
If your server starts with `from mcp.server.fastmcp import FastMCP`, you're using FastMCP 1.0 — the version bundled with v1 of the `mcp` package. Upgrading to the standalone FastMCP framework is easy. **For most servers, it's a single import change.**
|
||||
|
||||
```python
|
||||
# Before
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
# After
|
||||
from fastmcp import FastMCP
|
||||
```
|
||||
|
||||
That's it. Your `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` decorators, your `mcp.run()` call, and the rest of your server code all work as-is.
|
||||
|
||||
<Tip>
|
||||
**Why upgrade?** FastMCP 1.0 pioneered the Pythonic MCP server experience, and we're proud it was bundled into the `mcp` package. The standalone FastMCP project has since grown into a full framework for taking MCP servers from prototype to production — with composition, middleware, proxy servers, authentication, and much more. Upgrading gives you access to all of that, plus ongoing updates and fixes.
|
||||
</Tip>
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install --upgrade fastmcp
|
||||
# or
|
||||
uv add fastmcp
|
||||
```
|
||||
|
||||
FastMCP includes the `mcp` package as a dependency, so you don't lose access to anything. 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 the `mcp` package v1) to standalone FastMCP 4. Analyze the provided code and identify every change needed. The full upgrade guide is at https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context.
|
||||
|
||||
STEP 1 — IMPORT (required for all servers):
|
||||
Change "from mcp.server.fastmcp import FastMCP" to "from fastmcp import FastMCP".
|
||||
|
||||
STEP 2 — CONSTRUCTOR KWARGS (only if FastMCP() receives transport settings):
|
||||
FastMCP() no longer accepts: host, port, log_level, debug, sse_path, streamable_http_path, json_response, stateless_http.
|
||||
Fix: pass these to run() instead.
|
||||
Before: `mcp = FastMCP("server", host="0.0.0.0", port=8080); mcp.run()`
|
||||
After: `mcp = FastMCP("server"); mcp.run(transport="http", host="0.0.0.0", port=8080)`
|
||||
|
||||
STEP 3 — PROMPTS (only if using PromptMessage directly or returning dicts):
|
||||
mcp.types.PromptMessage is replaced by fastmcp.prompts.Message.
|
||||
Before: `PromptMessage(role="user", content=TextContent(type="text", text="Hello"))`
|
||||
After: `Message("Hello")` — role defaults to "user", accepts plain strings.
|
||||
Also: if prompts return raw dicts like `{"role": "user", "content": "..."}`, these must become Message objects or plain strings.
|
||||
The MCP SDK's FastMCP 1.0 silently coerced dicts; standalone FastMCP requires typed returns.
|
||||
|
||||
STEP 4 — OTHER MCP IMPORTS (only if importing from mcp.* directly):
|
||||
FastMCP now builds on MCP SDK v2, which removed the `mcp.types` module — protocol types live in the standalone `mcp_types` package. Update any `from mcp.types import X` to `from mcp_types import X`. Prefer FastMCP's own APIs where equivalents exist:
|
||||
- mcp_types.TextContent for tool returns → just return plain Python values (str, int, dict, etc.)
|
||||
- mcp_types.ImageContent → fastmcp.utilities.types.Image
|
||||
- from mcp.server.stdio import stdio_server → not needed, mcp.run() handles transport
|
||||
|
||||
STEP 5 — DECORATORS (only if treating decorated functions as objects):
|
||||
@mcp.tool, @mcp.resource, @mcp.prompt now return the original function, not a component object. Code that accesses .name or .description on the decorated result needs updating. Set FASTMCP_DECORATOR_MODE=object temporarily to restore v1 behavior (this compat setting is itself deprecated).
|
||||
|
||||
For each issue found, show the original line, explain what changed, and provide the corrected code.
|
||||
</Prompt>
|
||||
|
||||
## What Might Need Updating
|
||||
|
||||
Most servers need nothing beyond the import change. Skim the sections below to see if any apply.
|
||||
|
||||
### Constructor Settings
|
||||
|
||||
If you passed transport settings like `host` or `port` directly to `FastMCP()`, those now belong on `run()`. This keeps your server definition independent of how it's deployed:
|
||||
|
||||
```python
|
||||
# Before
|
||||
mcp = FastMCP("my-server", host="0.0.0.0", port=8080)
|
||||
mcp.run()
|
||||
|
||||
# After
|
||||
mcp = FastMCP("my-server")
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8080)
|
||||
```
|
||||
|
||||
If you pass the old kwargs, you'll get a clear `TypeError` with a migration hint.
|
||||
|
||||
### Prompts
|
||||
|
||||
If your prompt functions return `mcp.types.PromptMessage` objects or raw dicts with `role`/`content` keys, you'll need to upgrade to FastMCP's `Message` class. Or just return a plain string — it's automatically wrapped as a user message. The MCP SDK's bundled FastMCP 1.0 silently coerced dicts into messages; standalone FastMCP requires typed `Message` objects or strings.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("prompts")
|
||||
|
||||
@mcp.prompt
|
||||
def review(code: str) -> str:
|
||||
"""Review code for issues"""
|
||||
return f"Please review this code:\n\n{code}"
|
||||
```
|
||||
|
||||
For multi-turn prompts:
|
||||
|
||||
```python
|
||||
from fastmcp.prompts import Message
|
||||
|
||||
@mcp.prompt
|
||||
def debug(error: str) -> list[Message]:
|
||||
"""Start a debugging session"""
|
||||
return [
|
||||
Message(f"I'm seeing this error:\n\n{error}"),
|
||||
Message("I'll help debug that. Can you share the relevant code?", role="assistant"),
|
||||
]
|
||||
```
|
||||
|
||||
### Other `mcp.*` Imports
|
||||
|
||||
FastMCP now builds on MCP SDK v2. 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 the full picture, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3).
|
||||
|
||||
Where FastMCP provides its own API for the same thing, it's worth switching over:
|
||||
|
||||
| mcp Package | FastMCP Equivalent |
|
||||
|---|---|
|
||||
| `mcp.types.TextContent(type="text", text=str(x))` | Just return `x` from your tool |
|
||||
| `mcp.types.ImageContent(...)` | `from fastmcp.utilities.types import Image` |
|
||||
| `mcp.types.PromptMessage(...)` | `from fastmcp.prompts import Message` |
|
||||
| `from mcp.server.stdio import stdio_server` | Not needed — `mcp.run()` handles transport |
|
||||
|
||||
For protocol types without a FastMCP equivalent, import them from `mcp_types` directly.
|
||||
|
||||
### Decorated Functions
|
||||
|
||||
In FastMCP 1.0, `@mcp.tool` returned a `FunctionTool` object. Now decorators return your original function unchanged — so decorated functions stay callable for testing, reuse, and composition:
|
||||
|
||||
```python
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# This works now — the function is still a regular function
|
||||
assert greet("World") == "Hello, World!"
|
||||
```
|
||||
|
||||
If you have code that accesses `.name`, `.description`, or other attributes on the decorated result, that will need updating. This is uncommon — most servers don't interact with the tool object directly. If you need the old behavior temporarily, set `FASTMCP_DECORATOR_MODE=object` to restore it (this compatibility setting is itself deprecated and will be removed in a future release).
|
||||
|
||||
## Verify the Upgrade
|
||||
|
||||
```bash
|
||||
# Install
|
||||
pip install --upgrade fastmcp
|
||||
|
||||
# Check version
|
||||
fastmcp version
|
||||
|
||||
# Run your server
|
||||
python my_server.py
|
||||
```
|
||||
|
||||
You can also inspect your server's registered components with the FastMCP CLI:
|
||||
|
||||
```bash
|
||||
fastmcp inspect my_server.py
|
||||
```
|
||||
|
||||
## Looking Ahead
|
||||
|
||||
The MCP ecosystem is evolving fast. Part of FastMCP's job is to absorb that complexity on your behalf — as the protocol and its tooling grow, we do the work so your server code doesn't have to change.
|
||||
|
|
@ -66,9 +66,9 @@ Ask for more by annotating more parameters. What happens then is worth knowing:
|
|||
|
||||
A fixed string is the right question only when it is always the right question. Usually it stops being one as soon as you know something: once the traveller has said Paris, the useful thing to ask is not "which airport?" but "CDG or ORY?".
|
||||
|
||||
Pass a function instead of a string and the question gets built at the moment it is asked, out of values that are already known. The function's parameters are filled by name — from the tool's own arguments, from other elicited parameters, or both.
|
||||
Pass a function instead of a string and you get a **resolver** — something that runs when the parameter needs filling and decides what to do about it. A resolver returns `T | Elicit[T]`: an `Elicit` is a question to put to the user, and a plain value is the answer already known, in which case nobody is asked at all.
|
||||
|
||||
That name-matching does double duty. It supplies the values, and it establishes the order: a question that quotes an answer nobody has given yet cannot be written, so it waits for the round that produces it, while every question independent of it still goes out immediately.
|
||||
Its parameters are filled by name — from the tool's own arguments, from other elicited parameters, or both. That name-matching does double duty. It supplies the values, and it establishes the order: a question that quotes an answer nobody has given yet cannot be written, so it waits for the round that produces it, while every question independent of it still goes out immediately.
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
|
@ -79,8 +79,8 @@ from fastmcp.elicitation import Elicit
|
|||
mcp = FastMCP("Booking Server")
|
||||
|
||||
|
||||
def which_airport(destination: str) -> str:
|
||||
return f"Which airport in {destination} — CDG or ORY?"
|
||||
def which_airport(destination: str) -> Elicit[str]:
|
||||
return Elicit(f"Which airport in {destination} — CDG or ORY?", elicit_type=str)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
|
|
@ -91,7 +91,49 @@ async def book_flight(
|
|||
return f"Booked into {airport}"
|
||||
```
|
||||
|
||||
So this tool takes two rounds, and neither the round count nor the ordering appears anywhere in the code. Get the wiring wrong — name a value the tool does not have, or write two questions that each wait on the other — and FastMCP rejects the tool when it is registered, at import time, rather than on the first call in production.
|
||||
So this tool takes two rounds, and neither the round count nor the ordering appears anywhere in the code. Get the wiring wrong — name a value the tool does not have, write two questions that each wait on the other, or declare a resolver that elicits a type its parameter cannot hold — and FastMCP rejects the tool when it is registered, at import time, rather than on the first call in production.
|
||||
|
||||
### Questions worth skipping
|
||||
|
||||
Returning a value rather than an `Elicit` is how a resolver declines to ask. Most of the time you are asking because you genuinely do not know, but plenty of questions have an answer sitting somewhere already — on the user's profile, in an argument the model supplied, in a table with one row:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.dependencies import Depends
|
||||
from fastmcp.elicitation import Elicit
|
||||
|
||||
mcp = FastMCP("Booking Server")
|
||||
|
||||
|
||||
class Profile(BaseModel):
|
||||
home_airport: str | None = None
|
||||
|
||||
|
||||
def current_profile() -> Profile:
|
||||
return Profile(home_airport="LHR")
|
||||
|
||||
|
||||
def which_airport(destination: str, profile: Profile = Depends(current_profile)) -> str | Elicit[str]:
|
||||
if profile.home_airport:
|
||||
return profile.home_airport
|
||||
return Elicit(f"Which airport in {destination}?", elicit_type=str)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def book_flight(
|
||||
destination: str,
|
||||
airport: Annotated[str, Elicit(which_airport)],
|
||||
) -> str:
|
||||
return f"Booked {destination} from {airport}"
|
||||
```
|
||||
|
||||
A returning traveller is never asked and never pays a round trip; a new one gets the question. The tool body is identical either way, and so is the annotation — only the resolver knows the difference.
|
||||
|
||||
State the type with `elicit_type` when you build an `Elicit` inside a resolver. The parameter's annotation is two functions away at that point, and repeating it locally is worth more than the brevity of leaving it out. When a resolver also declares it — `-> str | Elicit[str]` — FastMCP checks the two agree at registration.
|
||||
|
||||
Because a question can quote a tool argument, it can also quote something the model supplied — which is exactly what you want for `f"Which airport in {destination}?"`, and a good reason to treat the wording as untrusted display text rather than as an instruction to the user.
|
||||
|
||||
|
|
@ -106,8 +148,8 @@ from fastmcp.elicitation import Elicit
|
|||
mcp = FastMCP("Booking Server")
|
||||
|
||||
|
||||
def confirm(destination: str, date: str) -> str:
|
||||
return f"Book a flight to {destination} on {date}?"
|
||||
def confirm(destination: str, date: str) -> Elicit[bool]:
|
||||
return Elicit(f"Book a flight to {destination} on {date}?", elicit_type=bool)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
|
|
@ -178,9 +220,9 @@ def search_flights(destination: str, date: str) -> list[str]:
|
|||
return [f"AF{number} to {destination} on {date}" for number in (100, 200)]
|
||||
|
||||
|
||||
def which_flight(destination: str, date: str) -> str:
|
||||
def which_flight(destination: str, date: str) -> Elicit[str]:
|
||||
options = search_flights(destination, date)
|
||||
return f"Which flight? {', '.join(options)}"
|
||||
return Elicit(f"Which flight? {', '.join(options)}", elicit_type=str)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
|
|
@ -710,7 +752,7 @@ connection negotiated '2025-11-25'. Use ctx.elicit() for server-initiated input
|
|||
on handshake-era connections.
|
||||
```
|
||||
|
||||
To support both eras from one tool, [declare the parameter](#declared-parameters) and let FastMCP pick the mechanism. Driving the exchange by hand means writing both paths and branching on `ctx.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones.
|
||||
To support both eras from one tool, [declare the parameter](#declared-parameters) and let FastMCP pick the mechanism. Driving the exchange by hand means writing both paths and branching on `ctx.request_context.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones.
|
||||
|
||||
### Prompts and resources
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ Tools discovered through search can also be called directly via `client.call_too
|
|||
|
||||
Search results respect the full authorization pipeline. Tools filtered by middleware, visibility transforms, or component-level auth checks won't appear in search results.
|
||||
|
||||
App-only tools are excluded too. A [MCP app](/apps/overview) can declare backend tools that only its UI may call, and normally the host keeps those from the model. A search result is tool output rather than an advertised listing, so no host filtering applies to it — the exclusion happens here instead. The `call_tool` proxy enforces the same boundary, since it executes a name the model supplies.
|
||||
|
||||
The search tool queries `list_tools()` through the complete pipeline at search time, so the same filtering that controls what a client sees in the listing also controls what they can discover through search.
|
||||
|
||||
```python
|
||||
|
|
|
|||
|
|
@ -100,9 +100,10 @@ uv pip install fastmcp
|
|||
For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation).
|
||||
|
||||
**Upgrading?** We have guides for:
|
||||
- [Upgrading from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2)
|
||||
- [Upgrading from the MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk)
|
||||
- [Upgrading from the low-level SDK](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk)
|
||||
- [Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3)
|
||||
- [Upgrading from FastMCP 2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2)
|
||||
- [Upgrading from MCP SDK v1](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2)
|
||||
- [Upgrading from the low-level SDK v1](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2)
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
|
|
|
|||
|
|
@ -54,16 +54,20 @@ F = TypeVar("F", bound=Callable[..., Any])
|
|||
|
||||
|
||||
def _make_resolver(app_name: str | None = None) -> Any:
|
||||
"""Create a CallTool resolver that prefixes tool names with a hash.
|
||||
"""Create a CallTool resolver that addresses peer tools by identity.
|
||||
|
||||
Structurally identical to the old ``___`` resolver — ``app_name`` is
|
||||
the FastMCPApp's name, known at serialization time from the tool's
|
||||
``meta["fastmcp"]["app"]`` tag. The only change is the wire format:
|
||||
``<hash>_<local_name>`` instead of ``<app_name>___<local_name>``.
|
||||
``app_name`` is the FastMCPApp's name, known at serialization time from
|
||||
the tool's ``meta["fastmcp"]["app"]`` tag. Serialization happens deep
|
||||
inside whatever composition the server has, so nothing here can know
|
||||
what these tools will be *called* by the time the payload reaches a
|
||||
host. References therefore start out identity-addressed, as
|
||||
``<hash>_<local_name>``.
|
||||
|
||||
The dispatcher recognizes the hashed form and routes it via
|
||||
``get_tool_by_hash`` which walks the provider tree recursively —
|
||||
same pattern as ``get_app_tool``.
|
||||
Each FastMCP server rewrites those references on the way out to the
|
||||
name it lists that tool under, so what a renderer finally receives is
|
||||
an ordinary tool name (see ``server.providers.prefab_payload``). A
|
||||
reference no server could resolve keeps this form, which the dispatcher
|
||||
still routes via ``get_tool_by_hash``.
|
||||
"""
|
||||
from fastmcp.server.providers.addressing import (
|
||||
hashed_backend_name,
|
||||
|
|
@ -227,14 +231,17 @@ class FastMCPApp(Provider):
|
|||
raise ValueError(f"Cannot determine tool name for {fn!r}")
|
||||
|
||||
from fastmcp.apps.config import AppConfig, app_config_to_meta_dict
|
||||
from fastmcp.server.providers.addressing import hash_tool
|
||||
from fastmcp.server.providers.addressing import (
|
||||
TOOL_HASH_META_KEY,
|
||||
hash_tool,
|
||||
)
|
||||
|
||||
app_config = AppConfig(visibility=visibility)
|
||||
meta: dict[str, Any] = {
|
||||
"ui": app_config_to_meta_dict(app_config),
|
||||
"fastmcp": {
|
||||
"app": self.name,
|
||||
"_tool_hash": hash_tool(self.name, resolved_name),
|
||||
TOOL_HASH_META_KEY: hash_tool(self.name, resolved_name),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -318,7 +325,10 @@ class FastMCPApp(Provider):
|
|||
|
||||
def _register(fn: F, tool_name: str | None) -> F:
|
||||
from fastmcp.apps.config import AppConfig, app_config_to_meta_dict
|
||||
from fastmcp.server.providers.addressing import hash_tool
|
||||
from fastmcp.server.providers.addressing import (
|
||||
TOOL_HASH_META_KEY,
|
||||
hash_tool,
|
||||
)
|
||||
from fastmcp.server.providers.local_provider.decorators.tools import (
|
||||
PREFAB_RENDERER_URI,
|
||||
)
|
||||
|
|
@ -334,7 +344,7 @@ class FastMCPApp(Provider):
|
|||
"ui": app_config_to_meta_dict(app_config),
|
||||
"fastmcp": {
|
||||
"app": self.name,
|
||||
"_tool_hash": hash_tool(self.name, resolved),
|
||||
TOOL_HASH_META_KEY: hash_tool(self.name, resolved),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -373,12 +383,15 @@ class FastMCPApp(Provider):
|
|||
if not isinstance(tool, Tool):
|
||||
tool = Tool._ensure_tool(tool)
|
||||
|
||||
from fastmcp.server.providers.addressing import hash_tool
|
||||
from fastmcp.server.providers.addressing import (
|
||||
TOOL_HASH_META_KEY,
|
||||
hash_tool,
|
||||
)
|
||||
|
||||
meta = dict(tool.meta) if tool.meta else {}
|
||||
fm = meta.setdefault("fastmcp", {})
|
||||
fm["app"] = self.name
|
||||
fm["_tool_hash"] = hash_tool(self.name, tool.name)
|
||||
fm[TOOL_HASH_META_KEY] = hash_tool(self.name, tool.name)
|
||||
ui = meta.setdefault("ui", {})
|
||||
if "visibility" not in ui:
|
||||
ui["visibility"] = ["app"]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from typing import Any, Literal
|
|||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.mime import UI_MIME_TYPE as UI_MIME_TYPE
|
||||
from fastmcp.utilities.mime import resolve_ui_mime_type as resolve_ui_mime_type
|
||||
|
||||
|
|
@ -182,3 +183,31 @@ def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]:
|
|||
if isinstance(app, AppConfig):
|
||||
return app.model_dump(by_alias=True, exclude_none=True)
|
||||
return app
|
||||
|
||||
|
||||
def is_model_visible(component: FastMCPComponent) -> bool:
|
||||
"""Whether a component may be shown to, or invoked by, the model.
|
||||
|
||||
Visibility is a declaration, and the MCP Apps spec puts the filtering on
|
||||
the host — so ``tools/list`` carries app-only tools and the host keeps
|
||||
them from the model. That division only works where a host stands between
|
||||
the server and the model.
|
||||
|
||||
It does not hold for surfaces a server drives itself. A search result or
|
||||
a code-mode catalog reaches the model as ordinary tool output, and a
|
||||
call-tool proxy invokes on a name the model supplies; nothing downstream
|
||||
can filter either. Those surfaces have to apply the declaration here.
|
||||
|
||||
A component with no ``visibility`` is visible: the field marks the
|
||||
exception, and the spec's default is both audiences.
|
||||
"""
|
||||
meta = component.meta
|
||||
if not meta:
|
||||
return True
|
||||
ui_meta = meta.get("ui")
|
||||
if not isinstance(ui_meta, dict):
|
||||
return True
|
||||
visibility = ui_meta.get("visibility")
|
||||
if not isinstance(visibility, list):
|
||||
return True
|
||||
return "model" in visibility
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ def _parse_mcp_servers(
|
|||
def _parse_mcp_config(path: Path, source: str) -> list[DiscoveredServer]:
|
||||
"""Parse an mcpServers-style JSON file into discovered servers."""
|
||||
try:
|
||||
text = path.read_text()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.debug("Could not read %s: %s", path, exc)
|
||||
return []
|
||||
|
|
@ -158,7 +158,7 @@ def _scan_claude_code(start_dir: Path) -> list[DiscoveredServer]:
|
|||
"""Scan ``~/.claude.json`` for global and project-scoped MCP servers."""
|
||||
path = Path.home() / ".claude.json"
|
||||
try:
|
||||
text = path.read_text()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ def _scan_goose() -> list[DiscoveredServer]:
|
|||
|
||||
path = config_dir / "config.yaml"
|
||||
try:
|
||||
text = path.read_text()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,16 @@ app name + tool name. The hash serves two purposes:
|
|||
and ``read_resource`` synthesize these on demand from the tool's meta.
|
||||
|
||||
The hash is computed at registration time from ``(app_name, tool_name)`` —
|
||||
both known at that moment — and stored in ``meta["fastmcp"]["_tool_hash"]``.
|
||||
both known at that moment — and stored in ``meta["fastmcp"]["tool_hash"]``.
|
||||
Deterministic across replicas (same code → same hash), no registry walk
|
||||
needed.
|
||||
|
||||
The key is deliberately public. Keys prefixed with ``_`` inside the
|
||||
``fastmcp`` meta namespace are stripped at every serialization boundary
|
||||
(see ``FastMCPComponent.get_meta``) because they hold process-local state
|
||||
such as enabled/disabled marks. The hash is the opposite: a stable
|
||||
identity that intermediaries need in order to recognize a tool they are
|
||||
forwarding, so it must survive the wire.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -25,6 +32,9 @@ import hashlib
|
|||
#: Length of the hex hash prefix used in URIs and backend-tool names.
|
||||
HASH_LENGTH = 12
|
||||
|
||||
#: Key inside the ``fastmcp`` meta namespace holding a tool's identity hash.
|
||||
TOOL_HASH_META_KEY = "tool_hash"
|
||||
|
||||
|
||||
def hash_tool(app_name: str, tool_name: str) -> str:
|
||||
"""Deterministic hex hash for a tool in an app.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from collections.abc import AsyncIterator, Sequence
|
|||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Literal, TypeVar
|
||||
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.exceptions import NotFoundError, ToolError
|
||||
from fastmcp.server.providers.base import Provider
|
||||
from fastmcp.server.transforms import Namespace
|
||||
from fastmcp.utilities.async_utils import gather
|
||||
|
|
@ -221,19 +221,41 @@ class AggregateProvider(Provider):
|
|||
return None
|
||||
|
||||
async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None:
|
||||
"""Query all child providers for a tool matching a hash."""
|
||||
"""Query all child providers for a tool matching a hash.
|
||||
|
||||
The hash identifies a tool by app name and registered name, with no
|
||||
mount-point component, so composing one app into two branches yields
|
||||
two distinct tools claiming the same identity. That is ambiguous
|
||||
rather than resolvable: picking either one silently routes a UI's
|
||||
call into the wrong branch. Raise instead.
|
||||
|
||||
An ambiguity raised by a child is a verdict, not a provider failure,
|
||||
so it propagates whatever the error strategy is. Swallowing it would
|
||||
turn a duplicated app into "unknown tool", which sends whoever hits
|
||||
it looking for a missing registration instead of a duplicate one.
|
||||
"""
|
||||
results = await gather(
|
||||
(p.get_tool_by_hash(tool_hash, tool_name) for p in self.providers),
|
||||
return_exceptions=True,
|
||||
)
|
||||
matches: list[Tool] = []
|
||||
for r in results:
|
||||
if isinstance(r, BaseException):
|
||||
if self.provider_error_strategy == "raise":
|
||||
if isinstance(r, ToolError) or self.provider_error_strategy == "raise":
|
||||
raise r
|
||||
continue
|
||||
if r is not None:
|
||||
return r
|
||||
return None
|
||||
matches.append(r)
|
||||
|
||||
if not matches:
|
||||
return None
|
||||
if len(matches) > 1:
|
||||
raise ToolError(
|
||||
f"Ambiguous app tool {tool_name!r}: {len(matches)} components share "
|
||||
f"the identity {tool_hash!r}. The same app is composed more than "
|
||||
f"once, so this call cannot be routed to a single tool."
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Resources
|
||||
|
|
|
|||
|
|
@ -214,9 +214,11 @@ class Provider:
|
|||
"""Look up an app-visible tool by its deterministic hash.
|
||||
|
||||
Same recursive-walk semantics as ``get_app_tool`` but matches on
|
||||
``meta["fastmcp"]["_tool_hash"]`` instead of the app name tag.
|
||||
``meta["fastmcp"]["tool_hash"]`` instead of the app name tag.
|
||||
Used by the dispatcher when receiving hashed backend-tool calls.
|
||||
"""
|
||||
from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY
|
||||
|
||||
tool = await self._get_tool(tool_name)
|
||||
if tool is not None:
|
||||
meta = tool.meta or {}
|
||||
|
|
@ -227,7 +229,7 @@ class Provider:
|
|||
)
|
||||
if (
|
||||
isinstance(fastmcp_meta, dict)
|
||||
and fastmcp_meta.get("_tool_hash") == tool_hash
|
||||
and fastmcp_meta.get(TOOL_HASH_META_KEY) == tool_hash
|
||||
and "app" in visibility
|
||||
):
|
||||
return tool
|
||||
|
|
|
|||
158
fastmcp_slim/fastmcp/server/providers/prefab_payload.py
Normal file
158
fastmcp_slim/fastmcp/server/providers/prefab_payload.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""Late-bound tool names in Prefab UI payloads.
|
||||
|
||||
A Prefab UI is serialized during the entry tool's call, deep inside whatever
|
||||
composition the server happens to have. At that moment nothing knows what the
|
||||
backend tools will be *called* by the time the payload reaches a host: every
|
||||
layer above may rename them, and the outermost layer's names are the only ones
|
||||
a client can actually invoke.
|
||||
|
||||
So the payload leaves the app addressed by identity — ``<hash>_<local_name>``,
|
||||
stable everywhere — and every FastMCP server rewrites those references on the
|
||||
way out to whatever it lists that tool as. Servers rewrite innermost-first, so
|
||||
the edge writes last and wins.
|
||||
|
||||
Rewriting a name in place would destroy the identity for the next layer up, so
|
||||
the payload carries a name-to-identity map under ``_meta.fastmcp.toolNames``.
|
||||
Each layer resolves through the map and updates it. The action objects keep the
|
||||
exact shape ``prefab_ui`` defines — only the value of ``tool`` changes, and only
|
||||
ever to another valid tool name.
|
||||
|
||||
Renderers read ``_meta`` already and ignore keys they don't recognize, so this
|
||||
needs no renderer change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.server.providers.addressing import parse_hashed_backend_name
|
||||
|
||||
#: Action discriminator emitted by ``prefab_ui``'s ``CallTool``.
|
||||
_TOOL_CALL_ACTION = "toolCall"
|
||||
|
||||
_META_KEY = "_meta"
|
||||
_FASTMCP_KEY = "fastmcp"
|
||||
_TOOL_NAMES_KEY = "toolNames"
|
||||
|
||||
#: Resolves an identity hash to the name this server lists that tool under.
|
||||
#: Returns None when the identity cannot be resolved here, in which case the
|
||||
#: existing reference is left alone.
|
||||
IdentityResolver = Callable[[str], str | None]
|
||||
|
||||
|
||||
def _walk_tool_calls(node: Any) -> list[dict[str, Any]]:
|
||||
"""Collect every ``toolCall`` action object in a payload tree."""
|
||||
found: list[dict[str, Any]] = []
|
||||
if isinstance(node, dict):
|
||||
if node.get("action") == _TOOL_CALL_ACTION and isinstance(
|
||||
node.get("tool"), str
|
||||
):
|
||||
found.append(node)
|
||||
for value in node.values():
|
||||
found.extend(_walk_tool_calls(value))
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
found.extend(_walk_tool_calls(item))
|
||||
return found
|
||||
|
||||
|
||||
def _read_map(payload: dict[str, Any]) -> dict[str, str]:
|
||||
meta = payload.get(_META_KEY)
|
||||
if not isinstance(meta, dict):
|
||||
return {}
|
||||
fastmcp_meta = meta.get(_FASTMCP_KEY)
|
||||
if not isinstance(fastmcp_meta, dict):
|
||||
return {}
|
||||
names = fastmcp_meta.get(_TOOL_NAMES_KEY)
|
||||
if not isinstance(names, dict):
|
||||
return {}
|
||||
return {k: v for k, v in names.items() if isinstance(k, str) and isinstance(v, str)}
|
||||
|
||||
|
||||
def _write_map(payload: dict[str, Any], names: dict[str, str]) -> None:
|
||||
meta = payload.setdefault(_META_KEY, {})
|
||||
if not isinstance(meta, dict):
|
||||
return
|
||||
fastmcp_meta = meta.setdefault(_FASTMCP_KEY, {})
|
||||
if not isinstance(fastmcp_meta, dict):
|
||||
return
|
||||
fastmcp_meta[_TOOL_NAMES_KEY] = names
|
||||
|
||||
|
||||
def payload_has_identities(payload: Any) -> bool:
|
||||
"""Cheap guard: does this payload carry tool references worth rewriting?
|
||||
|
||||
Runs on every tool result, so it must not walk the tree.
|
||||
"""
|
||||
return isinstance(payload, dict) and bool(_read_map(payload))
|
||||
|
||||
|
||||
def annotate_payload_identities(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record the identity-addressed form of each reference, at serialization.
|
||||
|
||||
References start out as ``<hash>_<local_name>``, so the map begins as an
|
||||
identity map to itself. Once a later layer rewrites a name, this is the
|
||||
only remaining route back: it carries both what the reference points at
|
||||
and the address any server can fall back to.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
|
||||
addresses: dict[str, str] = dict(_read_map(payload))
|
||||
for action in _walk_tool_calls(payload):
|
||||
tool_name = action["tool"]
|
||||
if tool_name in addresses:
|
||||
continue
|
||||
if parse_hashed_backend_name(tool_name) is not None:
|
||||
addresses[tool_name] = tool_name
|
||||
|
||||
if addresses:
|
||||
_write_map(payload, addresses)
|
||||
return payload
|
||||
|
||||
|
||||
def rewrite_payload_tool_names(
|
||||
payload: Any,
|
||||
resolve: IdentityResolver,
|
||||
) -> Any:
|
||||
"""Re-address a payload's tool references to this server's own names.
|
||||
|
||||
Mutates in place and returns the payload.
|
||||
|
||||
A reference this server cannot resolve is restored to its
|
||||
identity-addressed form rather than left as-is. Leaving it would strand
|
||||
whatever name an inner server chose — a name that is correct there and
|
||||
meaningless here — and, unlike the identity form, a stranded name has no
|
||||
route back. Restoring keeps the reference resolvable by the dispatcher,
|
||||
or by any server further out with a better view.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
|
||||
addresses = _read_map(payload)
|
||||
if not addresses:
|
||||
return payload
|
||||
|
||||
rebound: dict[str, str] = {}
|
||||
for current_name, address in addresses.items():
|
||||
parsed = parse_hashed_backend_name(address)
|
||||
new_name = resolve(parsed[0]) if parsed is not None else None
|
||||
if new_name is None:
|
||||
new_name = address
|
||||
if new_name != current_name:
|
||||
rebound[current_name] = new_name
|
||||
|
||||
if not rebound:
|
||||
return payload
|
||||
|
||||
for action in _walk_tool_calls(payload):
|
||||
new_name = rebound.get(action["tool"])
|
||||
if new_name is not None:
|
||||
action["tool"] = new_name
|
||||
|
||||
_write_map(
|
||||
payload,
|
||||
{rebound.get(name, name): address for name, address in addresses.items()},
|
||||
)
|
||||
return payload
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Tools marked as Prefab (via ``app=True``, ``PrefabAppConfig``, etc.) carry
|
||||
a placeholder ``meta.ui.resourceUri`` and optionally a hash in
|
||||
``meta.fastmcp._tool_hash``. This module synthesizes per-tool renderer
|
||||
``meta.fastmcp.tool_hash``. This module synthesizes per-tool renderer
|
||||
resources on demand at ``list_resources`` and ``read_resource`` time
|
||||
without storing or materializing anything.
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any, cast
|
|||
|
||||
from fastmcp.server.providers.addressing import (
|
||||
HASH_LENGTH,
|
||||
TOOL_HASH_META_KEY,
|
||||
hash_tool,
|
||||
parse_hashed_resource_uri,
|
||||
)
|
||||
|
|
@ -48,7 +49,7 @@ def _get_tool_hash(tool: Tool) -> str | None:
|
|||
meta = tool.meta or {}
|
||||
fastmcp_meta = meta.get("fastmcp")
|
||||
if isinstance(fastmcp_meta, dict):
|
||||
h = fastmcp_meta.get("_tool_hash")
|
||||
h = fastmcp_meta.get(TOOL_HASH_META_KEY)
|
||||
if isinstance(h, str) and len(h) == HASH_LENGTH:
|
||||
return h
|
||||
# Fall back to computing from app name
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ from fastmcp.client.sampling import create_sampling_callback
|
|||
from fastmcp.client.telemetry import client_span
|
||||
from fastmcp.client.transports import ClientTransportT
|
||||
from fastmcp.client.transports.base import TransportOptions
|
||||
from fastmcp.exceptions import ResourceError
|
||||
from fastmcp.exceptions import ResourceError, ToolError
|
||||
from fastmcp.mcp_config import MCPConfig
|
||||
from fastmcp.prompts import Message, Prompt, PromptResult
|
||||
from fastmcp.prompts.base import InputRequiredPromptResult, PromptArgument
|
||||
|
|
@ -856,6 +856,54 @@ class ProxyProvider(Provider):
|
|||
return None
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None:
|
||||
"""Resolve an identity against the remote listing.
|
||||
|
||||
The base implementation looks the tool up by its registered name,
|
||||
which assumes the name survived to here. Across a proxy it need not:
|
||||
a backend that mounts its app under a namespace advertises
|
||||
``crm_save``, and nothing named ``save`` was ever listed. Matching on
|
||||
the identity carried in meta is what the identity is for.
|
||||
|
||||
A remote that mounts one app twice sends back two tools claiming one
|
||||
identity, exactly as a local composition would. That is refused here
|
||||
on the same terms ``AggregateProvider`` refuses it, so a duplicated
|
||||
app is caught wherever it is composed rather than only nearby.
|
||||
"""
|
||||
from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY
|
||||
|
||||
cache = self._tools_cache
|
||||
if cache is None or not cache.is_fresh(self._cache_ttl):
|
||||
await self._list_tools()
|
||||
cache = self._tools_cache
|
||||
assert cache is not None
|
||||
|
||||
matches: list[Tool] = []
|
||||
for tool in cache.items:
|
||||
meta = tool.meta or {}
|
||||
fastmcp_meta = meta.get("fastmcp")
|
||||
ui_meta = meta.get("ui")
|
||||
visibility = (
|
||||
ui_meta.get("visibility", []) if isinstance(ui_meta, dict) else []
|
||||
)
|
||||
if (
|
||||
isinstance(fastmcp_meta, dict)
|
||||
and fastmcp_meta.get(TOOL_HASH_META_KEY) == tool_hash
|
||||
and "app" in visibility
|
||||
):
|
||||
matches.append(tool)
|
||||
|
||||
if not matches:
|
||||
return None
|
||||
distinct = {tool.name for tool in matches}
|
||||
if len(distinct) > 1:
|
||||
raise ToolError(
|
||||
f"Ambiguous app tool {tool_name!r}: {len(distinct)} components share "
|
||||
f"the identity {tool_hash!r}. The same app is composed more than "
|
||||
f"once, so this call cannot be routed to a single tool."
|
||||
)
|
||||
return max(matches, key=version_sort_key)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Resource methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -145,8 +145,9 @@ def _version_request_meta(
|
|||
|
||||
|
||||
# The MCP SDK warns "Tool X not listed, no validation will be performed"
|
||||
# for every call to app-only tools (hidden from list_tools by design).
|
||||
# This fires even when validate_input=False. Suppress it.
|
||||
# for every call addressed by hashed backend name, since that address is
|
||||
# an identity rather than a listed tool name. This fires even when
|
||||
# validate_input=False. Suppress it.
|
||||
class _SuppressUnlistedToolWarning(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return "not listed, no validation" not in record.getMessage()
|
||||
|
|
@ -223,64 +224,18 @@ def _get_auth_context() -> tuple[bool, Any]:
|
|||
return (False, get_access_token())
|
||||
|
||||
|
||||
def _is_backend_tool(tool: Tool) -> bool:
|
||||
"""Check whether a tool is handled specially as backend tool
|
||||
def _tool_identity(tool: Tool) -> str | None:
|
||||
"""Read a tool's stable identity hash, if it carries one."""
|
||||
from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY
|
||||
|
||||
Tools registered via ``@app.tool()`` (without ``model=True``) have
|
||||
``meta["ui"]["visibility"] == ["app"]`` — they are callable by app UIs
|
||||
but should not appear in tool list the client passes to the model.
|
||||
|
||||
They are handled specially for in various ways - e.g. they are looked
|
||||
up via get_app_tool(), and don't appear in the tools/list output.
|
||||
(FIXME: the latter isn't correct behavior according to the mcp-apps spec.)
|
||||
|
||||
Returns True (a backend tool) when:
|
||||
- The tool has ``meta.fastmcp.app``.
|
||||
- The tool has ``meta.ui.visibility``.
|
||||
- The visibility is precisely ``["app"]``.
|
||||
|
||||
Returns False otherwise.
|
||||
"""
|
||||
meta = tool.meta
|
||||
if not meta:
|
||||
return False
|
||||
fastmcp = meta.get("fastmcp")
|
||||
if not isinstance(fastmcp, dict):
|
||||
return False
|
||||
if fastmcp.get("app") is None:
|
||||
return False
|
||||
ui = meta.get("ui")
|
||||
if not isinstance(ui, dict):
|
||||
return False
|
||||
visibility = ui.get("visibility")
|
||||
if not isinstance(visibility, list):
|
||||
return False
|
||||
return len(visibility) == 1 and visibility[0] == "app"
|
||||
|
||||
|
||||
def _is_app_visible(tool: Tool) -> bool:
|
||||
"""Check whether a tool has explicitly opted into app-callable visibility.
|
||||
|
||||
Gates the dispatcher's hashed-name routing path: only tools whose
|
||||
``meta.ui.visibility`` list contains ``"app"`` can be reached via
|
||||
``<hash>_<local_name>`` calls. Tools without an explicit visibility
|
||||
declaration are NOT app-callable — they must be reached by their
|
||||
display name through the normal transform-aware resolution path.
|
||||
|
||||
This is the inverse of the "everything is dot-callable" trap: the
|
||||
hashed-name path is an opt-in mechanism for FastMCPApp backend tools,
|
||||
not a general bypass for arbitrary tools.
|
||||
"""
|
||||
meta = tool.meta
|
||||
if not meta:
|
||||
return False
|
||||
ui = meta.get("ui")
|
||||
if not isinstance(ui, dict):
|
||||
return False
|
||||
visibility = ui.get("visibility")
|
||||
if not isinstance(visibility, list):
|
||||
return False
|
||||
return "app" in visibility
|
||||
return None
|
||||
fastmcp_meta = meta.get("fastmcp")
|
||||
if not isinstance(fastmcp_meta, dict):
|
||||
return None
|
||||
identity = fastmcp_meta.get(TOOL_HASH_META_KEY)
|
||||
return identity if isinstance(identity, str) else None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -728,7 +683,7 @@ class FastMCP(
|
|||
"""Replace placeholder Prefab URIs with per-tool hashed ones.
|
||||
|
||||
For each tool whose ``meta.ui.resourceUri`` is the placeholder,
|
||||
reads the tool's stored hash from ``meta.fastmcp._tool_hash``
|
||||
reads the tool's stored hash from ``meta.fastmcp.tool_hash``
|
||||
and rewrites the URI to the per-tool form. Also strips CSP from
|
||||
tool meta (it belongs on the resource). Produces ``model_copy``
|
||||
views — originals are untouched.
|
||||
|
|
@ -742,6 +697,78 @@ class FastMCP(
|
|||
rewrite_tool_meta_for_wire(t) if _is_prefab_tool(t) else t for t in tools
|
||||
]
|
||||
|
||||
async def _rebind_prefab_tool_names(self, result: Any) -> Any:
|
||||
"""Re-address a Prefab payload's tool references to this server's names.
|
||||
|
||||
Runs on the way out of every ``tools/call``, above the middleware
|
||||
chain so a payload is re-addressed however it was produced. Servers
|
||||
unwind innermost-first, so the outermost server rewrites last and its
|
||||
names — the only ones a client can actually invoke — are what ship.
|
||||
|
||||
A call does not always answer with a tool result: submitting a task
|
||||
answers with the task's metadata. Anything that is not a tool result
|
||||
passes through untouched.
|
||||
|
||||
An identity claimed by more than one tool is not bound. That happens
|
||||
when one app is composed into a server twice, which leaves no fact
|
||||
anywhere in the listing that says which copy a UI belongs to. The
|
||||
reference keeps its identity-addressed form, and the dispatcher
|
||||
reports the ambiguity rather than binding to a coin flip.
|
||||
"""
|
||||
from fastmcp.server.providers.prefab_payload import (
|
||||
payload_has_identities,
|
||||
rewrite_payload_tool_names,
|
||||
)
|
||||
|
||||
if not isinstance(result, ToolResult):
|
||||
return result
|
||||
|
||||
payload = result.structured_content
|
||||
if not payload_has_identities(payload):
|
||||
return result
|
||||
|
||||
# Binding is safe only where one identity, one name, and one
|
||||
# component all agree. Each is tracked separately: collapsing them
|
||||
# early is what lets a duplicated app pass as a single tool.
|
||||
#
|
||||
# The middleware chain runs, because the binding has to describe the
|
||||
# listing a client will actually see. Middleware adds, removes and
|
||||
# shadows tools — an injected tool sharing a backend's name owns that
|
||||
# name at call time, and a listing taken beneath middleware would not
|
||||
# know it exists.
|
||||
claimed_by: dict[str, list[Tool]] = {}
|
||||
owners_of: dict[str, set[str | None]] = {}
|
||||
for tool in await self.list_tools():
|
||||
identity = _tool_identity(tool)
|
||||
owners_of.setdefault(tool.name, set()).add(identity)
|
||||
if identity is not None:
|
||||
claimed_by.setdefault(identity, []).append(tool)
|
||||
|
||||
def resolve(identity: str) -> str | None:
|
||||
tools = claimed_by.get(identity, [])
|
||||
names = {tool.name for tool in tools}
|
||||
if len(names) != 1:
|
||||
# Several names carry this identity: the app is composed more
|
||||
# than once and nothing says which copy the UI belongs to.
|
||||
return None
|
||||
|
||||
# One name can still be several components. `key` is the canonical
|
||||
# identity — type, name and version — so versions of one tool have
|
||||
# distinct keys while copies of one app repeat a key. A repeat
|
||||
# means two components are indistinguishable, which is worse than
|
||||
# the renamed case, not better.
|
||||
if len({tool.key for tool in tools}) != len(tools):
|
||||
return None
|
||||
|
||||
(name,) = names
|
||||
# And the name has to lead back. Two apps can each expose `save`,
|
||||
# or a plain tool can share the name — binding then hands one
|
||||
# app's button to someone else's implementation.
|
||||
return name if owners_of.get(name) == {identity} else None
|
||||
|
||||
rewrite_payload_tool_names(payload, resolve)
|
||||
return result
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Provider interface overrides - inherited from AggregateProvider
|
||||
# -------------------------------------------------------------------------
|
||||
|
|
@ -800,7 +827,7 @@ class FastMCP(
|
|||
async def list_tools(self, *, run_middleware: bool = True) -> Sequence[Tool]:
|
||||
"""List all enabled tools from providers.
|
||||
|
||||
Overrides Provider.list_tools() to add visibility filtering, auth filtering,
|
||||
Overrides Provider.list_tools() to add enabled filtering, auth filtering,
|
||||
and middleware execution. Returns all versions (no deduplication).
|
||||
Protocol handlers deduplicate for MCP wire format.
|
||||
"""
|
||||
|
|
@ -820,11 +847,14 @@ class FastMCP(
|
|||
|
||||
# Core logic: list tools
|
||||
with server_span("tools/list", "tools/list", self.name, "tool", ""):
|
||||
# Get all tools, apply session transforms, then filter enabled
|
||||
# and model-visible (app-only tools are hidden from the model).
|
||||
# Get all tools, apply session transforms, then filter enabled.
|
||||
# App-only tools (meta.ui.visibility == ["app"]) are listed:
|
||||
# the mcp-apps spec puts visibility filtering on the host, and
|
||||
# a tool absent from tools/list cannot be forwarded by any
|
||||
# intermediary that routes by name.
|
||||
tools = list(await super().list_tools())
|
||||
tools = await apply_session_transforms(tools)
|
||||
tools = [t for t in tools if is_enabled(t) and not _is_backend_tool(t)]
|
||||
tools = [t for t in tools if is_enabled(t)]
|
||||
|
||||
# Rewrite per-tool Prefab renderer URIs based on the tool's
|
||||
# mount-point address. The walk pairs each tool with the
|
||||
|
|
@ -882,7 +912,7 @@ class FastMCP(
|
|||
) -> Tool | None:
|
||||
"""Get a tool by name, filtering disabled tools.
|
||||
|
||||
Overrides Provider.get_tool() to add visibility filtering after all
|
||||
Overrides Provider.get_tool() to filter disabled tools after all
|
||||
transforms (including session-level) have been applied. This ensures
|
||||
session transforms can override provider-level disables.
|
||||
|
||||
|
|
@ -902,18 +932,18 @@ class FastMCP(
|
|||
|
||||
# Apply session transforms to single item
|
||||
tools = await apply_session_transforms([tool])
|
||||
if tools and is_enabled(tools[0]) and not _is_backend_tool(tools[0]):
|
||||
if tools and is_enabled(tools[0]):
|
||||
return tools[0]
|
||||
|
||||
# The highest version is disabled (or app-only). If an explicit version
|
||||
# was requested, respect that. Otherwise fall back to the next-highest
|
||||
# enabled, model-visible version.
|
||||
# The highest version is disabled. If an explicit version was
|
||||
# requested, respect that. Otherwise fall back to the next-highest
|
||||
# enabled version.
|
||||
if version is not None:
|
||||
return None
|
||||
|
||||
all_tools = [t for t in await super().list_tools() if t.name == name]
|
||||
all_tools = list(await apply_session_transforms(all_tools))
|
||||
enabled = [t for t in all_tools if is_enabled(t) and not _is_backend_tool(t)]
|
||||
enabled = [t for t in all_tools if is_enabled(t)]
|
||||
|
||||
skip_auth, token = _get_auth_context()
|
||||
authorized: list[Tool] = []
|
||||
|
|
@ -1398,7 +1428,7 @@ class FastMCP(
|
|||
# the whole thing (so it observes every call), and the
|
||||
# interceptors sit between it and the tool body (so each is the
|
||||
# last gate before execution).
|
||||
return await self._dispatch_component_middleware(
|
||||
dispatched = await self._dispatch_component_middleware(
|
||||
context=mw_context,
|
||||
call_next=self._compose_tool_call_interceptors(
|
||||
lambda context: self.call_tool(
|
||||
|
|
@ -1409,6 +1439,10 @@ class FastMCP(
|
|||
)
|
||||
),
|
||||
)
|
||||
# Above the chain, so a Prefab payload is re-addressed however
|
||||
# it was produced — middleware can answer a call itself, and
|
||||
# such a result never reaches the core path below.
|
||||
return await self._rebind_prefab_tool_names(dispatched)
|
||||
|
||||
# Core logic: find and execute tool
|
||||
with server_span(
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ from collections.abc import Sequence
|
|||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp.apps.config import is_model_visible
|
||||
from fastmcp.server.transforms import Transform
|
||||
from fastmcp.utilities.versions import dedupe_with_versions
|
||||
|
||||
|
|
@ -177,6 +178,16 @@ class CatalogTransform(Transform):
|
|||
of each tool is returned — matching what protocol handlers expose
|
||||
on the wire.
|
||||
|
||||
Tools the model may not see are excluded. A catalog is read by the
|
||||
model as tool output rather than advertised as ``tools/list``, so the
|
||||
host filtering the spec relies on never applies to it — this is the
|
||||
only place the declaration can be enforced.
|
||||
|
||||
Visibility is checked after deduplication, on the version a bare name
|
||||
actually reaches. Checking first would let a model-visible older
|
||||
version advertise a name whose highest version is app-only, and the
|
||||
call would run the version nobody was shown.
|
||||
|
||||
Args:
|
||||
ctx: The current request context.
|
||||
run_middleware: Whether to run middleware on the inner call.
|
||||
|
|
@ -188,7 +199,8 @@ class CatalogTransform(Transform):
|
|||
tools = await ctx.fastmcp.list_tools(run_middleware=run_middleware)
|
||||
finally:
|
||||
self._bypass.reset(token)
|
||||
return dedupe_with_versions(tools, lambda t: t.name)
|
||||
selected = dedupe_with_versions(tools, lambda t: t.name)
|
||||
return [tool for tool in selected if is_model_visible(tool)]
|
||||
|
||||
async def get_resource_catalog(
|
||||
self, ctx: Context, *, run_middleware: bool = True
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from abc import abstractmethod
|
|||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.transforms import GetToolNext
|
||||
from fastmcp.server.transforms.catalog import CatalogTransform
|
||||
|
|
@ -240,6 +241,13 @@ class BaseSearchTransform(CatalogTransform):
|
|||
raise ValueError(
|
||||
f"'{name}' is a synthetic search tool and cannot be called via the call_tool proxy"
|
||||
)
|
||||
# The name comes from the model, so this proxy is a second way
|
||||
# into the server that no host mediates. It may reach only what
|
||||
# the model was allowed to discover.
|
||||
if not any(
|
||||
tool.name == name for tool in await transform.get_tool_catalog(ctx)
|
||||
):
|
||||
raise NotFoundError(f"Unknown tool: {name!r}")
|
||||
return await ctx.fastmcp.call_tool(name, arguments)
|
||||
|
||||
return Tool.from_function(fn=call_tool, name=self._call_tool_name)
|
||||
|
|
|
|||
|
|
@ -518,15 +518,17 @@ def _get_tool_resolver(app_name: str | None = None) -> Callable[..., str] | None
|
|||
|
||||
|
||||
def _prefab_to_json(app: Any, fastmcp_app_name: str | None = None) -> dict[str, Any]:
|
||||
"""Call PrefabApp.to_json() with the hash-based resolver.
|
||||
"""Serialize a PrefabApp, addressing its peer-tool references by identity.
|
||||
|
||||
The resolver prefixes peer-tool references with a deterministic hash
|
||||
derived from the app name + tool name. The dispatcher recognizes that
|
||||
format and routes calls via ``get_tool_by_hash`` which walks the
|
||||
provider tree recursively — same pattern as the old ``get_app_tool``.
|
||||
The resolver writes each reference as ``<hash>_<local_name>``, and the
|
||||
identity behind it is recorded in the payload's meta so that servers
|
||||
can re-address the reference on the way out without losing track of
|
||||
what it points at.
|
||||
"""
|
||||
from fastmcp.server.providers.prefab_payload import annotate_payload_identities
|
||||
|
||||
data = app.to_json(tool_resolver=_get_tool_resolver(fastmcp_app_name))
|
||||
return data
|
||||
return annotate_payload_identities(data)
|
||||
|
||||
|
||||
def _get_fastmcp_app_name(tool: Tool) -> str | None:
|
||||
|
|
|
|||
|
|
@ -242,6 +242,50 @@ class ArgTransformConfig(FastMCPBaseModel):
|
|||
return ArgTransform(**self.model_dump(exclude_unset=True)) # pyright: ignore[reportAny]
|
||||
|
||||
|
||||
#: Meta namespaces the framework owns. An override replaces the caller-facing
|
||||
#: meta wholesale, but these carry a component's app membership, identity, and
|
||||
#: visibility — what intermediaries use to recognize a tool they are
|
||||
#: forwarding. Both are needed together: an identity that survives a rename
|
||||
#: while its ``ui.visibility`` marker does not leaves a tool that can be named
|
||||
#: but no longer answers to its identity.
|
||||
_FRAMEWORK_META_NAMESPACES = ("fastmcp", "ui")
|
||||
|
||||
|
||||
def _apply_meta_override(
|
||||
source_meta: dict[str, Any] | None,
|
||||
override: dict[str, Any] | None | NotSetT,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Apply a transform's ``meta=`` override, preserving framework namespaces.
|
||||
|
||||
An override replaces the caller-facing meta wholesale, which is what users
|
||||
expect. Framework-owned namespaces are carried across regardless, since a
|
||||
transform that renames a tool must not silently unwire it — values the
|
||||
override supplies for those namespaces still win key by key.
|
||||
"""
|
||||
if isinstance(override, NotSetT):
|
||||
return source_meta
|
||||
|
||||
source = source_meta or {}
|
||||
preserved = {
|
||||
namespace: dict(source[namespace])
|
||||
for namespace in _FRAMEWORK_META_NAMESPACES
|
||||
if isinstance(source.get(namespace), dict) and source[namespace]
|
||||
}
|
||||
|
||||
if override is None:
|
||||
return preserved or None
|
||||
|
||||
merged = dict(override)
|
||||
for namespace, source_values in preserved.items():
|
||||
override_values = override.get(namespace)
|
||||
merged[namespace] = (
|
||||
{**source_values, **override_values}
|
||||
if isinstance(override_values, dict)
|
||||
else source_values
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
class TransformedTool(Tool):
|
||||
"""A tool that is transformed from another tool.
|
||||
|
||||
|
|
@ -590,7 +634,7 @@ class TransformedTool(Tool):
|
|||
description if not isinstance(description, NotSetT) else tool.description
|
||||
)
|
||||
final_title = title if not isinstance(title, NotSetT) else tool.title
|
||||
final_meta = meta if not isinstance(meta, NotSetT) else tool.meta
|
||||
final_meta = _apply_meta_override(tool.meta, meta)
|
||||
final_annotations = (
|
||||
annotations if not isinstance(annotations, NotSetT) else tool.annotations
|
||||
)
|
||||
|
|
|
|||
|
|
@ -141,7 +141,9 @@ class TestFileUploadProvider:
|
|||
text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
assert "test.txt" in text
|
||||
|
||||
async def test_ui_tool_visible_backend_hidden(self):
|
||||
async def test_backend_tool_listed_as_app_only(self):
|
||||
"""``store_files`` is listed but declares visibility=["app"], so the
|
||||
host keeps it out of the model's tool list."""
|
||||
server = FastMCP("test", providers=[FileUpload()])
|
||||
|
||||
tools = await server.list_tools()
|
||||
|
|
@ -150,7 +152,11 @@ class TestFileUploadProvider:
|
|||
assert "file_manager" in tool_names
|
||||
assert "list_files" in tool_names
|
||||
assert "read_file" in tool_names
|
||||
assert "store_files" not in tool_names
|
||||
assert "store_files" in tool_names
|
||||
|
||||
store_files = next(t for t in tools if t.name == "store_files")
|
||||
assert store_files.meta is not None
|
||||
assert store_files.meta["ui"]["visibility"] == ["app"]
|
||||
|
||||
async def test_max_file_size_enforced_server_side(self):
|
||||
server = FastMCP("test", providers=[FileUpload(max_file_size=100)])
|
||||
|
|
|
|||
|
|
@ -148,6 +148,40 @@ class TestParseMcpConfig:
|
|||
assert isinstance(servers[0].config, RemoteMCPServer)
|
||||
assert servers[0].config.url == "http://localhost:8000/mcp"
|
||||
|
||||
def test_reads_as_utf8_explicitly(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Regression test for GH-4689: config files must be read with an
|
||||
explicit UTF-8 encoding, not the platform's preferred encoding
|
||||
(e.g. cp949 on Windows with a non-UTF-8 locale), since that's what
|
||||
every tool that writes these files emits."""
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def _tracking_read_text(self: Path, *args: Any, **kwargs: Any) -> str:
|
||||
assert kwargs.get("encoding") == "utf-8", (
|
||||
"path.read_text() must pass encoding='utf-8' explicitly"
|
||||
)
|
||||
return original_read_text(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", _tracking_read_text)
|
||||
|
||||
path = tmp_path / "config.json"
|
||||
path.write_bytes(
|
||||
json.dumps(
|
||||
{
|
||||
"mcpServers": {
|
||||
"demo": {
|
||||
"command": "echo",
|
||||
"args": ["hello — world"],
|
||||
}
|
||||
}
|
||||
}
|
||||
).encode("utf-8")
|
||||
)
|
||||
servers = _parse_mcp_config(path, "test")
|
||||
assert len(servers) == 1
|
||||
assert servers[0].name == "demo"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner: Claude Desktop
|
||||
|
|
|
|||
250
tests/docs/test_upgrade_guide_api_claims.py
Normal file
250
tests/docs/test_upgrade_guide_api_claims.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""Check the API claims the upgrade guides make against the real APIs.
|
||||
|
||||
The other two doc tests cover code blocks: one executes them, one compares the
|
||||
before/after pair. Neither looks at *prose*, and prose is where a migration
|
||||
guide does most of its work — mapping tables, prompt checklists, and sentences
|
||||
naming an attribute to use. Those claims went wrong repeatedly and in the same
|
||||
way: an API was named without anyone checking it resolved.
|
||||
|
||||
So this file checks the claims mechanically:
|
||||
|
||||
- every ``ctx.<name>`` the guides tell a reader to *use* exists on the class
|
||||
they'd be using it on, and every one they name as removed really is gone
|
||||
- every ``MCPServer`` constructor parameter appears somewhere in the SDK v2
|
||||
guide, so a newly added SDK argument can't quietly go unmapped
|
||||
- the ``request_context`` attributes the guides route people to are real
|
||||
|
||||
Run:
|
||||
uv run pytest tests/docs/test_upgrade_guide_api_claims.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
UPGRADE_DIR = Path("docs/getting-started/upgrading")
|
||||
|
||||
|
||||
def _guide(name: str) -> str:
|
||||
return (UPGRADE_DIR / name).read_text("utf-8")
|
||||
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
from fastmcp import Context as FastMCPContext
|
||||
|
||||
|
||||
# Context attributes the guides may mention without them existing on FastMCP's
|
||||
# Context, because the guide's whole point is that they are gone or moved. Each
|
||||
# is asserted to genuinely be absent, so a name that later gains an
|
||||
# implementation stops being listed as missing.
|
||||
DOCUMENTED_AS_ABSENT = {
|
||||
"sample",
|
||||
"sample_step",
|
||||
"list_roots",
|
||||
"mcp_server",
|
||||
"headers",
|
||||
"protocol_version",
|
||||
"client_capabilities",
|
||||
"elicit_url",
|
||||
"close_standalone_sse_stream",
|
||||
"notify_tools_changed",
|
||||
"notify_resources_changed",
|
||||
"notify_prompts_changed",
|
||||
"notify_resource_updated",
|
||||
"params",
|
||||
"meta",
|
||||
}
|
||||
|
||||
|
||||
def test_absent_context_attributes_are_really_absent():
|
||||
"""Names the guides describe as gone must not exist on FastMCP's Context.
|
||||
|
||||
If one of these gains an implementation, the guides are now telling people
|
||||
to work around something that works, and this test says so.
|
||||
"""
|
||||
resurrected = [
|
||||
n for n in sorted(DOCUMENTED_AS_ABSENT) if hasattr(FastMCPContext, n)
|
||||
]
|
||||
assert not resurrected, (
|
||||
f"guides describe these as absent from fastmcp.Context, but they exist: {resurrected}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"guide",
|
||||
sorted(p.name for p in UPGRADE_DIR.glob("*.mdx")),
|
||||
)
|
||||
def test_ctx_attributes_named_in_guides_exist(guide: str):
|
||||
"""Every ``ctx.<name>`` in a guide either exists or is documented as absent."""
|
||||
referenced = set(re.findall(r"`ctx\.([a-z_]+)", _guide(guide)))
|
||||
unknown = {
|
||||
name
|
||||
for name in referenced
|
||||
if not hasattr(FastMCPContext, name) and name not in DOCUMENTED_AS_ABSENT
|
||||
}
|
||||
assert not unknown, (
|
||||
f"{guide} names ctx.{{{', '.join(sorted(unknown))}}}, which do not exist on "
|
||||
f"fastmcp.Context and are not in DOCUMENTED_AS_ABSENT"
|
||||
)
|
||||
|
||||
|
||||
def test_request_context_attributes_the_guides_route_to_exist():
|
||||
"""The guides send people to ``ctx.request_context`` for several attributes.
|
||||
|
||||
``FastMCPRequestContext`` resolves its attributes dynamically, so this is
|
||||
checked against a live request rather than the class.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
|
||||
mcp = FastMCP("probe")
|
||||
|
||||
@mcp.tool
|
||||
async def probe(ctx: FastMCPContext) -> list[str]:
|
||||
rc = ctx.request_context
|
||||
return [n for n in ("request_id", "meta", "protocol_version") if hasattr(rc, n)]
|
||||
|
||||
async def run() -> list[str]:
|
||||
async with Client(mcp) as client:
|
||||
return (await client.call_tool("probe", {})).data
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
present = asyncio.run(run())
|
||||
|
||||
assert set(present) == {"request_id", "meta", "protocol_version"}
|
||||
|
||||
|
||||
# Context methods the SDK v2 guide says are *genuinely* unchanged. Existence is
|
||||
# not enough for that claim — a method present on both classes with a different
|
||||
# signature is worse than a missing one, because the import swap compiles and
|
||||
# fails at runtime. So these are compared signature-for-signature.
|
||||
CLAIMED_SIGNATURE_COMPATIBLE = ["report_progress"]
|
||||
|
||||
# Present on both, but with signatures that differ. The guide must describe each
|
||||
# migration rather than list it as carrying over; this pins the difference so a
|
||||
# future SDK or FastMCP release that converges them shows up as a failure.
|
||||
KNOWN_SIGNATURE_DIFFERENCES = ["log", "info", "debug", "warning", "error", "elicit"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", CLAIMED_SIGNATURE_COMPATIBLE)
|
||||
def test_methods_claimed_unchanged_have_identical_signatures(method: str):
|
||||
from mcp.server.mcpserver import Context as SDKContext
|
||||
|
||||
sdk = inspect.signature(getattr(SDKContext, method))
|
||||
fastmcp = inspect.signature(getattr(FastMCPContext, method))
|
||||
assert str(sdk) == str(fastmcp), (
|
||||
f"the SDK v2 guide lists ctx.{method} as carrying over unchanged, but "
|
||||
f"the signatures differ:\n SDK : {sdk}\n FastMCP: {fastmcp}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", KNOWN_SIGNATURE_DIFFERENCES)
|
||||
def test_methods_with_known_signature_differences_still_differ(method: str):
|
||||
from mcp.server.mcpserver import Context as SDKContext
|
||||
|
||||
sdk = inspect.signature(getattr(SDKContext, method))
|
||||
fastmcp = inspect.signature(getattr(FastMCPContext, method))
|
||||
assert str(sdk) != str(fastmcp), (
|
||||
f"ctx.{method} signatures now match; the guide's migration note for it "
|
||||
f"is stale and should be moved to the unchanged list"
|
||||
)
|
||||
|
||||
|
||||
# SDK v1's `mcp.server.fastmcp.FastMCP.__init__` parameters. Hardcoded because
|
||||
# v1 cannot be installed alongside v4 to introspect — read from the published
|
||||
# mcp 1.20.0 wheel. Anything here that FastMCP 4 does not accept must appear in
|
||||
# the v1 guide, since a reader following "it's one import change" hits it.
|
||||
SDK_V1_CONSTRUCTOR_PARAMS = [
|
||||
"name", "instructions", "website_url", "icons", "auth_server_provider",
|
||||
"token_verifier", "event_store", "tools", "debug", "log_level", "host",
|
||||
"port", "mount_path", "sse_path", "message_path", "streamable_http_path",
|
||||
"json_response", "stateless_http", "warn_on_duplicate_resources",
|
||||
"warn_on_duplicate_tools", "warn_on_duplicate_prompts", "dependencies",
|
||||
"lifespan", "auth", "transport_security", "transport",
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_sdk_v1_constructor_params_fastmcp_rejects_are_documented():
|
||||
"""Every v1 keyword FastMCP 4 refuses must be named in the v1 guide.
|
||||
|
||||
The guide's headline is that upgrading is a single import change. That is
|
||||
only honest if the constructor arguments it *doesn't* accept are spelled
|
||||
out, so nobody follows the headline into a ``TypeError``.
|
||||
"""
|
||||
from fastmcp import FastMCP
|
||||
|
||||
guide = _guide("from-mcp-sdk-v1.mdx")
|
||||
probe: dict[str, Any] = {
|
||||
"name": "s",
|
||||
"icons": None,
|
||||
"tools": None,
|
||||
"lifespan": None,
|
||||
}
|
||||
|
||||
undocumented = []
|
||||
for param in SDK_V1_CONSTRUCTOR_PARAMS:
|
||||
if param == "name":
|
||||
continue
|
||||
kwargs: dict[str, Any] = {param: probe.get(param)}
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
FastMCP("s", **kwargs)
|
||||
continue # accepted, nothing to document
|
||||
except TypeError:
|
||||
pass
|
||||
except Exception:
|
||||
continue # accepted the keyword, rejected the probe value
|
||||
shorthand = param.replace("warn_on_duplicate", "")
|
||||
if re.search(rf"`{re.escape(param)}[=`]", guide):
|
||||
continue
|
||||
if param.startswith("warn_on_duplicate") and re.search(
|
||||
rf"`{re.escape(shorthand)}[=`]", guide
|
||||
):
|
||||
continue
|
||||
undocumented.append(param)
|
||||
|
||||
assert not undocumented, (
|
||||
"SDK v1 FastMCP() parameters that FastMCP 4 rejects but from-mcp-sdk-v1.mdx "
|
||||
f"never mentions: {undocumented}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_mcpserver_constructor_param_is_mapped():
|
||||
"""The SDK v2 guide claims an exhaustive constructor mapping — hold it to that.
|
||||
|
||||
A parameter added to ``MCPServer`` upstream should fail here rather than
|
||||
reach a reader as an unmapped keyword that raises ``TypeError`` on FastMCP.
|
||||
"""
|
||||
guide = _guide("from-mcp-sdk-v2.mdx")
|
||||
params = [
|
||||
p for p in inspect.signature(MCPServer.__init__).parameters if p != "self"
|
||||
]
|
||||
|
||||
unmapped = []
|
||||
for param in params:
|
||||
# `warn_on_duplicate_resources` is covered by the table's shorthand
|
||||
# "warn_on_duplicate_tools, _resources, _prompts".
|
||||
shorthand = param.replace("warn_on_duplicate", "")
|
||||
if re.search(rf"`{re.escape(param)}[=`]", guide):
|
||||
continue
|
||||
if param.startswith("warn_on_duplicate") and re.search(
|
||||
rf"`{re.escape(shorthand)}`", guide
|
||||
):
|
||||
continue
|
||||
unmapped.append(param)
|
||||
|
||||
assert not unmapped, (
|
||||
f"MCPServer constructor parameters not mentioned in from-mcp-sdk-v2.mdx: {unmapped}"
|
||||
)
|
||||
239
tests/docs/test_upgrade_guide_equivalence.py
Normal file
239
tests/docs/test_upgrade_guide_equivalence.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""Prove the SDK v2 upgrade guides produce an equivalent server.
|
||||
|
||||
`test_upgrade_guide_examples.py` proves every example runs. That is necessary
|
||||
but not sufficient: a migration guide is only correct if the "after" code
|
||||
exposes the same MCP surface as the "before" code it replaces. A guide whose
|
||||
halves both run but disagree on a tool's schema teaches a silent regression.
|
||||
|
||||
So for each MCP SDK v2 guide, the complete before-and-after server pair is
|
||||
lifted out of the page, both halves are built, and their advertised tools,
|
||||
resources, templates, and prompts are compared. The SDK v1 guides are not
|
||||
covered here — v1 is not installable alongside v4, so their "before" code
|
||||
cannot be built to compare against.
|
||||
|
||||
Run:
|
||||
uv run pytest tests/docs/test_upgrade_guide_equivalence.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pytest_examples.find_examples import _extract_code_chunks
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
|
||||
UPGRADE_DIR = Path("docs/getting-started/upgrading")
|
||||
|
||||
|
||||
def _block_containing(page: str, needle: str) -> dict[str, Any]:
|
||||
"""Execute the one code block on `page` that contains `needle`."""
|
||||
path = UPGRADE_DIR / page
|
||||
matches = [
|
||||
ex
|
||||
for ex in _extract_code_chunks(path, path.read_text("utf-8"), uuid4())
|
||||
if needle in ex.source
|
||||
]
|
||||
assert len(matches) == 1, (
|
||||
f"expected exactly one block in {page} containing {needle!r}, "
|
||||
f"found {len(matches)}"
|
||||
)
|
||||
namespace: dict[str, Any] = {"__name__": "fastmcp_docs_example"}
|
||||
exec(compile(matches[0].source, str(path), "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
def _strip_titles(node: Any) -> Any:
|
||||
"""Recursively drop every "title" key, the one difference that's genuinely cosmetic.
|
||||
|
||||
A hand-written SDK schema has no title anywhere; FastMCP derives one at every
|
||||
level from the function/model it built the schema from. Everything else in the
|
||||
tree — constraints, "additionalProperties", nested "anyOf"/"const", enum values —
|
||||
is retained, because those describe what a client is allowed to send and a
|
||||
silent difference there is exactly the kind of regression this test exists to
|
||||
catch.
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
return {k: _strip_titles(v) for k, v in node.items() if k != "title"}
|
||||
if isinstance(node, list):
|
||||
return [_strip_titles(v) for v in node]
|
||||
return node
|
||||
|
||||
|
||||
def _normalize(schema: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Compare schemas by their full structure, modulo generated titles.
|
||||
|
||||
"required" is sorted because the SDK and FastMCP may build it in a different
|
||||
parameter order for the same signature — an ordering difference, not a
|
||||
contract difference.
|
||||
"""
|
||||
if not schema:
|
||||
return {}
|
||||
stripped = _strip_titles(schema)
|
||||
if "required" in stripped:
|
||||
stripped["required"] = sorted(stripped["required"])
|
||||
return stripped
|
||||
|
||||
|
||||
def _split_declared_strictness(
|
||||
before: dict[str, dict[str, Any]], after: dict[str, dict[str, Any]]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Pop `"additionalProperties": false` from every migrated schema, asserting it's there.
|
||||
|
||||
FastMCP's generated tool schemas declare `"additionalProperties": false`; a
|
||||
schema built by either SDK server API does not declare it. This is a real
|
||||
contract change, not a cosmetic one — both SDK APIs *accept* an unexpected
|
||||
argument at call time, and FastMCP rejects it (pinned by
|
||||
`test_fastmcp_tightens_the_argument_contract` in each class below). It is
|
||||
popped here only so the rest of the schema can be compared field for field,
|
||||
and popping is an assertion rather than a silent discard: if FastMCP ever
|
||||
stops declaring it, or the SDK starts, this fails.
|
||||
"""
|
||||
stripped: dict[str, dict[str, Any]] = {}
|
||||
for name, schema in after.items():
|
||||
schema = dict(schema)
|
||||
assert schema.pop("additionalProperties", None) is False, (
|
||||
f"expected FastMCP to declare additionalProperties: false for {name!r}"
|
||||
)
|
||||
assert "additionalProperties" not in before.get(name, {}), (
|
||||
f"expected the SDK schema for {name!r} not to declare additionalProperties"
|
||||
)
|
||||
stripped[name] = schema
|
||||
return stripped
|
||||
|
||||
|
||||
async def _fastmcp_surface(mcp: FastMCP) -> dict[str, Any]:
|
||||
async with Client(mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
resources = await client.list_resources()
|
||||
templates = await client.list_resource_templates()
|
||||
prompts = await client.list_prompts()
|
||||
return {
|
||||
"tools": {t.name: _normalize(t.input_schema) for t in tools},
|
||||
"resources": {str(r.uri) for r in resources},
|
||||
"templates": {t.uri_template for t in templates},
|
||||
"prompts": {p.name: sorted(a.name for a in p.arguments or []) for p in prompts},
|
||||
}
|
||||
|
||||
|
||||
class TestMCPServerGuide:
|
||||
"""docs/.../from-mcp-sdk-v2.mdx — the high-level MCPServer migration."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def pair(self) -> tuple[Any, FastMCP]:
|
||||
before = _block_containing("from-mcp-sdk-v2.mdx", 'MCPServer("demo")')
|
||||
after = _block_containing("from-mcp-sdk-v2.mdx", 'FastMCP("demo")')
|
||||
return before["server"], after["mcp"]
|
||||
|
||||
async def test_same_surface(self, pair):
|
||||
server, mcp = pair
|
||||
|
||||
before = {
|
||||
"tools": {
|
||||
t.name: _normalize(t.input_schema) for t in await server.list_tools()
|
||||
},
|
||||
"resources": {str(r.uri) for r in await server.list_resources()},
|
||||
"templates": {
|
||||
t.uri_template for t in await server.list_resource_templates()
|
||||
},
|
||||
"prompts": {
|
||||
p.name: sorted(a.name for a in p.arguments or [])
|
||||
for p in await server.list_prompts()
|
||||
},
|
||||
}
|
||||
|
||||
after = await _fastmcp_surface(mcp)
|
||||
after["tools"] = _split_declared_strictness(before["tools"], after["tools"])
|
||||
assert before == after
|
||||
|
||||
async def test_fastmcp_tightens_the_argument_contract(self, pair):
|
||||
"""FastMCP rejects an unexpected argument where MCPServer accepts it.
|
||||
|
||||
This is the behavior behind the `additionalProperties` schema difference,
|
||||
and it is a real change for any caller that was passing extra keys.
|
||||
"""
|
||||
server, mcp = pair
|
||||
|
||||
tolerated = await server.call_tool(
|
||||
"greet", {"name": "World", "extra": "surprise"}
|
||||
)
|
||||
assert tolerated.is_error is False
|
||||
assert tolerated.content[0].text == "Hello, World!"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool("greet", {"name": "World", "extra": "surprise"})
|
||||
|
||||
async def test_migrated_tools_still_work(self, pair):
|
||||
_, mcp = pair
|
||||
async with Client(mcp) as client:
|
||||
greeting = await client.call_tool("greet", {"name": "World"})
|
||||
processed = await client.call_tool("process", {"items": ["a", "b"]})
|
||||
|
||||
assert greeting.data == "Hello, World!"
|
||||
assert processed.data == "Processed 2 items"
|
||||
|
||||
|
||||
class TestLowLevelGuide:
|
||||
"""docs/.../from-low-level-sdk-v2.mdx — the low-level Server migration."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def pair(self) -> tuple[dict[str, Any], FastMCP]:
|
||||
before = _block_containing("from-low-level-sdk-v2.mdx", ' "demo",')
|
||||
after = _block_containing("from-low-level-sdk-v2.mdx", 'FastMCP("demo")')
|
||||
return before, after["mcp"]
|
||||
|
||||
async def test_same_surface(self, pair):
|
||||
handlers, mcp = pair
|
||||
|
||||
tools = await handlers["list_tools"](None, None)
|
||||
resources = await handlers["list_resources"](None, None)
|
||||
prompts = await handlers["list_prompts"](None, None)
|
||||
before = {
|
||||
"tools": {t.name: _normalize(t.input_schema) for t in tools.tools},
|
||||
"resources": {str(r.uri) for r in resources.resources},
|
||||
"templates": set(),
|
||||
"prompts": {
|
||||
p.name: sorted(a.name for a in p.arguments or [])
|
||||
for p in prompts.prompts
|
||||
},
|
||||
}
|
||||
|
||||
after = await _fastmcp_surface(mcp)
|
||||
after["tools"] = _split_declared_strictness(before["tools"], after["tools"])
|
||||
assert before == after
|
||||
|
||||
async def test_fastmcp_tightens_the_argument_contract(self, pair):
|
||||
"""FastMCP rejects an unexpected argument where the handler ignored it.
|
||||
|
||||
A low-level handler reads `params.arguments` as a plain dict and never
|
||||
looks at keys it doesn't need, so extras pass through silently. The
|
||||
migrated tool rejects them. Pinned rather than normalized away, because
|
||||
it is a real change for any caller that was passing extra keys.
|
||||
"""
|
||||
handlers, mcp = pair
|
||||
params = type(
|
||||
"Params", (), {"name": "greet", "arguments": {"name": "World", "extra": 1}}
|
||||
)()
|
||||
|
||||
tolerated = await handlers["call_tool"](None, params)
|
||||
assert tolerated.content[0].text == "Hello, World!"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool("greet", {"name": "World", "extra": 1})
|
||||
|
||||
async def test_handlers_and_tools_agree(self, pair):
|
||||
"""The rewritten tool returns what the hand-written handler returned."""
|
||||
handlers, mcp = pair
|
||||
params = type("Params", (), {"name": "greet", "arguments": {"name": "World"}})()
|
||||
|
||||
handler_result = await handlers["call_tool"](None, params)
|
||||
async with Client(mcp) as client:
|
||||
tool_result = await client.call_tool("greet", {"name": "World"})
|
||||
|
||||
assert handler_result.content[0].text == "Hello, World!"
|
||||
assert tool_result.data == "Hello, World!"
|
||||
101
tests/docs/test_upgrade_guide_examples.py
Normal file
101
tests/docs/test_upgrade_guide_examples.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Execute the Python examples in the upgrade guides.
|
||||
|
||||
`test_doc_examples.py` covers every page in `docs/`, but only checks that
|
||||
examples parse and that their ``fastmcp.*`` imports resolve. The upgrade guides
|
||||
carry a stronger obligation: someone lands on one mid-migration, copies a block,
|
||||
and runs it. So these examples are actually executed, and their non-FastMCP
|
||||
imports (`mcp`, `mcp_types`) are exercised along with everything else.
|
||||
|
||||
Both halves of a `<CodeGroup>` are executed where they can be. The "after" code
|
||||
is FastMCP 4, which this repo is. The "before" code is only runnable when it
|
||||
targets the MCP SDK **v2** — the version installed here — which covers the two
|
||||
SDK v2 guides. Blocks written against SDK v1 (whose `mcp.types` and
|
||||
`mcp.server.fastmcp` no longer exist) and fragments that pair a "# Before" and
|
||||
"# After" in one block are tagged ``test="skip"`` in the source and skipped here;
|
||||
the count of those is pinned so a new one can't appear unnoticed.
|
||||
|
||||
Run:
|
||||
uv run pytest tests/docs/test_upgrade_guide_examples.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pytest_examples import CodeExample
|
||||
from pytest_examples.find_examples import _extract_code_chunks
|
||||
|
||||
import fastmcp
|
||||
|
||||
UPGRADE_DIR = Path("docs/getting-started/upgrading")
|
||||
|
||||
# Blocks deliberately not executable: SDK v1 API that is no longer installable,
|
||||
# and before/after fragments that are not standalone programs. Pinned so that
|
||||
# adding a skip is a visible decision rather than a silent one.
|
||||
EXPECTED_SKIPS = 35
|
||||
|
||||
|
||||
def _examples() -> list[CodeExample]:
|
||||
examples: list[CodeExample] = []
|
||||
for mdx_file in sorted(UPGRADE_DIR.rglob("*.mdx")):
|
||||
code = mdx_file.read_text("utf-8")
|
||||
examples.extend(_extract_code_chunks(mdx_file, code, uuid4()))
|
||||
return examples
|
||||
|
||||
|
||||
ALL = _examples()
|
||||
RUNNABLE = [ex for ex in ALL if ex.prefix_settings().get("test") != "skip"]
|
||||
SKIPPED = [ex for ex in ALL if ex.prefix_settings().get("test") == "skip"]
|
||||
|
||||
|
||||
def _example_id(example: CodeExample) -> str:
|
||||
return f"{Path(example.path).name}:{example.start_line}"
|
||||
|
||||
|
||||
def test_guides_have_examples():
|
||||
"""Guard against the extractor silently matching nothing."""
|
||||
assert len(RUNNABLE) >= 20, f"only found {len(RUNNABLE)} runnable examples"
|
||||
|
||||
|
||||
def test_skip_count_is_pinned():
|
||||
"""A newly unrunnable example should be a deliberate choice."""
|
||||
listing = "\n".join(f" {_example_id(ex)}" for ex in SKIPPED)
|
||||
assert len(SKIPPED) == EXPECTED_SKIPS, (
|
||||
f"expected {EXPECTED_SKIPS} skipped examples, found {len(SKIPPED)}:\n{listing}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_global_settings():
|
||||
"""Undo any global setting an example changes.
|
||||
|
||||
Some examples exist precisely to show a global toggle — the upgrade guide
|
||||
demonstrates turning the camelCase bridge off with
|
||||
``fastmcp.settings.mcp_camelcase_compat = False``. Executing that here
|
||||
would otherwise leave the bridge off for every test that runs afterwards in
|
||||
the same process, which silently breaks unrelated suites.
|
||||
"""
|
||||
before = fastmcp.settings.model_dump()
|
||||
yield
|
||||
for field, value in before.items():
|
||||
if getattr(fastmcp.settings, field, value) != value:
|
||||
setattr(fastmcp.settings, field, value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("example", RUNNABLE, ids=[_example_id(e) for e in RUNNABLE])
|
||||
def test_example_executes(example: CodeExample):
|
||||
"""Every non-skipped example runs top to bottom without raising.
|
||||
|
||||
Examples are executed under a module name other than ``__main__`` so an
|
||||
``if __name__ == "__main__": mcp.run()`` footer defines the server without
|
||||
starting it.
|
||||
"""
|
||||
namespace: dict[str, object] = {"__name__": "fastmcp_docs_example"}
|
||||
with warnings.catch_warnings():
|
||||
# Guides intentionally demonstrate deprecated surfaces (the camelCase
|
||||
# bridge, SDK logging) whose warnings are the point being made.
|
||||
warnings.simplefilter("ignore")
|
||||
exec(compile(example.source, str(example.path), "exec"), namespace)
|
||||
|
|
@ -8,22 +8,46 @@ single-server, namespaced mounts, and cross-server mounts.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP, FastMCPApp
|
||||
from fastmcp.server.providers.addressing import hashed_backend_name
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.experimental.transforms.code_mode import CodeMode
|
||||
from fastmcp.server.middleware.tool_injection import ToolInjectionMiddleware
|
||||
from fastmcp.server.providers.addressing import hash_tool, hashed_backend_name
|
||||
from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider
|
||||
from fastmcp.server.transforms.search import RegexSearchTransform
|
||||
from fastmcp.server.transforms.tool_transform import ToolTransform
|
||||
from fastmcp.tools.base import Tool
|
||||
from fastmcp.tools.tool_transform import ToolTransformConfig
|
||||
|
||||
prefab_ui = pytest.importorskip("prefab_ui")
|
||||
from prefab_ui.actions.mcp import CallTool # noqa: E402
|
||||
from prefab_ui.components import Button, Column, Text # noqa: E402
|
||||
|
||||
|
||||
def _tool_refs(payload) -> list[str]:
|
||||
"""Every tool name the rendered UI would call, in document order."""
|
||||
refs: list[str] = []
|
||||
|
||||
def walk(node) -> None:
|
||||
if isinstance(node, dict):
|
||||
if node.get("action") == "toolCall" and isinstance(node.get("tool"), str):
|
||||
refs.append(node["tool"])
|
||||
for value in node.values():
|
||||
walk(value)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
walk(item)
|
||||
|
||||
walk(payload)
|
||||
return refs
|
||||
|
||||
|
||||
class TestSingleServerRoundTrip:
|
||||
async def test_ui_tool_serializes_hashed_peer_reference(self):
|
||||
"""The resolver converts a CallTool string reference to a hashed
|
||||
name that appears in the tool result's structured_content."""
|
||||
async def test_payload_carries_the_servers_own_tool_name(self):
|
||||
"""The renderer is handed a name that exists in this server's
|
||||
tools/list, not the identity-addressed form."""
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.tool()
|
||||
|
|
@ -41,13 +65,32 @@ class TestSingleServerRoundTrip:
|
|||
|
||||
result = await server.call_tool("contact_form", {})
|
||||
assert result.structured_content is not None
|
||||
assert _tool_refs(result.structured_content) == ["save_contact"]
|
||||
|
||||
# The hashed name should appear somewhere in the serialized output.
|
||||
sc_json = json.dumps(result.structured_content)
|
||||
expected_hash = hashed_backend_name("contacts", "save_contact")
|
||||
assert expected_hash in sc_json, (
|
||||
f"Expected {expected_hash!r} in structured_content but got: {sc_json[:200]}"
|
||||
)
|
||||
async def test_payload_records_the_identity_behind_each_reference(self):
|
||||
"""The identity-addressed form survives alongside the rewritten name,
|
||||
so an outer server can re-resolve it — or fall back to it."""
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.tool()
|
||||
def save_contact(name: str) -> str:
|
||||
return f"saved {name}"
|
||||
|
||||
@app.ui()
|
||||
def contact_form() -> Column:
|
||||
return Column(
|
||||
children=[Button(label="Save", on_click=CallTool(tool="save_contact"))]
|
||||
)
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
|
||||
result = await server.call_tool("contact_form", {})
|
||||
assert result.structured_content is not None
|
||||
names = result.structured_content["_meta"]["fastmcp"]["toolNames"]
|
||||
assert names == {
|
||||
"save_contact": hashed_backend_name("contacts", "save_contact")
|
||||
}
|
||||
|
||||
async def test_hashed_name_from_result_is_callable(self):
|
||||
"""The hashed name that appears in structured_content actually
|
||||
|
|
@ -131,6 +174,470 @@ class TestMountedServerRoundTrip:
|
|||
assert result.content[0].text == "saved Carol" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
|
||||
class TestProxiedServerRoundTrip:
|
||||
"""A gateway proxying an app-bearing backend.
|
||||
|
||||
A proxy knows only what crossed the wire, so this is the topology that
|
||||
breaks if app-only tools are filtered out of tools/list or if the
|
||||
identity hash is stripped from meta.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _backend() -> FastMCP:
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.tool()
|
||||
def save(name: str) -> str:
|
||||
return f"saved {name}"
|
||||
|
||||
@app.ui()
|
||||
def form() -> Text:
|
||||
return Text(content="Form")
|
||||
|
||||
backend = FastMCP("Backend")
|
||||
backend.add_provider(app)
|
||||
return backend
|
||||
|
||||
async def test_app_only_tool_is_forwarded_through_a_proxy(self):
|
||||
backend = self._backend()
|
||||
gateway = FastMCP("Gateway")
|
||||
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
|
||||
|
||||
names = [t.name for t in await gateway.list_tools()]
|
||||
assert "save" in names
|
||||
|
||||
async def test_identity_hash_survives_the_proxy(self):
|
||||
backend = self._backend()
|
||||
gateway = FastMCP("Gateway")
|
||||
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
|
||||
|
||||
tool = next(t for t in await gateway.list_tools() if t.name == "save")
|
||||
assert tool.meta is not None
|
||||
assert tool.meta["fastmcp"]["tool_hash"] == hash_tool("contacts", "save")
|
||||
|
||||
async def test_backend_tool_callable_by_hash_through_a_proxy(self):
|
||||
backend = self._backend()
|
||||
gateway = FastMCP("Gateway")
|
||||
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
|
||||
|
||||
hashed_name = hashed_backend_name("contacts", "save")
|
||||
result = await gateway.call_tool(hashed_name, {"name": "Dana"})
|
||||
assert result.content[0].text == "saved Dana" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
async def test_backend_tool_callable_through_a_namespaced_proxy(self):
|
||||
backend = self._backend()
|
||||
gateway = FastMCP("Gateway")
|
||||
gateway.add_provider(
|
||||
ProxyProvider(lambda: ProxyClient(backend)), namespace="up"
|
||||
)
|
||||
|
||||
names = [t.name for t in await gateway.list_tools()]
|
||||
assert "up_save" in names
|
||||
|
||||
hashed_name = hashed_backend_name("contacts", "save")
|
||||
result = await gateway.call_tool(hashed_name, {"name": "Erin"})
|
||||
assert result.content[0].text == "saved Erin" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
async def test_backend_tool_callable_through_chained_proxies(self):
|
||||
backend = self._backend()
|
||||
middle = FastMCP("Middle")
|
||||
middle.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
|
||||
top = FastMCP("Top")
|
||||
top.add_provider(ProxyProvider(lambda: ProxyClient(middle)))
|
||||
|
||||
hashed_name = hashed_backend_name("contacts", "save")
|
||||
result = await top.call_tool(hashed_name, {"name": "Frank"})
|
||||
assert result.content[0].text == "saved Frank" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
|
||||
class TestLateBoundToolNames:
|
||||
"""The payload is re-addressed on the way out of every FastMCP server.
|
||||
|
||||
Servers unwind innermost-first, so the outermost one rewrites last and its
|
||||
names — the only ones a client can invoke — are what the renderer receives.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _app(marker: str = "x", app_name: str = "contacts") -> FastMCPApp:
|
||||
app = FastMCPApp(app_name)
|
||||
|
||||
@app.tool()
|
||||
def save(name: str) -> str:
|
||||
return f"[{marker}] saved {name}"
|
||||
|
||||
@app.ui()
|
||||
def form() -> Column:
|
||||
return Column(
|
||||
children=[Button(label="Save", on_click=CallTool(tool="save"))]
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
async def test_namespaced_server_emits_its_namespaced_name(self):
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(self._app(), namespace="crm")
|
||||
|
||||
result = await server.call_tool("crm_form", {})
|
||||
assert _tool_refs(result.structured_content) == ["crm_save"]
|
||||
|
||||
async def test_name_accumulates_through_nested_mounts(self):
|
||||
inner = FastMCP("Inner")
|
||||
inner.add_provider(self._app(), namespace="a")
|
||||
mid = FastMCP("Mid")
|
||||
mid.add_provider(inner, namespace="b")
|
||||
top = FastMCP("Top")
|
||||
top.add_provider(mid, namespace="c")
|
||||
|
||||
result = await top.call_tool("c_b_a_form", {})
|
||||
assert _tool_refs(result.structured_content) == ["c_b_a_save"]
|
||||
|
||||
async def test_gateway_emits_its_own_name_not_the_backends(self):
|
||||
backend = FastMCP("Backend")
|
||||
backend.add_provider(self._app())
|
||||
|
||||
gateway = FastMCP("Gateway")
|
||||
gateway.add_provider(
|
||||
ProxyProvider(lambda: ProxyClient(backend)), namespace="up"
|
||||
)
|
||||
|
||||
result = await gateway.call_tool("up_form", {})
|
||||
assert _tool_refs(result.structured_content) == ["up_save"]
|
||||
|
||||
async def test_emitted_name_is_callable_on_the_same_server(self):
|
||||
"""The whole point: what the renderer is told to call, it can call."""
|
||||
backend = FastMCP("Backend")
|
||||
backend.add_provider(self._app(marker="be"))
|
||||
|
||||
gateway = FastMCP("Gateway")
|
||||
gateway.add_provider(
|
||||
ProxyProvider(lambda: ProxyClient(backend)), namespace="up"
|
||||
)
|
||||
|
||||
result = await gateway.call_tool("up_form", {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref in [t.name for t in await gateway.list_tools()]
|
||||
|
||||
clicked = await gateway.call_tool(ref, {"name": "alice"})
|
||||
assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"transform_factory,expected_listing",
|
||||
[
|
||||
(
|
||||
lambda: RegexSearchTransform(),
|
||||
["search_tools", "call_tool"],
|
||||
),
|
||||
(
|
||||
lambda: CodeMode(),
|
||||
["search", "get_schema", "execute"],
|
||||
),
|
||||
],
|
||||
ids=["tool-search", "code-mode"],
|
||||
)
|
||||
async def test_survives_a_collapsed_catalog(
|
||||
self, transform_factory, expected_listing
|
||||
):
|
||||
"""Tool search and code mode replace tools/list wholesale, so there is
|
||||
no better name to bind to. The reference stays identity-addressed and
|
||||
the hashed path still resolves it."""
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(self._app(marker="cat"))
|
||||
server.add_transform(transform_factory())
|
||||
|
||||
assert [t.name for t in await server.list_tools()] == expected_listing
|
||||
|
||||
result = await server.call_tool("form", {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref == hashed_backend_name("contacts", "save")
|
||||
|
||||
clicked = await server.call_tool(ref, {"name": "alice"})
|
||||
assert clicked.content[0].text == "[cat] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"compose",
|
||||
["siblings", "nested", "prefixing-namespaces"],
|
||||
)
|
||||
async def test_a_duplicated_app_is_not_bound(self, compose):
|
||||
"""One app composed twice leaves no fact in the listing saying which
|
||||
copy a UI belongs to, so no name is bound and the reference keeps its
|
||||
identity. Covers copies as siblings, nested inside one subtree, and
|
||||
under namespaces that prefix one another.
|
||||
"""
|
||||
if compose == "nested":
|
||||
inner = FastMCP("Inner")
|
||||
inner.add_provider(self._app(marker="A"), namespace="a")
|
||||
inner.add_provider(self._app(marker="B"), namespace="b")
|
||||
server = FastMCP("Top")
|
||||
server.add_provider(inner, namespace="outer")
|
||||
entry = "outer_a_form"
|
||||
else:
|
||||
second = "a_form" if compose == "prefixing-namespaces" else "b"
|
||||
server = FastMCP("Top")
|
||||
server.add_provider(self._app(marker="A"), namespace="a")
|
||||
server.add_provider(self._app(marker="B"), namespace=second)
|
||||
entry = "a_form"
|
||||
|
||||
result = await server.call_tool(entry, {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref == hashed_backend_name("contacts", "save")
|
||||
|
||||
async def test_a_duplicated_app_reports_the_ambiguity(self):
|
||||
"""The unbound reference must fail with a message that names the real
|
||||
cause, at any depth — a nested duplicate previously surfaced as
|
||||
`Unknown tool`, sending readers after a missing registration.
|
||||
"""
|
||||
inner = FastMCP("Inner")
|
||||
inner.add_provider(self._app(marker="A"), namespace="a")
|
||||
inner.add_provider(self._app(marker="B"), namespace="b")
|
||||
server = FastMCP("Top")
|
||||
server.add_provider(inner, namespace="outer")
|
||||
|
||||
result = await server.call_tool("outer_a_form", {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
|
||||
with pytest.raises(ToolError, match="composed more than once"):
|
||||
await server.call_tool(ref, {"name": "alice"})
|
||||
|
||||
@pytest.mark.parametrize("backend_namespace", [None, "crm"])
|
||||
async def test_collapsed_catalog_over_a_proxy(self, backend_namespace):
|
||||
"""The collapsed-catalog fallback has to survive a backend that
|
||||
renamed its app tools. Nothing named `save` was ever listed across
|
||||
the wire, so the identity has to resolve against the remote listing
|
||||
rather than against a name that only exists at the origin.
|
||||
"""
|
||||
app = self._app(marker="be")
|
||||
backend = FastMCP("Backend")
|
||||
backend.add_provider(app, namespace=backend_namespace)
|
||||
|
||||
gateway = FastMCP("Gateway")
|
||||
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
|
||||
gateway.add_transform(RegexSearchTransform())
|
||||
|
||||
entry = f"{backend_namespace}_form" if backend_namespace else "form"
|
||||
result = await gateway.call_tool(entry, {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref == hashed_backend_name("contacts", "save")
|
||||
|
||||
clicked = await gateway.call_tool(ref, {"name": "alice"})
|
||||
assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
async def test_versions_of_one_tool_are_a_single_target(self):
|
||||
"""Versions are listed individually and share an identity, but they
|
||||
also share a name that resolves to the highest version on its own.
|
||||
Only distinct names mean distinct copies of an app.
|
||||
"""
|
||||
app = FastMCPApp("contacts")
|
||||
for version, prefix in (("1.0.0", "v1"), ("2.0.0", "v2")):
|
||||
|
||||
def save(name: str, _prefix: str = prefix) -> str:
|
||||
return f"{_prefix} saved {name}"
|
||||
|
||||
app.add_tool(Tool.from_function(save, name="save", version=version))
|
||||
|
||||
@app.ui()
|
||||
def form() -> Column:
|
||||
return Column(
|
||||
children=[Button(label="Save", on_click=CallTool(tool="save"))]
|
||||
)
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
|
||||
result = await server.call_tool("form", {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref == "save"
|
||||
|
||||
clicked = await server.call_tool(ref, {"name": "alice"})
|
||||
assert clicked.content[0].text == "v2 saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
async def test_distinct_apps_sharing_a_backend_name(self):
|
||||
"""Identity and name must agree in both directions. Two apps can each
|
||||
expose `save`: the identities differ and each has one candidate, but
|
||||
the shared name resolves to only one of them.
|
||||
"""
|
||||
server = FastMCP("Platform")
|
||||
for app_name, entry, marker in (
|
||||
("crm", "crm_ui", "CRM"),
|
||||
("billing", "billing_ui", "BILLING"),
|
||||
):
|
||||
app = FastMCPApp(app_name)
|
||||
|
||||
@app.tool()
|
||||
def save(name: str, _marker: str = marker) -> str:
|
||||
return f"[{_marker}] saved {name}"
|
||||
|
||||
@app.ui(entry)
|
||||
def form() -> Column:
|
||||
return Column(
|
||||
children=[Button(label="Save", on_click=CallTool(tool="save"))]
|
||||
)
|
||||
|
||||
server.add_provider(app)
|
||||
|
||||
result = await server.call_tool("billing_ui", {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref == hashed_backend_name("billing", "save")
|
||||
|
||||
async def test_proxy_refuses_a_remote_that_duplicates_an_app(self):
|
||||
"""A remote mounting one app twice sends back two tools claiming one
|
||||
identity, and the proxy must refuse on the same terms a local
|
||||
composition would rather than returning whichever came first.
|
||||
"""
|
||||
backend = FastMCP("Backend")
|
||||
backend.add_provider(self._app(marker="A"), namespace="a")
|
||||
backend.add_provider(self._app(marker="B"), namespace="b")
|
||||
|
||||
gateway = FastMCP("Gateway")
|
||||
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
|
||||
|
||||
with pytest.raises(ToolError, match="composed more than once"):
|
||||
await gateway.call_tool(
|
||||
hashed_backend_name("contacts", "save"), {"name": "alice"}
|
||||
)
|
||||
|
||||
async def test_middleware_owns_the_names_it_shadows(self):
|
||||
"""Binding describes the listing a client will see, so it has to run
|
||||
the middleware chain. An injected tool sharing a backend's name owns
|
||||
that name at call time, and would be invisible to a listing taken
|
||||
beneath middleware.
|
||||
"""
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.tool()
|
||||
def save(name: str) -> str:
|
||||
return f"[APP] saved {name}"
|
||||
|
||||
@app.ui()
|
||||
def form() -> Column:
|
||||
return Column(
|
||||
children=[Button(label="Save", on_click=CallTool(tool="save"))]
|
||||
)
|
||||
|
||||
def injected(name: str) -> str:
|
||||
return f"[INJECTED] saved {name}"
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
server.add_middleware(
|
||||
ToolInjectionMiddleware([Tool.from_function(injected, name="save")])
|
||||
)
|
||||
|
||||
result = await server.call_tool("form", {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref == hashed_backend_name("contacts", "save")
|
||||
|
||||
clicked = await server.call_tool(ref, {"name": "alice"})
|
||||
assert clicked.content[0].text == "[APP] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
async def test_middleware_produced_results_are_rebound(self):
|
||||
"""Middleware can answer a call itself, and such a result never
|
||||
reaches the core dispatch path — so rebinding belongs above the
|
||||
chain, not inside it.
|
||||
"""
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.tool()
|
||||
def save(name: str) -> str:
|
||||
return f"saved {name}"
|
||||
|
||||
@app.ui()
|
||||
def form() -> Column:
|
||||
return Column(
|
||||
children=[Button(label="Save", on_click=CallTool(tool="save"))]
|
||||
)
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
|
||||
entry = await server.get_tool("form")
|
||||
assert entry is not None
|
||||
server.add_middleware(
|
||||
ToolInjectionMiddleware([entry.model_copy(update={"name": "injected"})])
|
||||
)
|
||||
|
||||
result = await server.call_tool("injected", {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref == "save"
|
||||
|
||||
clicked = await server.call_tool(ref, {"name": "alice"})
|
||||
assert clicked.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
async def test_a_transform_cannot_unwire_an_app_tool(self):
|
||||
"""A meta override that keeps the identity but drops app visibility
|
||||
leaves a tool that can be named yet no longer answers to its
|
||||
identity — which is the only address a collapsed catalog has.
|
||||
"""
|
||||
backend = FastMCP("Backend")
|
||||
backend.add_provider(self._app(marker="be"))
|
||||
backend.add_transform(
|
||||
ToolTransform({"save": ToolTransformConfig(meta={"team": "crm"})})
|
||||
)
|
||||
|
||||
transformed = next(t for t in await backend.list_tools() if t.name == "save")
|
||||
assert transformed.meta is not None
|
||||
assert transformed.meta["ui"]["visibility"] == ["app"]
|
||||
assert transformed.meta["team"] == "crm"
|
||||
|
||||
gateway = FastMCP("Gateway")
|
||||
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
|
||||
gateway.add_transform(RegexSearchTransform())
|
||||
|
||||
result = await gateway.call_tool("form", {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref == hashed_backend_name("contacts", "save")
|
||||
|
||||
clicked = await gateway.call_tool(ref, {"name": "alice"})
|
||||
assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
async def test_duplicate_copies_are_not_collapsed_by_a_shared_name(self):
|
||||
"""Copies whose backends collide on a name are the worst case, not the
|
||||
safe one: two components become indistinguishable. Counting names
|
||||
alone would see a single unambiguous target and bind to it.
|
||||
"""
|
||||
server = FastMCP("Platform")
|
||||
for entry, marker in (("form_a", "A"), ("form_b", "B")):
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.tool()
|
||||
def save(name: str, _marker: str = marker) -> str:
|
||||
return f"[{_marker}] saved {name}"
|
||||
|
||||
@app.ui(entry)
|
||||
def form() -> Column:
|
||||
return Column(
|
||||
children=[Button(label="Save", on_click=CallTool(tool="save"))]
|
||||
)
|
||||
|
||||
server.add_provider(app)
|
||||
|
||||
listed = await server.list_tools()
|
||||
assert [t.key for t in listed].count("tool:save@") == 2
|
||||
|
||||
result = await server.call_tool("form_b", {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref == hashed_backend_name("contacts", "save")
|
||||
|
||||
async def test_unresolvable_identity_is_restored(self):
|
||||
"""An inner server binds to a name that means nothing further out, so
|
||||
a reference this server cannot resolve is restored to its identity
|
||||
rather than left — a stranded name has no route back, an identity does.
|
||||
"""
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.ui()
|
||||
def form() -> Column:
|
||||
return Column(
|
||||
children=[Button(label="Go", on_click=CallTool(tool="not_registered"))]
|
||||
)
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
|
||||
result = await server.call_tool("form", {})
|
||||
(ref,) = _tool_refs(result.structured_content)
|
||||
assert ref == hashed_backend_name("contacts", "not_registered")
|
||||
|
||||
|
||||
class TestDynamicToolAdd:
|
||||
async def test_tool_added_after_first_call_is_reachable(self):
|
||||
"""Tools added to an already-mounted app after the first call
|
||||
|
|
@ -157,10 +664,9 @@ class TestDynamicToolAdd:
|
|||
|
||||
|
||||
class TestCollision:
|
||||
async def test_same_app_name_same_tool_name_first_wins(self):
|
||||
"""Two apps with the same name and same tool name: the hash is
|
||||
identical, so get_tool_by_hash returns the first match. This is
|
||||
the same first-match behavior the old get_app_tool had."""
|
||||
async def test_distinct_hashes_resolve_independently(self):
|
||||
"""Two apps sharing a name but with different tool names hash
|
||||
differently, so each tool resolves to itself."""
|
||||
app_a = FastMCPApp("shared")
|
||||
app_b = FastMCPApp("shared")
|
||||
|
||||
|
|
@ -172,14 +678,67 @@ class TestCollision:
|
|||
def save_b(name: str) -> str:
|
||||
return f"from B: {name}"
|
||||
|
||||
# Register under a different local tool name to avoid
|
||||
# actual collision at the provider level. The hash collision
|
||||
# only happens when both app name AND tool name match.
|
||||
# This test just verifies one app's tool is reachable.
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app_a)
|
||||
server.add_provider(app_b)
|
||||
|
||||
hashed_name = hashed_backend_name("shared", "save")
|
||||
result = await server.call_tool(hashed_name, {"name": "Eve"})
|
||||
result = await server.call_tool(
|
||||
hashed_backend_name("shared", "save"), {"name": "Eve"}
|
||||
)
|
||||
assert result.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
result_b = await server.call_tool(
|
||||
hashed_backend_name("shared", "save_b"), {"name": "Eve"}
|
||||
)
|
||||
assert result_b.content[0].text == "from B: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
async def test_ambiguous_identity_raises_rather_than_guessing(self):
|
||||
"""The same app composed into two branches yields two tools with one
|
||||
identity. Routing to either would silently execute the wrong branch's
|
||||
tool, so the call is refused."""
|
||||
server = FastMCP("Platform")
|
||||
for marker, namespace in (("A", "a"), ("B", "b")):
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.tool()
|
||||
def save(name: str, _marker: str = marker) -> str:
|
||||
return f"from {_marker}: {name}"
|
||||
|
||||
server.add_provider(app, namespace=namespace)
|
||||
|
||||
with pytest.raises(ToolError, match="Ambiguous app tool"):
|
||||
await server.call_tool(
|
||||
hashed_backend_name("contacts", "save"), {"name": "Eve"}
|
||||
)
|
||||
|
||||
async def test_distinct_app_names_route_independently_through_a_gateway(self):
|
||||
"""The multi-tenant gateway shape: distinct app names stay unambiguous
|
||||
no matter how many backends sit behind one proxy."""
|
||||
|
||||
def backend(marker: str, app_name: str) -> FastMCP:
|
||||
app = FastMCPApp(app_name)
|
||||
|
||||
@app.tool()
|
||||
def save(name: str) -> str:
|
||||
return f"from {marker}: {name}"
|
||||
|
||||
server = FastMCP(f"Backend-{marker}")
|
||||
server.add_provider(app)
|
||||
return server
|
||||
|
||||
first = backend("A", "crm")
|
||||
second = backend("B", "billing")
|
||||
|
||||
gateway = FastMCP("Gateway")
|
||||
gateway.add_provider(ProxyProvider(lambda: ProxyClient(first)), namespace="a")
|
||||
gateway.add_provider(ProxyProvider(lambda: ProxyClient(second)), namespace="b")
|
||||
|
||||
result_a = await gateway.call_tool(
|
||||
hashed_backend_name("crm", "save"), {"name": "Eve"}
|
||||
)
|
||||
assert result_a.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
result_b = await gateway.call_tool(
|
||||
hashed_backend_name("billing", "save"), {"name": "Eve"}
|
||||
)
|
||||
assert result_b.content[0].text == "from B: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
|
|
|||
168
tests/server/transforms/test_model_visibility_boundary.py
Normal file
168
tests/server/transforms/test_model_visibility_boundary.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""App-only tools must not reach the model through server-driven surfaces.
|
||||
|
||||
`tools/list` carries app-only tools on purpose — intermediaries need them to
|
||||
forward, and the MCP Apps spec puts visibility filtering on the host. That
|
||||
division holds only where a host sits between the server and the model.
|
||||
|
||||
A search result, a code-mode catalog, and a call-tool proxy are all driven by
|
||||
the server itself: the first two reach the model as ordinary tool output, and
|
||||
the third invokes on a name the model supplies. No host mediates any of them,
|
||||
so the visibility declaration has to be applied server-side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import Client, FastMCP, FastMCPApp
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.experimental.transforms.code_mode import CodeMode
|
||||
from fastmcp.server.providers.addressing import hashed_backend_name
|
||||
from fastmcp.server.transforms.search import BM25SearchTransform, RegexSearchTransform
|
||||
from fastmcp.tools.base import Tool
|
||||
|
||||
|
||||
def build_server_without_transform() -> FastMCP:
|
||||
return _build(None)
|
||||
|
||||
|
||||
def build_server(transform) -> FastMCP:
|
||||
return _build(transform)
|
||||
|
||||
|
||||
def _build(transform) -> FastMCP:
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.tool()
|
||||
def save_contact(name: str) -> str:
|
||||
"""UI-only backend that writes a contact."""
|
||||
return f"saved {name}"
|
||||
|
||||
@app.tool(model=True)
|
||||
def search_contacts(query: str) -> str:
|
||||
"""Model-visible backend."""
|
||||
return f"found {query}"
|
||||
|
||||
@app.ui()
|
||||
def contacts_ui() -> str:
|
||||
return "ui"
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
if transform is not None:
|
||||
server.add_transform(transform)
|
||||
return server
|
||||
|
||||
|
||||
CATALOG_TRANSFORMS = [
|
||||
pytest.param(RegexSearchTransform, id="regex-search"),
|
||||
pytest.param(BM25SearchTransform, id="bm25-search"),
|
||||
pytest.param(CodeMode, id="code-mode"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transform_cls", CATALOG_TRANSFORMS)
|
||||
async def test_app_only_tools_stay_out_of_model_catalogs(transform_cls):
|
||||
"""Discovery surfaces hand tool definitions straight to the model."""
|
||||
server = build_server(transform_cls())
|
||||
|
||||
async with Client(server) as client:
|
||||
blob = ""
|
||||
for tool in await client.list_tools():
|
||||
if "search" not in tool.name:
|
||||
continue
|
||||
# Each transform names its search argument differently; the
|
||||
# schema is the authority.
|
||||
(argument,) = (tool.input_schema or {}).get("required", ["query"])
|
||||
result = await client.call_tool(tool.name, {argument: "search_contacts"})
|
||||
blob += json.dumps(result.structured_content or "")
|
||||
blob += "".join(
|
||||
block.text for block in result.content if hasattr(block, "text")
|
||||
)
|
||||
|
||||
assert blob, "no search surface produced output"
|
||||
assert "save_contact" not in blob
|
||||
assert "search_contacts" in blob
|
||||
|
||||
|
||||
async def test_app_only_tools_are_listed_for_forwarding():
|
||||
"""The wire listing keeps them: a proxy cannot forward what it cannot see.
|
||||
|
||||
Only the model-facing catalog is filtered, so a server without a catalog
|
||||
transform still advertises the tool and its declaration for a host to
|
||||
act on.
|
||||
"""
|
||||
plain = build_server_without_transform()
|
||||
|
||||
async with Client(plain) as client:
|
||||
listed = {tool.name: tool for tool in await client.list_tools()}
|
||||
|
||||
assert "save_contact" in listed
|
||||
assert listed["save_contact"].meta is not None
|
||||
assert listed["save_contact"].meta["ui"]["visibility"] == ["app"]
|
||||
|
||||
|
||||
async def test_call_tool_proxy_refuses_undiscoverable_tools():
|
||||
"""The proxy takes a model-supplied name, so it is a second door in."""
|
||||
server = build_server(RegexSearchTransform())
|
||||
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(ToolError, match="save_contact"):
|
||||
await client.call_tool(
|
||||
"call_tool",
|
||||
{"name": "save_contact", "arguments": {"name": "eve"}},
|
||||
)
|
||||
|
||||
allowed = await client.call_tool(
|
||||
"call_tool",
|
||||
{"name": "search_contacts", "arguments": {"query": "ada"}},
|
||||
)
|
||||
assert allowed.content[0].text == "found ada" # type: ignore[union-attr]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transform_cls", CATALOG_TRANSFORMS)
|
||||
async def test_the_apps_own_ui_still_reaches_its_backend(transform_cls):
|
||||
"""The point of the boundary is the audience, not the tool: a UI calling
|
||||
by identity is not the model, and must still work.
|
||||
"""
|
||||
server = build_server(transform_cls())
|
||||
|
||||
result = await server.call_tool(
|
||||
hashed_backend_name("contacts", "save_contact"), {"name": "ada"}
|
||||
)
|
||||
assert result.content[0].text == "saved ada" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
|
||||
async def test_visibility_is_checked_on_the_version_a_name_reaches():
|
||||
"""A bare name selects the highest version, so that is the one whose
|
||||
declaration governs. Checking before deduplication would advertise a
|
||||
model-visible older version whose name runs an app-only newer one.
|
||||
"""
|
||||
|
||||
def versioned(version: str, visibility: list[str], marker: str) -> Tool:
|
||||
def same() -> str:
|
||||
return f"ran {marker}"
|
||||
|
||||
return Tool.from_function(
|
||||
same, name="same", version=version, meta={"ui": {"visibility": visibility}}
|
||||
)
|
||||
|
||||
app = FastMCPApp("contacts")
|
||||
app.add_tool(versioned("1.0.0", ["app", "model"], "v1"))
|
||||
app.add_tool(versioned("2.0.0", ["app"], "v2"))
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
server.add_transform(RegexSearchTransform())
|
||||
|
||||
async with Client(server) as client:
|
||||
found = await client.call_tool("search_tools", {"pattern": "same"})
|
||||
blob = json.dumps(found.structured_content or "") + "".join(
|
||||
block.text for block in found.content if hasattr(block, "text")
|
||||
)
|
||||
assert "same" not in blob
|
||||
|
||||
with pytest.raises(ToolError, match="same"):
|
||||
await client.call_tool("call_tool", {"name": "same", "arguments": {}})
|
||||
|
|
@ -22,6 +22,7 @@ from fastmcp.apps.app import (
|
|||
FastMCPApp,
|
||||
_make_resolver,
|
||||
)
|
||||
from fastmcp.server.providers.addressing import hash_tool, hashed_backend_name
|
||||
from fastmcp.tools.base import Tool
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -579,13 +580,13 @@ class TestCallToolAppRouting:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App-only tool filtering from server list_tools / get_tool
|
||||
# App-only tool visibility: declared in meta, listed on the wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAppOnlyToolFiltering:
|
||||
async def test_app_only_tool_hidden_from_list_tools(self):
|
||||
"""@app.tool() (visibility=["app"]) should not appear in server.list_tools()."""
|
||||
class TestAppOnlyToolVisibility:
|
||||
async def test_app_only_tool_appears_in_list_tools(self):
|
||||
"""@app.tool() (visibility=["app"]) is listed; the host filters it out."""
|
||||
app = FastMCPApp("crm")
|
||||
|
||||
@app.tool()
|
||||
|
|
@ -597,7 +598,40 @@ class TestAppOnlyToolFiltering:
|
|||
|
||||
tools = await server.list_tools()
|
||||
names = [t.name for t in tools]
|
||||
assert "save_contact" not in names
|
||||
assert "save_contact" in names
|
||||
|
||||
async def test_app_only_tool_declares_app_visibility(self):
|
||||
"""The listed tool carries visibility=["app"] so a host can filter it."""
|
||||
app = FastMCPApp("crm")
|
||||
|
||||
@app.tool()
|
||||
def save_contact(name: str) -> str:
|
||||
return name
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
|
||||
tool = next(t for t in await server.list_tools() if t.name == "save_contact")
|
||||
assert tool.meta is not None
|
||||
assert tool.meta["ui"]["visibility"] == ["app"]
|
||||
|
||||
async def test_app_only_tool_visibility_survives_the_wire(self):
|
||||
"""A client sees the visibility declaration, which is what it filters on."""
|
||||
app = FastMCPApp("crm")
|
||||
|
||||
@app.tool()
|
||||
def save_contact(name: str) -> str:
|
||||
return name
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
|
||||
async with Client(server) as client:
|
||||
tool = next(
|
||||
t for t in await client.list_tools() if t.name == "save_contact"
|
||||
)
|
||||
assert tool.meta is not None
|
||||
assert tool.meta["ui"]["visibility"] == ["app"]
|
||||
|
||||
async def test_model_visible_tool_in_list_tools(self):
|
||||
"""@app.tool(model=True) (visibility=["app","model"]) appears in list_tools."""
|
||||
|
|
@ -629,8 +663,8 @@ class TestAppOnlyToolFiltering:
|
|||
names = [t.name for t in tools]
|
||||
assert "show_dashboard" in names
|
||||
|
||||
async def test_app_only_tool_still_callable_via_app_name(self):
|
||||
"""Even though filtered from list_tools, app-only tools are callable via call_tool with app_name."""
|
||||
async def test_app_only_tool_callable_via_hashed_address(self):
|
||||
"""The hashed address still resolves, independent of the display name."""
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.tool()
|
||||
|
|
@ -640,35 +674,30 @@ class TestAppOnlyToolFiltering:
|
|||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
|
||||
# Verify it's hidden from list_tools
|
||||
tools = await server.list_tools()
|
||||
names = [t.name for t in tools]
|
||||
assert "save" not in names
|
||||
|
||||
# But still callable via the hashed-address routing path.
|
||||
from fastmcp.server.providers.addressing import hashed_backend_name
|
||||
|
||||
result = await server.call_tool(
|
||||
hashed_backend_name("contacts", "save"), {"name": "alice"}
|
||||
)
|
||||
assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
async def test_app_only_tool_hidden_from_get_tool(self):
|
||||
"""server.get_tool() returns None for app-only tools."""
|
||||
app = FastMCPApp("crm")
|
||||
async def test_app_only_tool_callable_by_display_name(self):
|
||||
"""App-only tools resolve normally; the host decides who may call them."""
|
||||
app = FastMCPApp("contacts")
|
||||
|
||||
@app.tool()
|
||||
def save_contact(name: str) -> str:
|
||||
return name
|
||||
def save(name: str) -> str:
|
||||
return f"saved {name}"
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app)
|
||||
|
||||
tool = await server.get_tool("save_contact")
|
||||
assert tool is None
|
||||
tool = await server.get_tool("save")
|
||||
assert tool is not None
|
||||
|
||||
async def test_app_only_tool_hidden_with_namespace(self):
|
||||
"""App-only tools hidden even when accessed through a namespace."""
|
||||
result = await server.call_tool("save", {"name": "alice"})
|
||||
assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
async def test_app_only_tool_namespaced_in_list_tools(self):
|
||||
"""Namespacing renames app-only tools like any other tool."""
|
||||
app = FastMCPApp("crm")
|
||||
|
||||
@app.tool()
|
||||
|
|
@ -680,7 +709,23 @@ class TestAppOnlyToolFiltering:
|
|||
|
||||
tools = await server.list_tools()
|
||||
names = [t.name for t in tools]
|
||||
assert "crm_save" not in names
|
||||
assert "crm_save" in names
|
||||
|
||||
async def test_app_only_tool_carries_public_hash(self):
|
||||
"""The identity hash is public meta, so intermediaries can match on it."""
|
||||
app = FastMCPApp("crm")
|
||||
|
||||
@app.tool()
|
||||
def save(name: str) -> str:
|
||||
return name
|
||||
|
||||
server = FastMCP("Platform")
|
||||
server.add_provider(app, namespace="crm")
|
||||
|
||||
async with Client(server) as client:
|
||||
tool = next(t for t in await client.list_tools() if t.name == "crm_save")
|
||||
assert tool.meta is not None
|
||||
assert tool.meta["fastmcp"]["tool_hash"] == hash_tool("crm", "save")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -872,21 +917,25 @@ class TestAppIntegration:
|
|||
server = FastMCP("Platform")
|
||||
server.add_provider(app, namespace="crm")
|
||||
|
||||
# The @app.ui() tool should be visible (namespaced) to the client.
|
||||
# The @app.tool() backend tool should NOT appear.
|
||||
# Both tools are listed (namespaced). The backend tool declares
|
||||
# visibility=["app"] so the host keeps it out of the model's list.
|
||||
async with Client(server) as client:
|
||||
tools = await client.list_tools()
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "crm_contact_form" in tool_names
|
||||
assert "crm_save_contact" not in tool_names
|
||||
assert "crm_save_contact" in tool_names
|
||||
|
||||
backend = next(t for t in tools if t.name == "crm_save_contact")
|
||||
assert backend.meta is not None
|
||||
assert backend.meta["ui"]["visibility"] == ["app"]
|
||||
|
||||
# Call the UI tool through the client and check structured_content
|
||||
result = await client.call_tool_mcp("crm_contact_form", {})
|
||||
sc = result.structured_content
|
||||
assert sc is not None
|
||||
|
||||
# Call the backend tool via its hashed address — bypasses namespace
|
||||
# transforms and visibility filtering by going through the registry.
|
||||
# Call the backend tool via its hashed address — resolves regardless
|
||||
# of the namespace transform applied to the display name.
|
||||
backend_result = await server.call_tool(
|
||||
hashed_backend_name("contacts", "save_contact"),
|
||||
{"name": "Alice", "email": "alice@example.com"},
|
||||
|
|
|
|||
|
|
@ -172,6 +172,45 @@ def test_tool_transform_config_removes_meta(sample_tool):
|
|||
assert transformed.meta is None
|
||||
|
||||
|
||||
def test_meta_override_preserves_fastmcp_namespace(sample_tool):
|
||||
"""A meta override replaces caller meta but keeps framework-owned data.
|
||||
|
||||
The fastmcp namespace carries app membership and the identity hash that
|
||||
intermediaries match on. A rename via config must not destroy it.
|
||||
"""
|
||||
sample_tool.meta = {"original": True, "fastmcp": {"app": "crm", "tool_hash": "abc"}}
|
||||
transformed = Tool.from_tool(sample_tool, meta={"custom": True})
|
||||
assert transformed.meta == {
|
||||
"custom": True,
|
||||
"fastmcp": {"app": "crm", "tool_hash": "abc"},
|
||||
}
|
||||
|
||||
|
||||
def test_meta_none_preserves_fastmcp_namespace(sample_tool):
|
||||
"""Clearing meta clears caller meta, not the framework namespace."""
|
||||
sample_tool.meta = {"original": True, "fastmcp": {"app": "crm", "tool_hash": "abc"}}
|
||||
transformed = Tool.from_tool(sample_tool, meta=None)
|
||||
assert transformed.meta == {"fastmcp": {"app": "crm", "tool_hash": "abc"}}
|
||||
|
||||
|
||||
def test_meta_override_can_extend_fastmcp_namespace(sample_tool):
|
||||
"""An override may add to the fastmcp namespace without dropping its keys."""
|
||||
sample_tool.meta = {"fastmcp": {"app": "crm", "tool_hash": "abc"}}
|
||||
transformed = Tool.from_tool(sample_tool, meta={"fastmcp": {"extra": 1}})
|
||||
assert transformed.meta == {
|
||||
"fastmcp": {"app": "crm", "tool_hash": "abc", "extra": 1}
|
||||
}
|
||||
|
||||
|
||||
def test_config_meta_override_preserves_identity_hash(sample_tool):
|
||||
"""The fastmcp.json `tools:` path goes through the same preservation."""
|
||||
sample_tool.meta = {"fastmcp": {"app": "crm", "tool_hash": "abc"}}
|
||||
config = ToolTransformConfig(name="renamed", meta={"team": "growth"})
|
||||
transformed = config.apply(sample_tool)
|
||||
assert transformed.meta is not None
|
||||
assert transformed.meta["fastmcp"]["tool_hash"] == "abc"
|
||||
|
||||
|
||||
# Enabled field tests
|
||||
def test_tool_transform_config_enabled_defaults_to_true(sample_tool):
|
||||
"""Test that enabled defaults to True and no visibility metadata is set."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue