Back to Fastmcp

Upgrading from MCP SDK v2

docs/getting-started/upgrading/from-mcp-sdk-v2.mdx

4.0.0b121.3 KB
Original Source

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_serverctx.fastmcp
  • ctx.headersget_http_headers() from fastmcp.server.dependencies (a function, not a property)
  • ctx.protocol_versionctx.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.

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 v2FastMCP
from mcp.server.mcpserver import MCPServerfrom fastmcp import FastMCP
from mcp.server.mcpserver import Contextfrom fastmcp import Context
from mcp.server.mcpserver import Image, Audiofrom fastmcp.utilities.types import Image, Audio
from mcp.server.mcpserver.prompts.base import Messagefrom 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_serverctx.fastmcp
ctx.headersget_http_headers() from fastmcp.server.dependencies
ctx.protocol_versionctx.request_context.protocol_version
ctx.client_capabilitiesread it off ctx.session
from mcp_types import Xunchanged

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
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
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=TrueFASTMCP_DEBUG environment variable
log_level="DEBUG"run_http_async(log_level=...) or FASTMCP_LOG_LEVEL
warn_on_duplicate_tools, _resources, _promptsa single on_duplicate=
dependencies=[...]a fastmcp.json 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(...)
middleware=[ServerMiddleware, ...]middleware=[Middleware, ...] — same keyword, different class

middleware= is the row most likely to be mistaken for a rename. Both constructors take a middleware= sequence, but an MCPServer wants the SDK's ServerMiddleware — one hook wrapping every raw JSON-RPC message — while FastMCP wants its own Middleware, which adds typed per-operation hooks (on_call_tool, on_list_tools, and the rest) on top of the same message-level pass. Keeping the keyword and swapping the base class is the migration; see Middleware.

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.

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 v2FastMCP
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 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
# 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, 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 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 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 mounts one server inside another, so a large surface splits into modules that are developed and tested independently. Middleware runs across every request for logging, rate limiting, error handling, and caching, with hooks at whichever level of specificity you need. Proxy servers 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 generates a whole server from an existing API specification. Tool transformation rewrites the tools a server exposes — renaming, hiding, and reshaping arguments — without touching the code that defines them.

FastMCP also ships a 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 a server means calling its tools in the same Python process, with no subprocess and no network.