This guide covers the breaking changes introduced in v2 of the MCP Python SDK and how to update your code.
Version 2 of the MCP Python SDK introduces several breaking changes to improve the API, align with the MCP specification, and provide better type safety.
The deprecated streamablehttp_client function has been removed. Use streamable_http_client instead.
Before (v1):
from mcp.client.streamable_http import streamablehttp_client
async with streamablehttp_client(
url="http://localhost:8000/mcp",
headers={"Authorization": "Bearer token"},
timeout=30,
sse_read_timeout=300,
auth=my_auth,
) as (read_stream, write_stream, get_session_id):
...After (v2):
import httpx
from mcp.client.streamable_http import streamable_http_client
# Configure headers, timeout, and auth on the httpx.AsyncClient
http_client = httpx.AsyncClient(
headers={"Authorization": "Bearer token"},
timeout=httpx.Timeout(30, read=300),
auth=my_auth,
)
async with http_client:
async with streamable_http_client(
url="http://localhost:8000/mcp",
http_client=http_client,
) as (read_stream, write_stream):
...The get_session_id callback (third element of the returned tuple) has been removed from streamable_http_client. The function now returns a 2-tuple (read_stream, write_stream) instead of a 3-tuple.
If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers:
Before (v1):
from mcp.client.streamable_http import streamable_http_client
async with streamable_http_client(url) as (read_stream, write_stream, get_session_id):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
session_id = get_session_id() # Get session ID via callbackAfter (v2):
import httpx
from mcp.client.streamable_http import streamable_http_client
# Option 1: Simply ignore if you don't need the session ID
async with streamable_http_client(url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# Option 2: Capture session ID via httpx event hooks if needed
captured_session_ids: list[str] = []
async def capture_session_id(response: httpx.Response) -> None:
session_id = response.headers.get("mcp-session-id")
if session_id:
captured_session_ids.append(session_id)
http_client = httpx.AsyncClient(
event_hooks={"response": [capture_session_id]},
follow_redirects=True,
)
async with http_client:
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
session_id = captured_session_ids[0] if captured_session_ids else NoneThe headers, timeout, sse_read_timeout, and auth parameters have been removed from StreamableHTTPTransport. Configure these on the httpx.AsyncClient instead (see example above).
The following deprecated type aliases and classes have been removed from mcp.types:
| Removed | Replacement |
|---|---|
Content |
ContentBlock |
ResourceReference |
ResourceTemplateReference |
Cursor |
Use str directly |
MethodT |
Internal TypeVar, not intended for public use |
RequestParamsT |
Internal TypeVar, not intended for public use |
NotificationParamsT |
Internal TypeVar, not intended for public use |
Before (v1):
from mcp.types import Content, ResourceReference, CursorAfter (v2):
from mcp.types import ContentBlock, ResourceTemplateReference
# Use `str` instead of `Cursor` for pagination cursorsThe deprecated args parameter has been removed from ClientSessionGroup.call_tool(). Use arguments instead.
Before (v1):
result = await session_group.call_tool("my_tool", args={"key": "value"})After (v2):
result = await session_group.call_tool("my_tool", arguments={"key": "value"})The deprecated cursor parameter has been removed from the following ClientSession methods:
list_resources()list_resource_templates()list_prompts()list_tools()
Use params=PaginatedRequestParams(cursor=...) instead.
Before (v1):
result = await session.list_resources(cursor="next_page_token")
result = await session.list_tools(cursor="next_page_token")After (v2):
from mcp.types import PaginatedRequestParams
result = await session.list_resources(params=PaginatedRequestParams(cursor="next_page_token"))
result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token"))The McpError exception class has been renamed to MCPError for consistent naming with the MCP acronym style used throughout the SDK.
Before (v1):
from mcp.shared.exceptions import McpError
try:
result = await session.call_tool("my_tool")
except McpError as e:
print(f"Error: {e.error.message}")After (v2):
from mcp.shared.exceptions import MCPError
try:
result = await session.call_tool("my_tool")
except MCPError as e:
print(f"Error: {e.message}")MCPError is also exported from the top-level mcp package:
from mcp import MCPErrorThe FastMCP class has been renamed to MCPServer to better reflect its role as the main server class in the SDK. This is a simple rename with no functional changes to the class itself.
Before (v1):
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Demo")After (v2):
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("Demo")The mount_path parameter has been removed from MCPServer.__init__(), MCPServer.run(), MCPServer.run_sse_async(), and MCPServer.sse_app(). It was also removed from the Settings class.
This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard root_path mechanism. When using Starlette's Mount("/path", app=mcp.sse_app()), Starlette automatically sets root_path in the ASGI scope, and the SseServerTransport uses this to construct the correct message endpoint path.
Transport-specific parameters have been moved from the MCPServer constructor to the run(), sse_app(), and streamable_http_app() methods. This provides better separation of concerns - the constructor now only handles server identity and authentication, while transport configuration is passed when starting the server.
Parameters moved:
host,port- HTTP server bindingsse_path,message_path- SSE transport pathsstreamable_http_path- StreamableHTTP endpoint pathjson_response,stateless_http- StreamableHTTP behaviorevent_store,retry_interval- StreamableHTTP event handlingtransport_security- DNS rebinding protection
Before (v1):
from mcp.server.fastmcp import FastMCP
# Transport params in constructor
mcp = FastMCP("Demo", json_response=True, stateless_http=True)
mcp.run(transport="streamable-http")
# Or for SSE
mcp = FastMCP("Server", host="0.0.0.0", port=9000, sse_path="/events")
mcp.run(transport="sse")After (v2):
from mcp.server.mcpserver import MCPServer
# Transport params passed to run()
mcp = MCPServer("Demo")
mcp.run(transport="streamable-http", json_response=True, stateless_http=True)
# Or for SSE
mcp = MCPServer("Server")
mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events")For mounted apps:
When mounting in a Starlette app, pass transport params to the app methods:
# Before (v1)
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("App", json_response=True)
app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())])
# After (v2)
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("App")
app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))])Note: DNS rebinding protection is automatically enabled when host is 127.0.0.1, localhost, or ::1. This now happens in sse_app() and streamable_http_app() instead of the constructor.
The following union types are no longer RootModel subclasses:
ClientRequestServerRequestClientNotificationServerNotificationClientResultServerResultJSONRPCMessage
This means you can no longer access .root on these types or use model_validate() directly on them. Instead, use the provided TypeAdapter instances for validation.
Before (v1):
from mcp.types import ClientRequest, ServerNotification
# Using RootModel.model_validate()
request = ClientRequest.model_validate(data)
actual_request = request.root # Accessing the wrapped value
notification = ServerNotification.model_validate(data)
actual_notification = notification.rootAfter (v2):
from mcp.types import client_request_adapter, server_notification_adapter
# Using TypeAdapter.validate_python()
request = client_request_adapter.validate_python(data)
# No .root access needed - request is the actual type
notification = server_notification_adapter.validate_python(data)
# No .root access needed - notification is the actual typeAvailable adapters:
| Union Type | Adapter |
|---|---|
ClientRequest |
client_request_adapter |
ServerRequest |
server_request_adapter |
ClientNotification |
client_notification_adapter |
ServerNotification |
server_notification_adapter |
ClientResult |
client_result_adapter |
ServerResult |
server_result_adapter |
JSONRPCMessage |
jsonrpc_message_adapter |
All adapters are exported from mcp.types.
The nested RequestParams.Meta Pydantic model class has been replaced with a top-level RequestParamsMeta TypedDict. This affects the ctx.meta field in request handlers and any code that imports or references this type.
Key changes:
RequestParams.Meta(Pydantic model) →RequestParamsMeta(TypedDict)- Attribute access (
meta.progress_token) → Dictionary access (meta.get("progress_token")) progress_tokenfield changed fromProgressToken | None = NonetoNotRequired[ProgressToken]`
In request context handlers:
# Before (v1)
@server.call_tool()
async def handle_tool(name: str, arguments: dict) -> list[TextContent]:
ctx = server.request_context
if ctx.meta and ctx.meta.progress_token:
await ctx.session.send_progress_notification(ctx.meta.progress_token, 0.5, 100)
# After (v2)
@server.call_tool()
async def handle_tool(name: str, arguments: dict) -> list[TextContent]:
ctx = server.request_context
if ctx.meta and "progress_token" in ctx.meta:
await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100)The uri field on resource-related types now uses str instead of Pydantic's AnyUrl. This aligns with the MCP specification schema which defines URIs as plain strings (uri: string) without strict URL validation. This change allows relative paths like users/me that were previously rejected.
Before (v1):
from pydantic import AnyUrl
from mcp.types import Resource
# Required wrapping in AnyUrl
resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validationAfter (v2):
from mcp.types import Resource
# Plain strings accepted
resource = Resource(name="test", uri="users/me") # Works
resource = Resource(name="test", uri="custom://scheme") # Works
resource = Resource(name="test", uri="https://example.com") # WorksIf your code passes AnyUrl objects to URI fields, convert them to strings:
# If you have an AnyUrl from elsewhere
uri = str(my_any_url) # Convert to stringAffected types:
Resource.uriReadResourceRequestParams.uriResourceContents.uri(and subclassesTextResourceContents,BlobResourceContents)SubscribeRequestParams.uriUnsubscribeRequestParams.uriResourceUpdatedNotificationParams.uri
The Client and ClientSession methods read_resource(), subscribe_resource(), and unsubscribe_resource() now only accept str for the uri parameter. If you were passing AnyUrl objects, convert them to strings:
# Before (v1)
from pydantic import AnyUrl
await client.read_resource(AnyUrl("test://resource"))
# After (v2)
await client.read_resource("test://resource")
# Or if you have an AnyUrl from elsewhere:
await client.read_resource(str(my_any_url))The decorator-based API for registering handlers on the low-level Server class has been removed. Use the constructor-based handler registration instead, which provides better type safety and clearer dependencies.
Before (v1):
from mcp.server.lowlevel import Server
server = Server("my-server")
@server.list_tools()
async def list_tools():
return [types.Tool(name="tool", description="...")]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
return {"result": "..."}After (v2):
from mcp.server.lowlevel import Server
from mcp.server.session import ServerSession
from mcp.shared.context import RequestContext
import mcp.types as types
from typing import Any
async def list_tools(
ctx: RequestContext[ServerSession, Any, Any],
params: types.PaginatedRequestParams | None,
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[
types.Tool(name="tool", description="...", input_schema={"type": "object"})
])
async def call_tool(
ctx: RequestContext[ServerSession, Any, Any],
params: types.CallToolRequestParams,
) -> types.CallToolResult:
return types.CallToolResult(
content=[types.TextContent(type="text", text="result")]
)
server = Server(
"my-server",
on_list_tools=list_tools,
on_call_tool=call_tool,
)Key differences:
- Handlers receive
(context, params)instead of extracted arguments - Handlers return proper result types (
ListToolsResult,CallToolResult, etc.) - Context provides access to session, lifespan data, and request metadata
- Handlers are passed to the constructor using
on_*parameters
Migration steps:
- Update handler signatures to accept
(ctx, params) - Update return types to use proper result classes
- Pass handlers to the Server constructor using
on_*parameters - Remove decorator calls
Available constructor parameters:
| Constructor Parameter | Params Type | Return Type |
|---|---|---|
on_list_prompts |
PaginatedRequestParams | None |
ListPromptsResult |
on_get_prompt |
GetPromptRequestParams |
GetPromptResult |
on_list_resources |
PaginatedRequestParams | None |
ListResourcesResult |
on_list_resource_templates |
PaginatedRequestParams | None |
ListResourceTemplatesResult |
on_read_resource |
ReadResourceRequestParams |
ReadResourceResult |
on_subscribe_resource |
SubscribeRequestParams |
EmptyResult |
on_unsubscribe_resource |
UnsubscribeRequestParams |
EmptyResult |
on_list_tools |
PaginatedRequestParams | None |
ListToolsResult |
on_call_tool |
CallToolRequestParams |
CallToolResult |
on_set_logging_level |
SetLevelRequestParams |
EmptyResult |
on_completion |
CompleteRequestParams |
CompleteResult |
on_progress_notification |
ProgressNotificationParams |
None |
Benefits:
- Context available in all handlers (session, lifespan data, request metadata)
- Type-safe params and return types
- Clearer dependencies at construction time
- Better testability (handlers can be mocked/replaced)
MCP protocol types no longer accept arbitrary extra fields at the top level. This matches the MCP specification which only allows extra fields within _meta objects, not on the types themselves.
# This will now raise a validation error
from mcp.types import CallToolRequestParams
params = CallToolRequestParams(
name="my_tool",
arguments={},
unknown_field="value", # ValidationError: extra fields not permitted
)
# Extra fields are still allowed in _meta
params = CallToolRequestParams(
name="my_tool",
arguments={},
_meta={"progressToken": "tok", "customField": "value"}, # OK
)The streamable_http_app() method is now available directly on the lowlevel Server class, not just MCPServer. This allows using the streamable HTTP transport without the MCPServer wrapper.
from mcp.server.lowlevel.server import Server
from mcp.server.session import ServerSession
from mcp.shared.context import RequestContext
import mcp.types as types
from typing import Any
async def list_tools(
ctx: RequestContext[ServerSession, Any, Any],
params: types.PaginatedRequestParams | None,
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[
types.Tool(name="my_tool", description="...", inputSchema={"type": "object"})
])
server = Server("my-server", on_list_tools=list_tools)
# Create a Starlette app for streamable HTTP
app = server.streamable_http_app(
streamable_http_path="/mcp",
json_response=False,
stateless_http=False,
)The lowlevel Server also now exposes a session_manager property to access the StreamableHTTPSessionManager after calling streamable_http_app().
If you encounter issues during migration:
- Check the API Reference for updated method signatures
- Review the examples for updated usage patterns
- Open an issue on GitHub if you find a bug or need further assistance