docs/getting-started/upgrading/from-low-level-sdk-v2.mdx
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, covered below.
Using SDK v2's high-level MCPServer class? See Upgrading from MCP SDK v2 — that migration is mostly renaming.
</Note>
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 itserver.run(read_stream, write_stream, server.create_initialization_options()) and its stdio_server() context managerserver.streamable_http_app() and any Starlette app assembled around itasyncio.run(main()) boilerplatelifespan= — 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_toolon_list_resources + on_list_resource_templates + on_read_resource → one @mcp.resource function per resource/templateon_list_prompts + on_get_prompt → one @mcp.prompt function per prompton_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 equivalenton_set_logging_level, on_progress, on_roots_list_changed, on_ping — flag for the user, these are protocol-level hooks with no direct FastMCP surfaceTYPES THAT DISAPPEAR FROM YOUR CODE
input_schema / output_schema JSON Schema dicts — these come from type hints nowtypes.ListToolsResult, types.CallToolResult, types.ListResourcesResult, types.ListResourceTemplatesResult, types.ReadResourceResult, types.ListPromptsResult, types.GetPromptResult — result wrappers FastMCP builds for youtypes.TextContent, types.TextResourceContents, types.BlobResourceContents — return plain Python values insteadtypes.ImageContent / types.AudioContent — fastmcp.utilities.types.Image / Audiotypes.Tool, types.Resource, types.ResourceTemplate, types.Prompt, types.PromptArgument — declaration types FastMCP derivestypes.PromptMessage — fastmcp.prompts.Messagemcp_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 parameterctx.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 existctx.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.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-coupledERRORS AND AUTH
raise ValueError(f"Unknown tool: ...") dispatch fallbacks — these become unnecessaryMCPError construction and any error-code mappingauth=AuthSettings(...), token_verifier=, auth_server_provider= — one auth= provider in FastMCPTransportSecuritySettingsFor 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>
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:
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 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.
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.
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())
from fastmcp import FastMCP
mcp = FastMCP("my-server")
# ... register tools, resources, prompts ...
if __name__ == "__main__":
mcp.run()
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:
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.
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>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)
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
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.
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.
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:
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.
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:
# 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 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>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,
)
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}"})
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 if a template legitimately accepts those values.
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.
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)
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}"
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:
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"),
]
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.
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)
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"
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, elicitation, and component visibility — see 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.
Everything above, applied at once:
<CodeGroup>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,
)
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()
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 mounts one server inside another, so a surface that grew unwieldy as a single dispatch chain splits into modules 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 — the cross-cutting concerns that, on the low-level class, meant threading the same code through every handler. 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, and the OpenAPI integration generates an entire server from an API specification you already have. 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. 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.