Saltar a contenido

Migration Guide: v1 to v2

Se muestra en inglés

No hay una traducción vigente de esta página, así que la estás leyendo en inglés. Traducciones explica cómo funciona la documentación traducida.

This guide covers the breaking changes in v2 of the MCP Python SDK and how to update code written against v1.x.

Not ready to migrate yet?

The v1.x maintenance line keeps receiving critical bug fixes and security patches, and its documentation is at /v1/. If your package depends on mcp, keep a <2 upper bound until you've migrated.

Find your changes

Every section heading names the API it affects, so searching this page for the symbol your code uses is the fastest route to the change that broke it. Only breaking and behavior changes are listed.

Changes almost every project hits

Change First symptom Section
FastMCP renamed to MCPServer ModuleNotFoundError: No module named 'mcp.server.fastmcp' FastMCP renamed
Fields renamed from camelCase to snake_case AttributeError: 'Tool' object has no attribute 'inputSchema' snake_case fields
mcp.types names removed ImportError: cannot import name 'Content' from 'mcp.types' Removed types
McpError renamed to MCPError ImportError: cannot import name 'McpError' from 'mcp' McpError renamed
Resource URIs are str, not AnyUrl AttributeError: 'str' object has no attribute 'host' URI type
Message unions (ServerNotification, JSONRPCMessage, ...) are plain unions, not RootModel AttributeError: 'LoggingMessageNotification' object has no attribute 'root' RootModel → unions
streamablehttp_client removed ImportError: cannot import name 'streamablehttp_client' streamablehttp_client
httpx and httpx-sse replaced by httpx2 ModuleNotFoundError: No module named 'httpx', or TypeError: Invalid "auth" argument from httpx.AsyncClient(auth=provider) httpx2 swap
Transport parameters moved off the MCPServer constructor TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' constructor parameters
Sync handlers run on a worker thread asyncio.get_running_loop() in a def handler raises RuntimeError worker threads
Tool exception messages no longer reach the model tool error text is only Error executing tool <name>, without the exception message exception messages
Lowlevel decorators replaced with on_* constructor params AttributeError: 'Server' object has no attribute 'list_tools' on_* handlers
Lowlevel return value wrapping removed bare list or dict returns fail result validation instead of being wrapped wrapping removed
Lowlevel tool exceptions no longer become isError: true results clients raise a JSON-RPC error instead of seeing the error text tool exceptions
In-process test client negotiates 2026-07-28 sampling/elicitation tests raise NoBackChannelError; logging callbacks go quiet default connection
Deprecated v1 calls emit MCPDeprecationWarning warnings-as-errors test runs fail on unchanged code deprecation warnings

Find your area

If you... Read
pin or cap dependencies, or use httpx directly Packaging and dependencies
import mcp.types or touch protocol types (everyone does) Types and wire format
run FastMCP/MCPServer servers MCPServer (formerly FastMCP)
use the lowlevel Server Lowlevel Server, plus Timeouts take float seconds and Experimental Tasks support removed under Clients
write client code with Client or ClientSession Clients, plus streamablehttp_client removed under Transports
use stdio or streamable HTTP directly, or maintain a custom transport Transports
maintain OAuth client auth or a protected server OAuth and server auth
rely on lenient handling of off-schema traffic, or assert on exact wire bytes Stricter protocol validation and wire behavior
test against in-memory server/client pairs Testing utilities
use sampling, elicitation, roots, logging, or change notifications from handlers Behavior changes on v2's default connection

Suggested migration order

  1. Update your dependency pins: Packaging and dependencies.
  2. Apply the mechanical renames and import moves: Types and wire format.
  3. Port your server surface: MCPServer (formerly FastMCP) or Lowlevel Server.
  4. Port your client code: Clients.
  5. Update transport setup and auth: Transports and OAuth and server auth.
  6. Run your tests and check anything that now errors against Stricter protocol validation and wire behavior, Testing utilities, and Behavior changes on v2's default connection.

Packaging and dependencies

Dependency requirements changed

A pin or cap in your project that conflicts with a new floor makes dependency resolution fail (uv reports "No solution found when resolving dependencies"), and code that imported a dropped package without declaring it must now depend on it directly:

Dependency v1.x v2 Change
anyio >=4.5 >=4.9 (>=4.10 on Python 3.14+) floor raised
pydantic >=2.11,<3 (>=2.12,<3 on Python 3.14+) >=2.12 floor raised on Python <3.14; <3 cap dropped
sse-starlette >=1.6.1 >=3.0.0 floor raised to 3.x
typing-extensions >=4.9.0 >=4.13.0 floor raised
pywin32 (Windows) >=310 (>=311 on Python 3.14+) >=311 floor raised on Python <3.14
httpx >=0.27.1,<1.0.0 removed see httpx and httpx-sse replaced by httpx2
httpx-sse >=0.4 removed see above
pydantic-settings >=2.5.2 removed no longer installed with mcp; declare it yourself if you import pydantic_settings
websockets (ws extra) >=15.0.1 removed see WebSocket transport removed
httpx2 not a dependency >=2.5.0 new required dependency
mcp-types not a dependency pinned to the exact mcp version new required dependency; do not pin it separately from mcp
opentelemetry-api not a dependency >=1.28.0 new required dependency; tracing is a no-op until you install an OpenTelemetry SDK (OpenTelemetry)

Relax any conflicting pin. sse-starlette moves to 3.x, so code that imports sse_starlette directly must also port to its 3.x API.

httpx and httpx-sse replaced by httpx2

The SDK depends on httpx2 (a fork of httpx with server-sent events built in) instead of httpx and httpx-sse, and installs neither. Parameter lists are unchanged; every httpx type in the client API is now its httpx2 equivalent: http_client= on streamable_http_client is an httpx2.AsyncClient (as is whatever your sse_client(httpx_client_factory=...) returns), and auth= on sse_client is an httpx2.Auth, so a custom httpx.Auth subclass re-bases onto httpx2.Auth. If you built the client only to get default behavior, Client("http://.../mcp") needs no HTTP client at all; see Client transports.

Before (v1):

import httpx

http_client = httpx.AsyncClient(headers={"Authorization": "Bearer <token>"})
try:
    ...
except httpx.ConnectError:
    ...

After (v2):

import httpx2

http_client = httpx2.AsyncClient(headers={"Authorization": "Bearer <token>"})
try:
    ...
except httpx2.ConnectError:
    ...

Direct httpx-sse usage becomes httpx2.EventSource or AsyncClient.sse(). Code that imported httpx or httpx_sse only because v1 pulled them in now fails with ModuleNotFoundError: No module named 'httpx' (or 'httpx_sse'): declare httpx in your own dependencies (the two packages install side by side) or port those calls.

The transports raise httpx2 exceptions (httpx2.ConnectError, httpx2.HTTPStatusError, and so on), so an old except httpx.ConnectError: clause silently never matches if httpx is still installed; audit except clauses, isinstance checks, and test fixtures (pytest.raises(httpx.ConnectError), httpx.MockTransport, test-only httpx.Auth subclasses) along with the imports.

The objects are not interchangeable either: an httpx.AsyncClient passed as http_client= still connects and calls tools, but its standalone GET stream never opens, so messages the server sends outside an in-flight request (a list-changed notification, say) silently never arrive.

The SDK's own auth providers made the same move: OAuthClientProvider, ClientCredentialsOAuthProvider, and PrivateKeyJWTOAuthProvider subclass httpx2.Auth (v1: httpx.Auth), so the client you attach one to must be an httpx2.AsyncClient; httpx.AsyncClient(auth=provider) fails at construction with TypeError: Invalid "auth" argument. See OAuth clients.

Log filters and instrumentation keyed to the httpx/httpcore names no longer see the SDK's traffic: the loggers are httpx2 and httpcore2.* (target logging.getLogger("httpx2") and logging.getLogger("httpcore2")), the default User-Agent is python-httpx2/<version>, and OpenTelemetry's httpx instrumentation does not hook httpx2.

TLS verification changes too: httpx validated certificates against the bundled certifi CA list, while httpx2 validates against the operating-system trust store via truststore. With no usable system CA store (some minimal containers), point SSL_CERT_FILE or SSL_CERT_DIR at a CA bundle (checked before the system store) or pass an explicit verify=ssl_context to your httpx2.AsyncClient.

Types and wire format

mcp.types moved to the mcp-types package

The protocol wire types now ship in a separate distribution, mcp-types (import package mcp_types, API reference); mcp depends on the identically-versioned mcp-types, so it installs with mcp. mcp.types is a permanent alias of mcp_types (every name is the same object), so import mcp.types as types, from mcp.types import ..., from mcp import types, and the top-level re-exports (from mcp import Tool) all still work. Keep importing through mcp; import mcp_types directly only in a project that depends on mcp-types without the SDK.

mcp.shared.version is gone: its constants now live in mcp.types.version and changed meaning as well; see LATEST_PROTOCOL_VERSION and SUPPORTED_PROTOCOL_VERSIONS changed.

Removed type aliases and classes

These mcp.types names are gone:

Removed Replacement
Content ContentBlock
ResourceReference ResourceTemplateReference
Cursor str
AnyFunction Callable[..., Any]
MethodT, RequestParamsT, NotificationParamsT internal TypeVars; no longer exported
ClientRequestType, ClientNotificationType, ClientResultType, ServerRequestType, ServerNotificationType, ServerResultType the union now has the bare name (ClientRequest, ClientNotification, ClientResult, ServerRequest, ServerNotification, ServerResult); see Replace RootModel by union types with TypeAdapter validation
TaskExecutionMode, TASK_FORBIDDEN, TASK_OPTIONAL, TASK_REQUIRED, TASK_STATUS_* string literals; TaskStatus remains as the literal-union type; see Experimental Tasks support removed

Before (v1):

from mcp.types import Content, Cursor, ResourceReference

def render(block: Content, cursor: Cursor | None = None) -> str: ...

ref = ResourceReference(type="ref/resource", uri="file:///{name}")

After (v2):

from mcp.types import ContentBlock, ResourceTemplateReference

def render(block: ContentBlock, cursor: str | None = None) -> str: ...

ref = ResourceTemplateReference(type="ref/resource", uri="file:///{name}")

Field names changed from camelCase to snake_case

Every field on the protocol type models (mcp.types) is now snake_case in Python. The JSON wire format is unchanged: the SDK still sends camelCase through Pydantic aliases.

Before (v1):

result = await session.call_tool("my_tool", {"x": 1})
if result.isError:
    ...

tools = await session.list_tools()
cursor = tools.nextCursor
schema = tools.tools[0].inputSchema

After (v2):

result = await session.call_tool("my_tool", {"x": 1})
if result.is_error:
    ...

tools = await session.list_tools()
cursor = tools.next_cursor
schema = tools.tools[0].input_schema

Common renames:

v1 (camelCase) v2 (snake_case)
inputSchema input_schema
outputSchema output_schema
isError is_error
nextCursor next_cursor
mimeType mime_type
structuredContent structured_content
serverInfo server_info
protocolVersion protocol_version
uriTemplate uri_template
listChanged list_changed
progressToken progress_token

Rename attribute access and constructor kwargs alike: tool.input_schema, Tool(input_schema={...}). A leftover camelCase kwarg such as Tool(inputSchema={...}) still runs but fails type checking. Parsing is unaffected: model_validate() accepts camelCase wire JSON and snake_case dumps alike.

If you serialize models yourself, pass by_alias=True. model_dump() and model_dump_json() now silently emit snake_case keys instead of the camelCase wire format:

tool.model_dump()                                # {"name": ..., "input_schema": ...}
tool.model_dump(by_alias=True, mode="json")      # {"name": ..., "inputSchema": ...}  (wire format)

Extra fields on MCP types are no longer preserved

v1 protocol models set extra="allow", so unknown fields passed to a constructor or received from a peer were stored and re-serialized. v2 models silently drop them during validation: the data no longer round-trips, model_extra is None, and reading a dropped field as an attribute (result.customField) raises AttributeError. Carry custom data in _meta instead.

Before (v1):

from mcp.types import CallToolResult

result = CallToolResult.model_validate({"content": [], "customField": "x"})
result.model_dump()["customField"]  # 'x'

After (v2):

from mcp.types import CallToolResult

result = CallToolResult.model_validate({"content": [], "customField": "x"})
"customField" in result.model_dump()  # False

result = CallToolResult(content=[], _meta={"myapp/custom": "x"})
result.meta  # {'myapp/custom': 'x'}

Resource URI type changed from AnyUrl to str

The uri field on resource types and the uri parameter of the client's read_resource(), subscribe_resource(), and unsubscribe_resource() are now plain str. Passing an AnyUrl object raises ValidationError ("Input should be a valid string"); wrap it in str(...). URIs you read back are strings too: attribute access like uri.scheme or uri.host raises AttributeError (parse with urllib.parse), and a comparison such as uri == AnyUrl(...) is now always False, so compare against strings.

Before (v1):

from pydantic import AnyUrl
from mcp.types import Resource

resource = Resource(name="test", uri=AnyUrl("resource://items/1"))
scheme = resource.uri.scheme
result = await session.read_resource(AnyUrl("resource://items/1"))

After (v2):

from urllib.parse import urlparse

from mcp.types import Resource

resource = Resource(name="test", uri="resource://items/1")
scheme = urlparse(resource.uri).scheme
result = await session.read_resource("resource://items/1")

Affected uri fields: Resource (and ResourceLink), ResourceContents (TextResourceContents, BlobResourceContents), ReadResourceRequestParams, SubscribeRequestParams, UnsubscribeRequestParams, ResourceUpdatedNotificationParams, and the MCPServer resource classes (TextResource, FileResource, ...).

The string is no longer validated or normalized: relative URIs such as users/me, which v1 rejected, are accepted, and https://example.com is sent as given rather than as https://example.com/.

Replace RootModel by union types with TypeAdapter validation

ClientRequest, ServerRequest, ClientNotification, ServerNotification, ClientResult, ServerResult, and JSONRPCMessage are now plain X | Y unions of the concrete pydantic classes, not RootModel subclasses, so .root, model_validate(), and the wrapper call are gone. Validate raw data with the matching TypeAdapter:

Before (v1):

from mcp.types import ClientRequest, ServerNotification

request = ClientRequest.model_validate(data)
actual_request = request.root

notification = ServerNotification.model_validate(data)
actual_notification = notification.root

After (v2):

from mcp.types import client_request_adapter, server_notification_adapter

request = client_request_adapter.validate_python(data)  # already the concrete type
notification = server_notification_adapter.validate_python(data)
Union Adapter (exported from mcp.types)
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

Constructing a value drops the wrapper only; pass the member (and its params) directly:

Before (v1):

from mcp.types import (
    ClientNotification,
    ClientRequest,
    InitializedNotification,
    ListToolsRequest,
    ListToolsResult,
)

await session.send_notification(ClientNotification(InitializedNotification()))
result = await session.send_request(ClientRequest(ListToolsRequest()), ListToolsResult)

After (v2):

from mcp.types import (
    CancelledNotification,
    CancelledNotificationParams,
    InitializedNotification,
    ListToolsRequest,
    ListToolsResult,
)

await session.send_notification(InitializedNotification())
result = await session.send_request(ListToolsRequest(), ListToolsResult)
await session.send_notification(
    CancelledNotification(params=CancelledNotificationParams(request_id=request_id, reason="timeout"))
)

Values the SDK hands you are the member instances themselves. isinstance(msg, ServerNotification), isinstance(msg, LoggingMessageNotification), and match/case on the member classes keep working; .root accesses do not (AttributeError: 'LoggingMessageNotification' object has no attribute 'root'), so a message_handler becomes:

# Before (v1)
if isinstance(message, ServerNotification):
    if isinstance(message.root, LoggingMessageNotification):
        print(message.root.params.data)

# After (v2)
if isinstance(message, LoggingMessageNotification):
    print(message.params.data)

Custom transports and EventStore implementations follow the same rule: mcp.shared.message.SessionMessage takes the member directly (SessionMessage(JSONRPCNotification(...)), not SessionMessage(JSONRPCMessage(JSONRPCNotification(...)))), and raw JSON parses with jsonrpc_message_adapter.validate_json(raw) instead of JSONRPCMessage.model_validate_json(raw). A parsed JSONRPCError may now carry id=None (JSON-RPC 2.0 allows it; v1 rejected such messages), so JSONRPCError.id is typed RequestId | None and code that read it as a definite str | int needs a None check.

RequestParams.Meta replaced with RequestParamsMeta TypedDict

The nested RequestParams.Meta Pydantic model is now the RequestParamsMeta TypedDict (from mcp.types import RequestParamsMeta), and NotificationParams.Meta is gone: notification _meta is a plain dict[str, Any]. ctx.meta (lowlevel handlers, client callbacks) and ctx.request_context.meta (MCPServer handlers) are dicts, so attribute access becomes key access, progressToken becomes the optional progress_token key, and _meta you built with types.RequestParams.Meta(...) or types.NotificationParams.Meta(...) is passed as a plain dict (CallToolRequestParams(name="x", _meta={"traceparent": ...})). The wire format is unchanged.

If you read progressToken only to send progress, call report_progress() instead: it is a no-op when the caller did not ask for progress, and it also delivers on the default in-process Client(server) connection, where ctx.meta carries no progress_token even when the caller passed progress_callback (see Progress).

Before (v1):

ctx = server.request_context
if ctx.meta and ctx.meta.progressToken:
    await ctx.session.send_progress_notification(ctx.meta.progressToken, 0.5, 100)

After (v2):

await ctx.session.report_progress(0.5, 100)  # ctx: ServerRequestContext

For other _meta keys use dictionary access; meta is still None when the request carries no _meta:

Before (v1):

trace = ctx.meta.traceparent  # extra keys were attributes

After (v2):

trace = (ctx.meta or {}).get("traceparent")

LATEST_PROTOCOL_VERSION and SUPPORTED_PROTOCOL_VERSIONS changed

mcp.shared.version is gone (ModuleNotFoundError); its constants live in mcp.types.version and changed under the same names:

  • LATEST_PROTOCOL_VERSION was "2025-11-25", the version the v1 client offered in initialize. In v2 it is "2026-07-28", the newest revision the SDK speaks, which the initialize handshake never negotiates (see Protocol versions); from mcp.types import LATEST_PROTOCOL_VERSION still imports, so the value changes silently. Where your code used it as the handshake version (a hand-built initialize, or a comparison against the negotiated version), use LATEST_HANDSHAKE_VERSION ("2025-11-25").
  • SUPPORTED_PROTOCOL_VERSIONS is now a tuple (was a list) with "2026-07-28" appended: membership tests still pass, but [-1] is "2026-07-28" and list operations such as + [...] raise TypeError. The handshake-only set, with v1's four values, is HANDSHAKE_PROTOCOL_VERSIONS.

Before (v1):

from mcp.shared.version import LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS

After (v2):

from mcp.types.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION

# LATEST_HANDSHAKE_VERSION == "2025-11-25", the value v1 called LATEST_PROTOCOL_VERSION

McpError renamed to MCPError

Hard rename, no compatibility alias; import from mcp or mcp.shared.exceptions as before. e.error still holds the ErrorData, so e.error.code/e.error.message keep working; e.code, e.message, and e.data are new shortcuts.

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 import MCPError

try:
    result = await session.call_tool("my_tool")
except MCPError as e:
    print(f"Error: {e.message}")

The constructor now takes code, message, and optional data instead of an ErrorData (the v1 form raises TypeError). If you already hold an ErrorData, use MCPError.from_error_data(error).

Before (v1):

from mcp.shared.exceptions import McpError
from mcp.types import INVALID_REQUEST, ErrorData

raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input"))

After (v2):

from mcp import MCPError
from mcp.types import INVALID_REQUEST

raise MCPError(INVALID_REQUEST, "bad input")

MCPServer (formerly FastMCP)

FastMCP renamed to MCPServer

FastMCP is now MCPServer, and the mcp.server.fastmcp package is now mcp.server.mcpserver; submodule paths below it are unchanged. The old path is removed, not aliased: importing mcp.server.fastmcp or anything below it raises ModuleNotFoundError, so an except ImportError fallback in code that supports both majors keeps working.

Before (v1):

from mcp.server.fastmcp import Context, FastMCP

mcp = FastMCP("Demo")

After (v2):

from mcp.server.mcpserver import Context, MCPServer

mcp = MCPServer("Demo")

MCPServer is also exported from mcp.server, as FastMCP was. Other imports move the same way:

  • Image, Audio, Icon from mcp.server.mcpserver (Icon's mimeType field is now mime_type; see snake_case renames)
  • Message, UserMessage, AssistantMessage from mcp.server.mcpserver.prompts.base (now also re-exported from mcp.server.mcpserver)
  • ToolError, ResourceError, MCPServerError (was FastMCPError) from mcp.server.mcpserver.exceptions; ValidationError is removed (the SDK never raised it)

The ctx.fastmcp property is now ctx.mcp_server. Beyond the changes in this group, the @mcp.tool()/@mcp.resource()/@mcp.prompt() decorators, the lifespan= hook, and mcp.run() port with only the import change.

Default server name and version changed

A nameless FastMCP() reported serverInfo.name == "FastMCP"; a nameless MCPServer() reports "mcp-server". Without version= (v1's FastMCP had no such parameter), serverInfo.version was the installed mcp package version; v2 reports an empty string. Nothing raises, but clients keying off serverInfo and tests asserting on the initialize result see the new values, so set both explicitly:

Before (v1):

from mcp.server.fastmcp import FastMCP

mcp = FastMCP()  # serverInfo.name == "FastMCP", version == installed mcp version

After (v2):

from mcp.server import MCPServer

mcp = MCPServer("my-server", version="1.2.3")

The lowlevel Server takes the same fix: Server("my-server", version="1.2.3").

MCPServer constructor: positional parameter order changed

The positional order is now name, title, description, instructions, website_url, icons, version (v1: name, instructions, website_url, icons). A v1 call that passed instructions as the second positional argument still runs on v2, but the string lands in title: the server reports it as serverInfo.title and clients receive no instructions.

Before (v1):

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Demo", "You answer questions about the weather.")

After (v2):

from mcp.server import MCPServer

mcp = MCPServer("Demo", instructions="You answer questions about the weather.")

Keep name positional and pass everything else by keyword.

mount_path parameter removed from MCPServer

mount_path is gone from the constructor, Settings, run(), run_sse_async(), and sse_app(): passing it raises TypeError, and mcp.settings.mount_path = ... raises ValueError. The SSE transport prefixes its advertised message endpoint with the ASGI root_path that Starlette's Mount sets, so delete mount_path and mount as before.

Before (v1):

from starlette.applications import Starlette
from starlette.routing import Mount

from mcp.server.fastmcp import FastMCP

github_mcp = FastMCP("GitHub", mount_path="/github")
search_mcp = FastMCP("Search")
search_mcp.settings.mount_path = "/search"
curl_mcp = FastMCP("Curl")

app = Starlette(
    routes=[
        Mount("/github", app=github_mcp.sse_app()),
        Mount("/search", app=search_mcp.sse_app()),
        Mount("/curl", app=curl_mcp.sse_app("/curl")),
    ]
)

After (v2):

from starlette.applications import Starlette
from starlette.routing import Mount

from mcp.server import MCPServer

github_mcp = MCPServer("GitHub")
search_mcp = MCPServer("Search")
curl_mcp = MCPServer("Curl")

app = Starlette(
    routes=[
        Mount("/github", app=github_mcp.sse_app()),
        Mount("/search", app=search_mcp.sse_app()),
        Mount("/curl", app=curl_mcp.sse_app()),
    ]
)

mcp.run(transport="sse", mount_path="/x") becomes mcp.run(transport="sse"); if a reverse proxy strips /x, serve with uvicorn.run(mcp.sse_app(), root_path="/x") instead so the advertised message endpoint keeps the prefix. See Add to an existing app for mounting details.

Transport parameters moved off the MCPServer constructor

Transport options are no longer constructor arguments: MCPServer(..., json_response=True) raises TypeError: MCPServer.__init__() got an unexpected keyword argument 'json_response'. Pass them where the server starts instead: run(transport, ...), or streamable_http_app(...) / sse_app(...) when you build the ASGI app yourself (same options minus port, which belongs to whatever serves the app).

Moved: host, port, sse_path, message_path, streamable_http_path, json_response, stateless_http, max_request_body_size, session_idle_timeout, max_sessions, event_store, retry_interval, transport_security. mount_path is removed, not moved. Each transport takes only its own keywords (run() is overloaded per transport, so a type checker rejects e.g. run(transport="sse", json_response=True) and at runtime it raises TypeError); Running your server lists them. json_response=True and stateless_http=True also change how server-to-client requests fail, see Server-initiated sampling, elicitation, and roots raise NoBackChannelError.

Before (v1):

from mcp.server.fastmcp import FastMCP

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 import MCPServer

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 a mounted app the options go on the app method: FastMCP("App", json_response=True).streamable_http_app() becomes MCPServer("App").streamable_http_app(json_response=True) (Add to an existing app).

The moved fields are gone from mcp.settings too: v1's mcp.settings.port = 9000 or mcp.settings.streamable_http_path = "/" before run() / mounting now raises ValueError: "Settings" object has no field "port" (or "streamable_http_path"); write run(..., port=9000) or streamable_http_app(streamable_http_path="/") instead. If another module builds the app, pass the keywords there (e.g. functools.partial(mcp.streamable_http_app, json_response=True)).

DNS-rebinding protection is now armed from the host= these methods receive (default 127.0.0.1) rather than from the constructor. v1 code that passed host="0.0.0.0" or transport_security= to FastMCP(...) so a mounted or externally-served app accepts a real hostname must pass it at the new call site; otherwise non-localhost Host headers are rejected with 421 Invalid Host header. See Deploy & scale.

Streamable HTTP: lifespan now entered once at manager startup

Under streamable HTTP, v1 entered the server's lifespan for every session (stateful) or every request (stateless_http=True) and exited it when that session or request ended. In v2 StreamableHTTPSessionManager.run() enters it once at startup and exits it at shutdown; the object it yields is the same ctx.request_context.lifespan_context for every session and request (see Lifespan). SSE and stdio are unchanged.

State you scoped to a session or request by yielding it from the lifespan (a per-client cache, a per-connection transaction) is now shared between all clients and torn down only at server shutdown. There is no per-session teardown hook: keep only server-wide resources (connection pool, HTTP client, loaded model) in the lifespan, acquire and release per-request state inside the handler, and key anything that must persist across one client's stateful session on the mcp-session-id header (ctx.headers; see ServerSession is now a thin proxy).

MCPServer.get_context() removed

MCPServer.get_context() is gone; there is no ambient context to fetch. Declare a ctx: Context parameter on the handler and the SDK injects it. Nothing is injected into functions the handler calls, so helpers that used get_context() now take ctx as an argument. See The Context.

Before (v1):

@mcp.tool()
async def my_tool(x: int) -> str:
    ctx = mcp.get_context()
    await ctx.report_progress(1, 2)
    return str(x)

After (v2):

from mcp.server.mcpserver import Context

@mcp.tool()
async def my_tool(x: int, ctx: Context) -> str:
    await ctx.report_progress(1, 2)
    return str(x)

Sync handler functions now run on a worker thread

v1 called a synchronous (def) tool, resource, or prompt function directly on the event-loop thread. v2 runs it on a worker thread via anyio.to_thread.run_sync(); async def handlers are unchanged. A synchronous handler that depended on the event-loop thread now behaves differently:

  • asyncio.get_running_loop() in the body raises RuntimeError: no running event loop.
  • Thread-affine state (a sqlite3 connection created at import time, thread locals set at startup) is now used from another thread.
  • Concurrent requests to synchronous handlers run in parallel, so unlocked shared state races.

Declare the handler async def to keep it on the event-loop thread.

Before (v1):

import asyncio

@mcp.tool()
def now_ms() -> int:
    return int(asyncio.get_running_loop().time() * 1000)

After (v2):

import asyncio

@mcp.tool()
async def now_ms() -> int:
    return int(asyncio.get_running_loop().time() * 1000)

v1 also awaited a coroutine returned by a resource or prompt function; v2 only checks whether the function itself is async, so an async def resource or prompt wrapped by a synchronous decorator is no longer awaited and the client receives its <coroutine object ...> repr as the content. Make the wrapper async def as well.

Calling MCPServer.call_tool(), get_prompt(), and read_resource() directly

Direct calls to these methods (unit tests, wrapper servers, a handler that calls another) change in three ways.

Return types. call_tool() returns a CallToolResult instead of a bare list of content blocks or a (content, structured_content) tuple, and every declared return type is widened with InputRequiredResult (returned only when a multi-round-trip handler asks the client for input), so narrow with isinstance:

Before (v1):

# (content, structured_content) tuple for tools with an output schema,
# a bare list of content blocks otherwise
content, structured = await mcp.call_tool("greet", {"name": "Ada"})
text = content[0].text

After (v2):

from mcp.types import CallToolResult, TextContent

result = await mcp.call_tool("greet", {"name": "Ada"})
assert isinstance(result, CallToolResult)
block = result.content[0]
assert isinstance(block, TextContent)
text = block.text
structured = result.structured_content  # {"result": "Hello, Ada!"}

get_prompt() is likewise typed GetPromptResult | InputRequiredResult and read_resource() is Iterable[ReadResourceContents] | InputRequiredResult; handlers ported from v1 never produce InputRequiredResult, so only the narrowing is new (assert not isinstance(result, InputRequiredResult) suffices; Prompt.render() and ResourceTemplate.create_resource() widen too). In tests, prefer the in-memory Client(mcp), whose methods return plain CallToolResult/GetPromptResult/ReadResourceResult.

Exceptions. A tool that raises anything other than ToolError, ResourceError, or MCPError now surfaces from call_tool() as UnexpectedToolError, a ToolError subclass whose message is only Error executing tool <name> (v1 appended : <original message>); except ToolError still catches it, and the original exception is on __cause__. An MCPError raised by the tool now propagates unchanged instead of being wrapped in ToolError (see Handling errors). Direct read_resource() failures raise ResourceError subclasses instead of ValueError (see Reading a missing resource raises ResourceNotFoundError and returns invalid-params).

No ambient request context. v1 read the ambient request context, so mcp.call_tool() from inside a handler gave the inner handler a working ctx. In v2 each method takes an optional context: Context | None = None and builds a request-less Context when you omit it, so in the inner handler ctx.session, ctx.request_id, ctx.report_progress(), etc. raise ValueError("Context is not available outside of a request"). Pass the caller's ctx through:

Before (v1):

from mcp.server.fastmcp import Context, FastMCP

mcp = FastMCP("Demo")


@mcp.tool()
async def report(ctx: Context) -> str:
    return f"report for request {ctx.request_id}"


@mcp.tool()
async def digest(ctx: Context) -> str:
    result = await mcp.call_tool("report", {})
    return f"digest: {result}"

After (v2):

from mcp.server import MCPServer
from mcp.server.mcpserver import Context

mcp = MCPServer("Demo")


@mcp.tool()
async def report(ctx: Context) -> str:
    return f"report for request {ctx.request_id}"


@mcp.tool()
async def digest(ctx: Context) -> str:
    result = await mcp.call_tool("report", {}, context=ctx)
    return f"digest: {result}"

The lower layers (ToolManager.call_tool, Tool.run, PromptManager.render_prompt, Prompt.render, ResourceTemplate.create_resource, ResourceManager.get_resource) now require the context argument; call the MCPServer methods above instead.

MCPError raised from an @mcp.tool() handler now surfaces as a JSON-RPC error

Raising MCPError (or a subclass) inside an @mcp.tool() handler, or letting one propagate from a call the tool makes, now fails the tools/call request with a JSON-RPC error carrying the raised code, message, and data, so the client's call_tool() raises MCPError instead of returning a result. v1 caught it like any other exception and returned CallToolResult(isError=True) with the text Error executing tool <name>: <message>, dropping the code and data (UrlElicitationRequiredError was already re-raised and is unchanged).

Before (v1):

from mcp.server.fastmcp import FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import INVALID_PARAMS, ErrorData

mcp = FastMCP("demo")


@mcp.tool()
def strict(x: int) -> int:
    if x < 0:
        raise McpError(ErrorData(code=INVALID_PARAMS, message="x must be non-negative"))
    return x

# session.call_tool("strict", {"x": -1}) returned CallToolResult(isError=True)
# with text "Error executing tool strict: x must be non-negative"

After (v2):

from mcp import Client, MCPError
from mcp.server import MCPServer
from mcp.types import INVALID_PARAMS

mcp = MCPServer("demo")


@mcp.tool()
def strict(x: int) -> int:
    if x < 0:
        raise MCPError(INVALID_PARAMS, "x must be non-negative", {"x": x})
    return x


async def call() -> None:
    async with Client(mcp) as client:
        try:
            await client.call_tool("strict", {"x": -1})
        except MCPError as e:  # was: `if result.isError:`
            print(e.code, e.message, e.data)  # -32602 x must be non-negative {'x': -1}

Raise MCPError when the request itself should be rejected. To hand the model an is_error=True result instead, raise ToolError or return CallToolResult(is_error=True, ...); any other exception still produces one, but with only the generic text Error executing tool <name> (next section).

Exception messages from @mcp.tool() handlers no longer reach the model

v1 turned any exception raised inside an @mcp.tool() function into CallToolResult(isError=True) with the text Error executing tool <name>: <exception message>, so the model (and tests asserting on content[0].text) saw the message. v2 keeps the message only for ToolError; MCPError becomes a JSON-RPC error, and any other exception is treated as a crash: the result is still is_error=True but its text is just Error executing tool <name>, and the traceback goes to the server log at ERROR. Raise ToolError for failures the model should read (v1 has the same class at mcp.server.fastmcp.exceptions.ToolError with identical output, so the change can land before upgrading), or return CallToolResult(is_error=True, ...).

Before (v1):

@mcp.tool()
def read_config(path: str) -> str:
    with open(path) as f:
        return f.read()

# isError=True, text:
# "Error executing tool read_config: [Errno 2] No such file or directory: 'app.toml'"

After (v2):

from mcp.server.mcpserver.exceptions import ToolError


@mcp.tool()
def read_config(path: str) -> str:
    try:
        with open(path) as f:
            return f.read()
    except OSError as e:
        raise ToolError(f"Cannot read {path}: {e.strerror}") from e

# is_error=True, text:
# "Error executing tool read_config: Cannot read app.toml: No such file or directory"

A direct await mcp.call_tool(...) on a crashing tool raises UnexpectedToolError with the same generic message (see Calling MCPServer.call_tool(), get_prompt(), and read_resource() directly). Prompt, resource, and @mcp.completion() functions follow the same rule: only MCPError (or ResourceError from a resource) carries your message to the client; any other exception yields a generic error message and a server-side traceback. See Handling errors.

Content-block return annotations no longer produce output_schema or structured_content

A v1 tool whose return annotation is or contains a content-block type (-> TextContent, -> list[TextContent], -> EmbeddedResource, -> list[str | ImageContent], ...) advertised an outputSchema and echoed its return value into structuredContent. MCPServer treats these annotations as unstructured: the tool lists with output_schema=None and returns structured_content=None. content is unchanged.

Before (v1):

@mcp.tool()
def report() -> list[TextContent]:
    return [TextContent(type="text", text="done")]

result = await session.call_tool("report", {})
result.structuredContent  # {"result": [{"type": "text", "text": "done", ...}]}

After (v2):

result = await client.call_tool("report", {})
result.structured_content  # None; result.content is unchanged

Read content, or pass @mcp.tool(structured_output=True) to keep publishing the schema and structured_content. See Structured output.

Reading a missing resource raises ResourceNotFoundError and returns invalid-params

Reading a URI that matches no resource or template now fails with JSON-RPC code -32602 (invalid params) and error.data = {"uri": ...}, per SEP-2164; v1 returned code 0 with no data. Other read failures return -32603 with the same data: a ResourceError raised by the handler keeps its message, while any other exception gets a generic message naming only the URI (v1 sent the exception text; v2 logs it server-side).

Server-side, MCPServer.read_resource() and ctx.read_resource() raise ResourceNotFoundError for an unknown URI and its parent ResourceError for every other failure (a crashing handler is wrapped in the subclass UnexpectedResourceError, original on __cause__). None of these subclasses ValueError, which v1 raised for an unknown URI or a failing template, so an unported except ValueError inside a tool now lets the error escape and the tool returns is_error=True.

Before (v1):

try:
    contents = await ctx.read_resource("config://missing")
except ValueError:
    ...

After (v2):

from mcp.server.mcpserver.exceptions import ResourceNotFoundError

try:
    contents = await ctx.read_resource("config://missing")
except ResourceNotFoundError:
    ...

A resource or template handler that raised a plain exception for a missing instance should now raise ResourceNotFoundError so its message and the URI reach the client as -32602; see Handling errors.

Resource classes reject unknown keyword arguments

The Resource base class now sets extra="forbid", so TextResource, BinaryResource, FunctionResource, FileResource, HttpResource, DirectoryResource, and your own subclasses raise pydantic.ValidationError on an unrecognized keyword argument instead of silently dropping it. Remove the stray argument; to restore v1's silent-drop behavior on a subclass, set model_config = ConfigDict(extra="ignore").

FileResource.is_binary replaced by encoding

FileResource no longer takes is_binary; passing it raises pydantic.ValidationError at construction. Text versus blob is now chosen by encoding: str | None (None reads bytes and serves a base64 blob). When omitted, encoding is derived from mime_type: a declared charset= wins, otherwise "utf-8-sig" (UTF-8, BOM tolerated) for text/*, application/json, application/xml and any +json/+xml type, and None for everything else.

Two defaults change silently: files whose mime type is textual but not text/* (application/json, application/xml, image/svg+xml, …), which v1 served as blobs, are now served as text; and text files are decoded as UTF-8 rather than with the platform locale encoding (v1 called Path.read_text() with no encoding), so a non-UTF-8 file that v1 only read correctly under a matching locale (e.g. cp1252 on Windows) now raises UnicodeDecodeError. Where either default is wrong for a file, pass encoding explicitly.

Before (v1):

from pathlib import Path

from mcp.server.fastmcp.resources import FileResource

FileResource(uri="file:///logo.png", path=Path("/srv/logo.png"), mime_type="image/png", is_binary=True)
FileResource(uri="file:///notes.txt", path=Path("/srv/notes.txt"))  # text, locale encoding
FileResource(uri="file:///data.json", path=Path("/srv/data.json"), mime_type="application/json")  # blob

After (v2):

from pathlib import Path

from mcp.server.mcpserver.resources import FileResource

FileResource(uri="file:///logo.png", path=Path("/srv/logo.png"), mime_type="image/png")  # blob, from mime_type
FileResource(uri="file:///notes.txt", path=Path("/srv/notes.txt"))  # text, UTF-8
FileResource(uri="file:///data.json", path=Path("/srv/data.json"), mime_type="application/json")  # now text
FileResource(uri="file:///legacy.txt", path=Path("/srv/legacy.txt"), encoding="cp1252")  # non-UTF-8 text
# keep this JSON file as a blob
FileResource(uri="file:///raw.json", path=Path("/srv/raw.json"), mime_type="application/json", encoding=None)

Resource templates: matching behavior changes

Template matching now follows RFC 6570 instead of a regex built from the URI string. Existing {var} templates behave differently in these ways:

Extracted values are percent-decoded. docs://{name} read as docs://hello%20world calls the handler with name = "hello world"; v1 passed "hello%20world". Because %2F decodes to /, a simple {var} value can now contain slashes (docs://a%2Fb gives name = "a/b"), which v1's [^/]+ capture ruled out. Remove any manual unquote() in the handler.

Path-safety checks reject unsafe values by default. A decoded value that escapes upward through .. segments (a substring like v1.0..v2.0 is fine), looks like an absolute path (/etc/passwd, C:\Windows, any single-letter x: prefix), or contains a null byte fails the read with the same -32602 "Unknown resource" error as an unmatched URI, and the handler never runs. Exempt a parameter that legitimately carries such values, or pass resource_security= to MCPServer to change the policy server-wide:

from mcp.server.mcpserver import ResourceSecurity

@mcp.resource("inspect://file/{target}", security=ResourceSecurity(exempt_params={"target"}))
def inspect_file(target: str) -> str: ...

Literals and delimiters match exactly. data://v1.0/{id} no longer matches data://v1X0/42 (a template . was regex any-character), and {var} no longer captures ?, #, &, or ,: api://{id} no longer matches api://foo?x=1. To keep accepting the query string, write api://{id}{?x} and give the x parameter a default; for values that contain a literal , or &, use {+var} (which also admits /) or have clients percent-encode them.

{var} matches an empty value. tickets://{ticket_id} now matches tickets:// with ticket_id=""; v1 returned "Unknown resource" instead. Validate non-empty values in the handler if you relied on that.

Templates v1 accepted can now fail at decoration time. Duplicate variable names and other malformed shapes raise InvalidUriTemplate (a ValueError) when the decorator runs; v1 raised re.error only once a read reached the template. Two adjacent variables (x://{name}{path}), which v1's regex accepted, are rejected: separate them with a literal (x://{name}/{path}) or capture one variable and split it in the handler. A static URI whose handler takes only a Context parameter, silently unreachable in v1, now raises ValueError at decoration time: drop the parameter, or add a template variable so Context is injected; an optional query variable keeps a plain notes://recent read matching:

@mcp.resource("notes://recent{?limit}")
async def recent_notes(ctx: Context, limit: int = 10) -> str: ...

See URI templates and path safety for the operators added in v2 and the security settings.

MCPServer's Context logging: message renamed to data

On the MCPServer Context, log(), debug(), info(), warning(), and error() now take data: Any (any JSON-serializable value) instead of message: str; calls that pass message= by keyword raise TypeError.

Before (v1):

await ctx.log(level="info", message="hello")

After (v2):

await ctx.log(level="info", data="hello")
await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432})

Positional calls (await ctx.info("hello")) and logger_name= are unaffected. These methods are also deprecated in v2 and emit mcp.MCPDeprecationWarning, so the rename only keeps them working; the durable replacement is standard logging (see Logging and Deprecated features).

Context.client_id removed

ctx.client_id is removed; it only echoed a non-standard client_id key from the request's _meta. Read that key directly, or use the access token for the authenticated OAuth client (see Authorization).

Before (v1):

client_id = ctx.client_id

After (v2):

from mcp.server.auth.middleware.auth_context import get_access_token

# the raw _meta key, if you set it yourself
meta = ctx.request_context.meta
client_id = meta.get("client_id") if meta else None

# the authenticated OAuth client (usually what you want)
token = get_access_token()
client_id = token.client_id if token else None

ProgressContext and progress() context manager removed

The mcp.shared.progress module (Progress, ProgressContext, and the progress() context manager) is removed. Use Context.report_progress() in an MCPServer handler instead. Two behavior changes:

  • report_progress takes the absolute current value, not a delta: p.progress(25) called twice reported 25 then 50, while the same calls to report_progress report 25 twice. Keep the running total yourself.
  • With no progress token on the request, progress() raised ValueError; report_progress is a silent no-op.

Before (v1):

from mcp.shared.progress import progress

with progress(request_context, total=100) as p:
    await p.progress(25, message="step 1")  # reports 25
    await p.progress(25)                    # reports 50

After (v2):

from mcp.server import MCPServer
from mcp.server.mcpserver import Context

mcp = MCPServer("Progress")


@mcp.tool()
async def my_tool(x: int, ctx: Context) -> str:
    await ctx.report_progress(25, 100, message="step 1")
    await ctx.report_progress(50, 100)  # absolute value, not a delta
    return "done"

In a lowlevel Server handler the equivalent is await ctx.session.report_progress(50, 100, message="halfway") on the ServerRequestContext. See Progress.

Context.elicit() rejects non-spec schemas; mismatched answers raise ValueError

ctx.elicit() (and elicit_with_validation()) now reject two field shapes v1 accepted, raising TypeError before anything is sent: a list[str] field without spec-valid enum items and a union of primitives such as int | str. Use list[Literal[...]] for a multi-select and a single primitive type per field.

Before (v1):

from pydantic import BaseModel


class Preferences(BaseModel):
    tags: list[str]
    level: int | str

After (v2):

from typing import Literal

from pydantic import BaseModel


class Preferences(BaseModel):
    tags: list[Literal["news", "sports"]]
    level: str

An accepted answer whose content doesn't match the schema now raises ValueError (the pydantic ValidationError is its __cause__) instead of letting ValidationError propagate; change except ValidationError around ctx.elicit() to except ValueError.

See Elicitation.

isinstance() checks against ElicitationResult raise TypeError

ElicitationResult (the return type of ctx.elicit()) is now a subscriptable TypeAliasType rather than a plain union, so it is no longer a valid isinstance() target. Check the member classes instead, or branch on result.action ("accept" / "decline" / "cancel"), which is unaffected.

Before (v1):

from mcp.server.elicitation import ElicitationResult

if isinstance(result, ElicitationResult):
    ...

After (v2):

from mcp.server.elicitation import AcceptedElicitation, CancelledElicitation, DeclinedElicitation

if isinstance(result, (AcceptedElicitation, DeclinedElicitation, CancelledElicitation)):
    ...

Lowlevel Server

Lowlevel Server: decorator-based handlers replaced with constructor on_* params

The lowlevel Server no longer has @server.list_tools()-style registration decorators. Pass each handler to the constructor as an on_* keyword argument; every handler has the signature async (ctx: ServerRequestContext, params) -> Result, taking the typed params model and returning the full result type.

Before (v1):

from mcp.server.lowlevel.server import Server
import mcp.types as types

server = Server("my-server")

@server.list_tools()
async def handle_list_tools():
    return [types.Tool(name="my_tool", description="A tool", inputSchema={})]

@server.call_tool()
async def handle_call_tool(name: str, arguments: dict):
    return [types.TextContent(type="text", text=f"Called {name}")]

After (v2):

from mcp.server import Server, ServerRequestContext
from mcp.types import (
    CallToolRequestParams,
    CallToolResult,
    ListToolsResult,
    PaginatedRequestParams,
    TextContent,
    Tool,
)


async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
    return ListToolsResult(tools=[Tool(name="my_tool", description="A tool", input_schema={"type": "object"})])


async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
    return CallToolResult(content=[TextContent(type="text", text=f"Called {params.name}")])


server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)

ctx.session is the ServerSession; ctx.lifespan_context, ctx.request_id, and ctx.meta are on the context too. The jsonschema input/output validation that @server.call_tool() performed is gone: input_schema is advertised, never applied, so validate params.arguments yourself.

v1 decorator v2 constructor kwarg params type return type
@server.list_tools() on_list_tools PaginatedRequestParams \| None ListToolsResult
@server.call_tool() on_call_tool CallToolRequestParams CallToolResult
@server.list_resources() on_list_resources PaginatedRequestParams \| None ListResourcesResult
@server.list_resource_templates() on_list_resource_templates PaginatedRequestParams \| None ListResourceTemplatesResult
@server.read_resource() on_read_resource ReadResourceRequestParams ReadResourceResult
@server.subscribe_resource() on_subscribe_resource SubscribeRequestParams EmptyResult
@server.unsubscribe_resource() on_unsubscribe_resource UnsubscribeRequestParams EmptyResult
@server.list_prompts() on_list_prompts PaginatedRequestParams \| None ListPromptsResult
@server.get_prompt() on_get_prompt GetPromptRequestParams GetPromptResult
@server.completion() on_completion CompleteRequestParams CompleteResult
@server.set_logging_level() on_set_logging_level SetLevelRequestParams EmptyResult
@server.progress_notification() on_progress ProgressNotificationParams None
(none) on_ping RequestParams \| None EmptyResult
(none) on_roots_list_changed NotificationParams \| None None

All params and result types come from mcp.types. The full handler model is in The low-level Server. Around the handlers, the transports and how you drive server.run(...), create_initialization_options(), NotificationOptions, and the streamable-HTTP session manager with its EventStore are unchanged.

Lowlevel Server: automatic return value wrapping removed

The v1 handler decorators converted several return shapes into the wire result type; v2 on_* handlers return the fully constructed result (see The low-level Server), and a ported body that still returns a bare dict or list fails the request with an MCPError instead. If you want the conveniences back, use MCPServer, whose @mcp.tool() and @mcp.resource() still wrap return values.

call_tool wrapped list[ContentBlock], dict, or (list, dict) into a CallToolResult; a dict became structured_content plus a JSON TextContent. Build it yourself:

Before (v1):

@server.call_tool()
async def handle(name: str, arguments: dict) -> dict:
    return {"temperature": 22.5, "city": "London"}

After (v2):

import json

from mcp.server import ServerRequestContext
from mcp.types import CallToolRequestParams, CallToolResult, TextContent


async def handle(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
    data = {"temperature": 22.5, "city": "London"}
    return CallToolResult(
        content=[TextContent(type="text", text=json.dumps(data, indent=2))],
        structured_content=data,
    )

params.arguments is None when the client sends no arguments (the v1 decorator passed {}); use params.arguments or {}.

read_resource converted Iterable[ReadResourceContents] (and the deprecated str/bytes shorthand) into TextResourceContents/BlobResourceContents, base64-encoding bytes and defaulting the mime type. Build the contents yourself:

Before (v1):

from collections.abc import Iterable

from pydantic import AnyUrl

from mcp.server.lowlevel.helper_types import ReadResourceContents


@server.read_resource()
async def handle(uri: AnyUrl) -> Iterable[ReadResourceContents]:
    return [ReadResourceContents(content="file contents", mime_type="text/plain")]

After (v2):

import base64

from mcp.server import ServerRequestContext
from mcp.types import (
    BlobResourceContents,
    ReadResourceRequestParams,
    ReadResourceResult,
    TextResourceContents,
)


async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
    if params.uri.endswith(".png"):
        blob = base64.b64encode(b"\x89PNG...").decode()  # base64-encode binary yourself
        return ReadResourceResult(contents=[BlobResourceContents(uri=params.uri, blob=blob, mime_type="image/png")])
    return ReadResourceResult(
        contents=[TextResourceContents(uri=params.uri, text="file contents", mime_type="text/plain")]
    )

The other decorators wrapped bare returns too: list_* handlers now return ListToolsResult(tools=[...]), ListResourcesResult(resources=[...]), ListResourceTemplatesResult(resource_templates=[...]), and ListPromptsResult(prompts=[...]), and completion returns CompleteResult(completion=Completion(values=[...])) instead of Completion | None.

Lowlevel Server: tool handler exceptions no longer become CallToolResult(is_error=True)

The v1 @server.call_tool() decorator caught any exception the handler raised and returned it as CallToolResult(isError=True) carrying the exception text, so the model saw the error and could retry. A v2 on_call_tool handler has no such wrapper: the exception fails the tools/call request with a JSON-RPC error, the client's call_tool() raises MCPError (Internal server error on v2's default connection), and the model gets no tool result; see The low-level Server.

Before (v1):

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    raise ValueError("kaboom")  # client received CallToolResult(isError=True) with text "kaboom"

After (v2): catch the exception in the handler and build the error result yourself:

from mcp.server import ServerRequestContext
from mcp.types import CallToolRequestParams, CallToolResult, TextContent


async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
    try:
        args = params.arguments or {}
        text = str(args["a"] + args["b"])  # tool logic that may raise
    except Exception as e:  # what the v1 decorator did for you
        return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True)
    return CallToolResult(content=[TextContent(type="text", text=text)])

Raise MCPError only when the request itself should fail as a protocol error. MCPServer's @mcp.tool() still converts other exceptions into is_error=True results, but only a ToolError's message reaches the model; see Exception messages from @mcp.tool() handlers no longer reach the model.

Lowlevel Server: constructor parameters are now keyword-only

Every parameter after name is keyword-only, so passing version (or anything later) positionally raises TypeError at construction.

Before (v1):

from mcp.server import Server

server = Server("my-server", "1.0")

After (v2):

from mcp.server import Server

server = Server("my-server", version="1.0")

Lowlevel Server: type parameter reduced from 2 to 1

Server[LifespanResultT, RequestT] is now Server[LifespanResultT]. Drop the second type argument: a two-argument subscript fails type checking and raises TypeError where the annotation is evaluated at runtime.

Before (v1):

from typing import Any

from mcp.server import Server

server: Server[dict[str, Any], Any] = Server("my-server")

After (v2):

from typing import Any

from mcp.server import Server

server: Server[dict[str, Any]] = Server("my-server")

The transport request object that RequestT typed (server.request_context.request in v1) is now typed on the handler's ctx: ServerRequestContext[LifespanContextT, RequestT]; see RequestContext type parameters simplified.

Lowlevel Server: request_handlers and notification_handlers attributes removed

The public server.request_handlers and server.notification_handlers dicts are gone. Register handlers with the constructor's on_* parameters or, after construction, with add_request_handler / add_notification_handler; look them up with get_request_handler / get_notification_handler, which return the registered entry (with .handler and .params_type) or None. Keys are method strings, not request types.

Before (v1):

from mcp.types import ListToolsRequest

server.request_handlers[ListToolsRequest] = handle_list_tools
if ListToolsRequest in server.request_handlers:
    ...

After (v2):

from mcp.types import PaginatedRequestParams

server.add_request_handler("tools/list", PaginatedRequestParams, handle_list_tools)
if server.get_request_handler("tools/list") is not None:
    ...

handle_list_tools uses the v2 (ctx, params) handler signature; see The low-level Server for add_request_handler and custom methods.

Lowlevel Server.run(raise_exceptions=True): transport errors no longer re-raised

raise_exceptions=True now only affects handler exceptions: an unexpected exception from a handler still propagates out of run(), but the JSON-RPC error response is written to the client first (v1 re-raised without responding).

Exceptions the transport places on the read stream (e.g. a stdio JSON parse error) are now debug-logged and dropped regardless of the flag: run() keeps serving instead of raising, and the client no longer receives the Internal Server Error notifications/message log entry v1 sent.

Cancelled requests are no longer answered

v1 answered a request the peer cancelled with a JSON-RPC error, {"code": 0, "message": "Request cancelled"}; v2 sends no response (no result, no error) in either direction: the server for a cancelled client request, the client for a cancelled server-initiated request such as sampling or elicitation. The one exception is the 2025-era streamable HTTP server transport, whose wire can end a request's stream only with a response: it terminates a cancelled request with -32800 (mcp.server.streamable_http.REQUEST_CANCELLED) instead of the old 0.

Callers that abandon a call through the SDK (cancelling the awaiting task or scope, or a per-request timeout) are unaffected. Code that hand-sends notifications/cancelled while another task still awaits the call breaks: the call no longer raises McpError("Request cancelled"); depending on the connection it hangs unanswered, fails with MCPError code -32800 (2025-era streamable HTTP), or completes normally because the hand-sent cancel is not applied (e.g. the default in-process Client(server)). Cancel the scope or task awaiting the call instead; v2 signals the server for you and the handler is interrupted:

Before (v1):

await session.send_notification(
    types.ClientNotification(
        types.CancelledNotification(
            params=types.CancelledNotificationParams(requestId=request_id, reason="user aborted"),
        )
    )
)  # the task awaiting session.call_tool(...) then raised McpError("Request cancelled")

After (v2):

with anyio.CancelScope() as scope:  # scope.cancel() abandons the call and interrupts the server-side handler
    result = await session.call_tool("slow", {})
if scope.cancelled_caught:
    ...  # the call was abandoned (replaces v1's `except McpError`)

Server.run() no longer takes a stateless flag

Passing stateless= to the lowlevel Server.run() raises TypeError; delete it. Stateless serving is a setting of the streamable-HTTP transport, which the lowlevel Server can now build itself:

Before (v1):

await server.run(read_stream, write_stream, init_options, stateless=True)

After (v2):

from mcp.server.streamable_http_manager import StreamableHTTPSessionManager

app = server.streamable_http_app(stateless_http=True)  # new on the lowlevel Server
# or keep driving the session manager yourself (unchanged from v1):
manager = StreamableHTTPSessionManager(app=server, stateless=True)

Over any other stream pair, a pre-2026 client must now send initialize before its first request. On a stateless_http=True server a handler's server-initiated request (sampling, elicitation, roots) raises NoBackChannelError instead of hanging until the client's read timeout as it did in v1; see Server-initiated sampling, elicitation, and roots raise NoBackChannelError and Serving legacy clients.

Lowlevel Server: request_context property removed

server.request_context and the module-level request_ctx contextvar are gone. The request context is now the handler's first argument, ctx: ServerRequestContext, with the same session, lifespan_context, request_id, meta, and request fields. There is no ambient accessor in v2, so pass ctx down to any helper that called request_ctx.get().

Before (v1):

import mcp.types as types
from mcp.server.lowlevel import Server
from mcp.server.lowlevel.server import request_ctx

server = Server("my-server")


@server.call_tool()
async def query_db(name: str, arguments: dict) -> list[types.TextContent]:
    ctx = server.request_context  # or, from any nested helper, request_ctx.get()
    db = ctx.lifespan_context["db"]
    return [types.TextContent(type="text", text=f"queried {db}")]

After (v2):

from mcp.server import Server, ServerRequestContext
from mcp.types import CallToolRequestParams, CallToolResult, TextContent


async def query_db(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
    db = ctx.lifespan_context["db"]
    return CallToolResult(content=[TextContent(type="text", text=f"queried {db}")])


server = Server("my-server", on_call_tool=query_db)

RequestContext type parameters simplified

mcp.shared.context.RequestContext[SessionT, LifespanContextT, RequestT] is gone (from mcp.shared.context import RequestContext raises ImportError). It is split by side, and the leading session type parameter is dropped:

  • The sampling_callback, elicitation_callback, and list_roots_callback client callbacks receive the non-generic ClientRequestContext from mcp.client (session, request_id, meta); the always-None lifespan_context and request are gone.
  • Server-side code uses ServerRequestContext[LifespanContextT, RequestT] from mcp.server.
  • The injected high-level Context[ServerSessionT, LifespanContextT, RequestT] becomes Context[LifespanContextT, RequestT]: Context[ServerSession, None] becomes bare Context, and Context[ServerSession, AppContext] becomes Context[AppContext] (a tools-only spelling, as in v1).

A leftover two-argument v1 annotation such as Context[ServerSession, None] still runs but silently types lifespan_context as ServerSession; three type arguments raise TypeError.

Before (v1):

from dataclasses import dataclass
from typing import Any

from mcp import ClientSession, types
from mcp.server.fastmcp import Context
from mcp.server.session import ServerSession
from mcp.shared.context import RequestContext


@dataclass
class AppContext:
    db_url: str


async def handle_sampling(
    context: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams
) -> types.CreateMessageResult: ...


def db_url_from(ctx: RequestContext[ServerSession, AppContext, Any]) -> str:
    return ctx.lifespan_context.db_url


async def typed_tool(ctx: Context[ServerSession, AppContext]) -> str:
    return ctx.request_context.lifespan_context.db_url

After (v2):

from dataclasses import dataclass

from mcp.client import ClientRequestContext
from mcp.server import ServerRequestContext
from mcp.server.mcpserver import Context
from mcp.types import CreateMessageRequestParams, CreateMessageResult


@dataclass
class AppContext:
    db_url: str


async def handle_sampling(
    context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult: ...


def db_url_from(ctx: ServerRequestContext[AppContext]) -> str:
    return ctx.lifespan_context.db_url


async def typed_tool(ctx: Context[AppContext]) -> str:
    return ctx.request_context.lifespan_context.db_url

The LifespanContextT/RequestT TypeVars now live in mcp.server.context. ServerRequestContext.request_id is RequestId | None (None when the same context reaches a notification handler), so code passing ctx.request_id on as a definite RequestId needs a None check.

ServerSession is now a thin proxy (no longer a BaseSession)

ServerSession no longer subclasses BaseSession and is not an async context manager; handlers still reach it as ctx.session. The typed helpers (send_*, create_message, elicit_*, list_roots, client_params, check_client_capability) keep their v1 names, but send_notification and send_request now take the message model itself: the types.ServerNotification(...) / types.ServerRequest(...) wrappers are gone (see Replace RootModel by union types with TypeAdapter validation).

Two behavior changes reach code that only used ctx.session:

  • ctx.session is a new object on every request. v1 kept one ServerSession per connection, so servers keyed per-client state on its identity (WeakKeyDictionary[ServerSession, ...], id(ctx.session), a set of captured sessions to notify later). Those idioms now silently misbehave: a session-keyed dict never finds an earlier entry, and a broadcast set gains a proxy per request and sends duplicates. Key on something connection-stable instead: on stateful streamable HTTP the mcp-session-id header (ctx.headers on MCPServer, ctx.request.headers in a lowlevel handler; 2026-07-28 clients are sessionless and send none); on stdio the process serves a single client.
  • Sends on a closed connection are dropped instead of raising. In v1 the notification helpers (send_resource_updated(), send_tool_list_changed(), ...) raised anyio.ClosedResourceError / anyio.BrokenResourceError once the connection was gone, and broadcast loops used that to prune dead sessions. In v2 the send returns normally (the drop is debug-logged); probe with a request instead: await session.send_ping() raises MCPError (CONNECTION_CLOSED) once the connection has closed. On a 2026-07-28 connection send_ping() raises NoBackChannelError regardless (see Behavior changes on v2's default connection), so it is a liveness probe only for 2025-11-25 and earlier peers.

If you constructed or subclassed ServerSession directly, hand the streams to a Server instead:

Before (v1):

import anyio

from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.server.stdio import stdio_server
from mcp.types import ServerCapabilities

init_options = InitializationOptions(server_name="mcp", server_version="0.1.0", capabilities=ServerCapabilities())


async def main() -> None:
    async with stdio_server() as (read_stream, write_stream):
        async with ServerSession(read_stream, write_stream, init_options) as session:
            async for message in session.incoming_messages:
                ...


anyio.run(main)

After (v2):

import anyio

from mcp.server import Server
from mcp.server.stdio import stdio_server

server = Server("mcp")


async def main() -> None:
    async with stdio_server() as (read_stream, write_stream):
        await server.run(read_stream, write_stream, server.create_initialization_options())


anyio.run(main)

Register handlers via the Server constructor's on_* arguments (or add_request_handler); to observe every inbound request and notification (what incoming_messages or an overridden _receive_loop / _received_request gave you), add a middleware. Also removed from mcp.server.session: InitializationState, ServerRequestResponder, and the ServerSessionT TypeVar (see RequestContext type parameters simplified); the tasks-only experimental, send_message(), and add_response_router() members went with the experimental tasks API.

ServerSession.elicit() and elicit_form() take requested_schema, not requestedSchema

ServerSession.elicit() and elicit_form() renamed the requestedSchema parameter to requested_schema; keyword callers get TypeError: ServerSession.elicit_form() got an unexpected keyword argument 'requestedSchema'.

Before (v1):

result = await ctx.session.elicit_form(
    message="Your name?",
    requestedSchema={
        "type": "object",
        "properties": {"name": {"type": "string"}},
        "required": ["name"],
    },
)

After (v2):

result = await ctx.session.elicit_form(
    message="Your name?",
    requested_schema={
        "type": "object",
        "properties": {"name": {"type": "string"}},
        "required": ["name"],
    },
)

Positional callers (session.elicit_form(message, schema)) are unaffected.

Clients

ClientSession.get_server_capabilities() replaced by properties

get_server_capabilities() is removed; read the server_capabilities property, which is likewise None until the session is initialized. server_info, instructions, and protocol_version are exposed the same way; initialize() still returns the InitializeResult.

Before (v1):

await session.initialize()
capabilities = session.get_server_capabilities()

After (v2):

await session.initialize()
capabilities = session.server_capabilities
server_info = session.server_info
instructions = session.instructions
version = session.protocol_version

The high-level Client exposes the same four properties, populated on entering its async with block.

cursor parameter removed from ClientSession list methods

ClientSession.list_resources(), list_resource_templates(), list_prompts(), and list_tools() no longer accept the deprecated cursor argument; passing it raises TypeError. Wrap the cursor in PaginatedRequestParams, or use the high-level Client, whose list_* methods take cursor= directly (and do not accept params=). See Pagination.

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"))

# or, on the high-level Client:
result = await client.list_resources(cursor="next_page_token")
result = await client.list_tools(cursor="next_page_token")

args parameter removed from ClientSessionGroup.call_tool()

Calls using the old args= keyword now raise TypeError. Use arguments=, or pass the dict positionally (works on both v1 and v2).

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"})

Timeouts take float seconds instead of timedelta

Every timeout parameter that took a datetime.timedelta in v1 now takes plain seconds as a float:

Surface v1 type v2 type
ClientSession(read_timeout_seconds=...) timedelta \| None float \| None
ClientSession.call_tool(read_timeout_seconds=...) timedelta \| None float \| None
ClientSession.send_request(request_read_timeout_seconds=...) timedelta \| None float \| None
ClientSessionGroup.call_tool(read_timeout_seconds=...) timedelta \| None float \| None
ClientSessionParameters.read_timeout_seconds timedelta \| None float \| None
StreamableHttpParameters.timeout / .sse_read_timeout timedelta float
ServerSession.send_request(request_read_timeout_seconds=...) timedelta \| None float \| None

SseServerParameters already used float in v1 and is unaffected.

Before (v1):

from datetime import timedelta

session = ClientSession(read_stream, write_stream, read_timeout_seconds=timedelta(seconds=30))
result = await session.call_tool("slow_tool", {}, read_timeout_seconds=timedelta(minutes=2))

params = StreamableHttpParameters(
    url="https://example.com/mcp",
    timeout=timedelta(seconds=30),
    sse_read_timeout=timedelta(seconds=300),
)

After (v2):

session = ClientSession(read_stream, write_stream, read_timeout_seconds=30)
result = await session.call_tool("slow_tool", {}, read_timeout_seconds=120)

params = StreamableHttpParameters(
    url="https://example.com/mcp",
    timeout=30,
    sse_read_timeout=300,
)

Replace timedelta(...) with plain seconds, or append .total_seconds(). Only StreamableHttpParameters fails loudly on a leftover timedelta (pydantic ValidationError: Input should be a valid number); the session and per-call parameters accept it silently, and the first request that arms the timeout raises TypeError: unsupported operand type(s) for +: 'float' and 'datetime.timedelta' from anyio.

Client request timeouts raise REQUEST_TIMEOUT instead of code 408

A request that exceeds read_timeout_seconds still raises MCPError (v1's McpError), but its error code changed from the HTTP status 408 (httpx.codes.REQUEST_TIMEOUT) to the JSON-RPC code -32001, exported as REQUEST_TIMEOUT. The message wording changed too and differs between the in-process and wire transports, so match on the code, not the text. A leftover e.error.code == 408 check still evaluates without error and silently never matches.

Before (v1):

from mcp.shared.exceptions import McpError

try:
    result = await session.call_tool("slow_tool", {})
except McpError as e:
    if e.error.code == 408:
        ...  # retry / back off
    else:
        raise

After (v2):

from mcp import MCPError
from mcp.types import REQUEST_TIMEOUT  # -32001

try:
    result = await session.call_tool("slow_tool", {})
except MCPError as e:
    if e.code == REQUEST_TIMEOUT:  # e.error.code still works too
        ...  # retry / back off
    else:
        raise

ClientSession now runs on JSONRPCDispatcher; BaseSession removed

ClientSession no longer subclasses BaseSession: the mcp.shared.session module (BaseSession, RequestResponder) is gone with no shim, and the receive loop is now JSONRPCDispatcher (mcp.shared.jsonrpc_dispatcher). The constructor callbacks and lifecycle are unchanged; code that reached into BaseSession to intercept the wire passes a dispatcher through the new keyword-only dispatcher= argument instead (the Dispatcher protocol in mcp.shared.dispatcher is provisional). ProgressFnT moved to mcp.shared.dispatcher, RequestId comes from mcp.types, and the session TypeVars (SendRequestT and friends) went with the module: the sessions are no longer generic.

message_handler no longer receives requests (the typed callbacks answer them): its parameter is IncomingMessage = ServerNotification | Exception, exported from mcp.client (see Callbacks). Notifications arrive as the concrete model rather than the v1 RootModel wrapper, so drop .root (message.params, not message.root.params; see Replace RootModel by union types with TypeAdapter validation).

Before (v1):

import mcp.types as types
from mcp.shared.session import RequestResponder


async def message_handler(
    message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
    if isinstance(message, Exception):
        raise message

After (v2):

from mcp.client import IncomingMessage


async def message_handler(message: IncomingMessage) -> None:
    if isinstance(message, Exception):
        raise message

That raise no longer takes the session down: v2 logs an exception raised from message_handler and carries on, whereas on v1 it ended the receive loop and every later send raised anyio.ClosedResourceError.

Behavior changes on unchanged session code:

  • Callbacks and notifications run concurrently. v1 handled one inbound message at a time, so callbacks ran inline and in order; v2 runs each server-initiated request callback and each notification delivery in its own task. They may interleave, a callback may itself send requests (that deadlocked in v1), an in-flight callback is interrupted when the server sends notifications/cancelled, and a progress_callback may run after its request has returned. Callbacks that need strict ordering must coordinate themselves.
  • A raising request callback is answered with code=0 and the exception text; v1 flattened every callback exception to INVALID_PARAMS ("Invalid request parameters", still the answer for a pydantic ValidationError). Return ErrorData or raise MCPError to choose the error code.
  • After the connection closes, send_request raises MCPError (CONNECTION_CLOSED) and send_notification is dropped with a debug log (the typed notification helpers included); v1 raised anyio.ClosedResourceError or anyio.BrokenResourceError from both. If that exception was your disconnect signal, probe with a request instead. send_request before entering the context manager now raises RuntimeError (v1 wrote the request and hung).
  • send_notification no longer takes related_request_id.
  • Abandoning a request now sends notifications/cancelled. Cancelling the awaiting task or scope, or a request hitting its read timeout, tells the server to interrupt the handler; v1 sent nothing. A cancelled peer no longer answers at all: see Cancelled requests are no longer answered.
  • Request callbacks receive mcp.client.ClientRequestContext in place of RequestContext[ClientSession, Any]; see RequestContext type parameters simplified.

Experimental Tasks support removed

The deprecated experimental Tasks API is removed (the 2026-07-28 spec moves Tasks into an extension, SEP-2663, that this SDK does not implement). The mcp.client.experimental, mcp.server.experimental, mcp.shared.experimental, and mcp.server.lowlevel.experimental modules are gone, along with the experimental accessors on Server, ServerSession, ClientSession, and RequestContext, and the ClientSession(experimental_task_handlers=...) argument. The Task* models stay in mcp.types as types-only definitions; the TASK_* constants and the TaskExecutionMode alias are gone (see Removed type aliases and classes).

There is no drop-in replacement for the tasks runtime (server.experimental.enable_tasks() and its @server.experimental.* handler decorators, ctx.experimental.run_task(), ServerTaskContext / TaskStore, the client's session.experimental.call_tool_as_task() / poll_task() / get_task_result()). Port by what the code used tasks for:

Status updates on a long-running tool. Run the work inline in the handler and replace task.update_status() with ctx.report_progress() (ctx.session.report_progress() in a lowlevel handler); the client passes progress_callback= to call_tool() instead of creating a task and polling; see Progress.

Before (v1):

# server
async def work(task: ServerTaskContext) -> CallToolResult:
    await task.update_status("Processing step 1...")
    ...

result = await ctx.experimental.run_task(work)

# client: create the task, poll its status, then fetch the result
result = await session.experimental.call_tool_as_task("long_running_task", arguments={}, ttl=60000)
async for status in session.experimental.poll_task(result.task.taskId):
    print(status.statusMessage)
task_result = await session.experimental.get_task_result(result.task.taskId, CallToolResult)

After (v2):

# server
@mcp.tool()
async def long_running_task(ctx: Context) -> str:
    await ctx.report_progress(1, total=3, message="Processing step 1...")
    ...
    return "Task completed!"

# client
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
    print(message)

result = await client.call_tool("long_running_task", {}, progress_callback=on_progress)

User input mid-work (task.elicit(), task.create_message()). Don't port to inline ctx.elicit() / ctx.session.create_message(): those raise NoBackChannelError on 2026-07-28 connections, the default for an in-process Client(server). Use the resolver dependencies (Elicit, Sample) or return an InputRequiredResult; see Multi-round-trip requests and Server-initiated sampling, elicitation, and roots raise NoBackChannelError.

Detached work (fetch the result on a later connection or after a client restart) has no v2 equivalent.

Also drop execution=ToolExecution(taskSupport=types.TASK_REQUIRED) from lowlevel types.Tool definitions: the constant is gone and nothing in v2 reads the field.

Transports

The sections below are client-side apart from stdio_server keeps the protocol streams on private descriptors; the streamable-HTTP lifespan change is under MCPServer (lifespan entered once at manager startup).

streamablehttp_client removed

The deprecated streamablehttp_client function is gone. Use streamable_http_client(url, *, http_client=None, terminate_on_close=True): headers, timeouts, and auth move onto an httpx2.AsyncClient you pass as http_client (see Client transports), and the yielded tuple drops the get_session_id callback (next section).

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 httpx2
from mcp.client.streamable_http import streamable_http_client

async with httpx2.AsyncClient(
    headers={"Authorization": "Bearer token"},
    timeout=httpx2.Timeout(30, read=300),
    auth=my_auth,
    follow_redirects=True,
) as http_client:
    async with streamable_http_client(
        url="http://localhost:8000/mcp",
        http_client=http_client,
    ) as (read_stream, write_stream):
        ...

Port timeout=/sse_read_timeout= as httpx2.Timeout(timeout, read=sse_read_timeout) and keep follow_redirects=True: a bare httpx2.AsyncClient() defaults to a flat 5-second timeout (too short for the long-lived GET stream) and no redirect following, while omitting http_client gives you a default client with the v1 values (Timeout(30, read=300), redirects followed). httpx_client_factory is gone: call your factory yourself, enter the client it returns (the SDK no longer closes a client it did not create), and pass it as http_client.

get_session_id callback removed from streamable_http_client

streamable_http_client now yields (read_stream, write_stream) instead of a 3-tuple, so a three-name unpack fails with ValueError: not enough values to unpack (expected 3, got 2). The get_session_id callback, the StreamableHTTPTransport.get_session_id() method behind it, and the GetSessionIdCallback alias are gone (from mcp.client.streamable_http import GetSessionIdCallback raises ImportError; inline Callable[[], str | None] if you still need the type).

Before (v1):

from mcp import ClientSession
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()

After (v2): unpack two values. If you still need the session ID, read the mcp-session-id response header with an httpx2 event hook (the hook fires on every response; the last entry is the current session's ID):

import httpx2

from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

captured_session_ids: list[str] = []


async def capture_session_id(response: httpx2.Response) -> None:
    session_id = response.headers.get("mcp-session-id")
    if session_id:
        captured_session_ids.append(session_id)


http_client = httpx2.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[-1] if captured_session_ids else None

StreamableHTTPTransport parameters removed

StreamableHTTPTransport now takes only url; passing headers, timeout, sse_read_timeout, or auth raises TypeError. Configure them on the httpx2.AsyncClient you pass to streamable_http_client(url, http_client=...) (headers=, auth=, timeout=httpx2.Timeout(timeout, read=sse_read_timeout)), as shown in streamablehttp_client removed and Client transports. sse_client keeps all four parameters; there only auth is retyped, to httpx2.Auth (see the httpx2 swap).

StreamableHTTPTransport.protocol_version and MCP_PROTOCOL_VERSION removed

StreamableHTTPTransport no longer exposes a protocol_version attribute; read the negotiated version from session.protocol_version on ClientSession or client.protocol_version on the high-level Client. The header-name constant moved from mcp.client.streamable_http to mcp.shared.inbound under a new name (same value, "mcp-protocol-version"):

Before (v1):

from mcp.client.streamable_http import MCP_PROTOCOL_VERSION

headers = {MCP_PROTOCOL_VERSION: "2025-11-25"}

After (v2):

from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER

headers = {MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}

Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors

In v1 a 404 to a request POST injected McpError with the positive code 32600; any other non-2xx response raised httpx.HTTPStatusError, which escaped streamable_http_client as an ExceptionGroup and cancelled every pending request. In v2 the transport resolves the failing request with a JSON-RPC error, raised as MCPError from that one call, and the connection stays usable.

Server response v1 v2
404, session established McpError with positive code 32600 MCPError(-32600, 'Session terminated')
404, no session yet McpError with positive code 32600 MCPError(-32601, 'Not Found')
Any other 4xx/5xx httpx.HTTPStatusError escapes the context as ExceptionGroup MCPError(-32603, 'Server returned an error response')
Any of the above with a JSON-RPC error body body ignored body's error surfaced verbatim; an SDK server's unknown-session 404 arrives as MCPError(-32600, 'Session not found')

Two v1 patterns silently stop working: an except* httpx.HTTPStatusError around the transport context is dead code, and a session-expiry check on error.code == 32600 never matches the now-negative -32600.

Before (v1):

import httpx
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.exceptions import McpError

try:
    async with streamable_http_client(url) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            try:
                await session.list_tools()
            except McpError as exc:
                if exc.error.code == 32600:  # 404: "Session terminated"
                    ...  # rebuild the connection
                else:
                    raise
except* httpx.HTTPStatusError:
    ...  # any other 4xx/5xx: rebuild the connection

After (v2):

from mcp import ClientSession, MCPError
from mcp.client.streamable_http import streamable_http_client
from mcp.types import INVALID_REQUEST  # -32600

async with streamable_http_client(url) as (read, write):
    async with ClientSession(read, write) as session:
        await session.initialize()
        try:
            await session.list_tools()
        except MCPError as exc:
            if exc.code == INVALID_REQUEST and exc.message in ("Session not found", "Session terminated"):
                ...  # session expired: rebuild the connection
            else:
                raise  # only this call failed; the session is still usable

Catch MCPError per call (McpError renamed to MCPError); connect-level failures such as httpx2.ConnectError still escape the transport context as an ExceptionGroup, so keep context-level handling for those only. Troubleshooting covers Server returned an error response and Session not found.

mcp.os.win32.utilities process helpers changed

mcp.os.win32.utilities is internal plumbing for stdio_client, but code that reused its helpers (e.g. a copy of the stdio client's cleanup) needs two edits: the deprecated terminate_windows_process is gone with no replacement (stdio_client handles termination itself), and terminate_windows_process_tree no longer accepts timeout_seconds (the argument was already ignored, so drop it).

Before (v1):

from mcp.os.win32.utilities import terminate_windows_process_tree

await terminate_windows_process_tree(process, timeout_seconds)

After (v2):

from mcp.os.win32.utilities import terminate_windows_process_tree

await terminate_windows_process_tree(process)

stdio_server keeps the protocol streams on private descriptors

A POSIX watchdog thread that polls fd 0 to detect a vanished client stops working. While stdio_server() (and so any stdio MCPServer) is serving, the protocol runs on private duplicates of the standard descriptors, with fd 0 pointing at the null device and fd 1 at stderr (both restored on exit). The null device never reports POLLHUP or POLLERR, so the wait no longer fires when the client closes the pipe (on macOS, where poll() does not support device files, it can instead return POLLNVAL at startup). Poll a duplicate of the descriptor taken before the server starts:

Before (v1):

import os
import select
import threading


def watch_stdin() -> None:
    poller = select.poll()
    poller.register(0, select.POLLHUP | select.POLLERR)
    poller.poll()  # blocked until the client closes the pipe
    os._exit(0)


threading.Thread(target=watch_stdin, daemon=True).start()

After (v2):

import os
import select
import threading

wire = os.dup(0)  # capture the client pipe before stdio_server() diverts fd 0


def watch_stdin() -> None:
    poller = select.poll()
    poller.register(wire, select.POLLHUP | select.POLLERR)
    poller.poll()  # blocked until the client closes the pipe
    os._exit(0)


threading.Thread(target=watch_stdin, daemon=True).start()

This also works on v1. If the descriptor cannot be captured before serving begins, watch os.getppid() for a change instead: an orphaned server is reparented when its launching parent dies. The redirection also means output flushed to fd 1 while serving (a stray print(), an inherited child process) lands on stderr instead of the wire (Running your server: stdio); explicit stdin=/stdout= streams passed to stdio_server() are used as given and not diverted.

WebSocket transport removed

mcp.client.websocket.websocket_client, mcp.server.websocket.websocket_server, and the mcp[ws] extra are gone (WebSocket was never part of the MCP spec). Use the streamable HTTP transport instead.

Before (v1):

from mcp.client.websocket import websocket_client

async with websocket_client("ws://localhost:8000/ws") as (read, write):
    ...

After (v2):

from mcp.client.streamable_http import streamable_http_client

async with streamable_http_client("http://localhost:8000/mcp") as (read, write):
    ...

Or connect with Client("http://localhost:8000/mcp") directly. On the server, drop the websocket_server ASGI route and mount streamable_http_app() (available on MCPServer and the lowlevel Server), or call mcp.run(transport="streamable-http"). See Client transports and Add to an existing app.

OAuth and server auth

RFC7523OAuthClientProvider and JWTParameters removed

RFC7523OAuthClientProvider and its JWTParameters model are removed from mcp.client.auth.extensions.client_credentials. Use ClientCredentialsOAuthProvider for a client secret, PrivateKeyJWTOAuthProvider for a JWT.

Before (v1):

from mcp.client.auth.extensions.client_credentials import JWTParameters, RFC7523OAuthClientProvider

provider = RFC7523OAuthClientProvider(
    server_url=server_url,
    client_metadata=client_metadata,
    storage=storage,
    jwt_parameters=JWTParameters(issuer="my-client-id", subject="my-client-id", jwt_signing_key=key_pem),
)

After (v2):

from mcp.client.auth.extensions.client_credentials import PrivateKeyJWTOAuthProvider, SignedJWTParameters

provider = PrivateKeyJWTOAuthProvider(
    server_url=server_url,
    storage=storage,
    client_id="my-client-id",
    assertion_provider=SignedJWTParameters(
        issuer="my-client-id", subject="my-client-id", signing_key=key_pem
    ).create_assertion_provider(),
    issuer="https://auth.example.com",
)

The provider sends a client_credentials grant with private_key_jwt client authentication rather than the jwt-bearer grant RFC7523OAuthClientProvider sent, and takes a pre-registered client_id plus scope= and issuer= (see scopes= renamed to scope=) in place of client_metadata (no dynamic client registration). Relative to JWTParameters, SignedJWTParameters drops the jwt_ prefix (jwt_signing_keysigning_key, jwt_signing_algorithmsigning_algorithm, jwt_lifetime_secondslifetime_seconds), renames claims to additional_claims, and has no audience (the provider supplies the authorization server's issuer); a prebuilt JWTParameters(assertion=...) becomes assertion_provider=static_assertion_provider(token). See OAuth clients. For an enterprise ID-JAG jwt-bearer grant, use IdentityAssertionOAuthProvider (Identity assertion). The authorization_code flow with private_key_jwt token-endpoint authentication has no v2 equivalent. The module also no longer re-exports OAuthTokenError; import it from mcp.client.auth.

OAuth metadata URLs no longer gain a trailing slash

OAuthMetadata, ProtectedResourceMetadata, OAuthClientMetadata, OAuthClientInformationFull, and AuthSettings now set url_preserve_empty_path=True: a path-less URL parsed from a string (constructor argument or wire JSON) keeps its empty path instead of gaining a trailing slash.

from mcp.shared.auth import OAuthClientMetadata

meta = OAuthClientMetadata.model_validate({"redirect_uris": ["http://localhost:8080"]})
print(meta.model_dump(mode="json")["redirect_uris"])
# v1: ['http://localhost:8080/']
# v2: ['http://localhost:8080']

The client sends redirect_uris[0] verbatim in the /authorize and token requests, and authorization servers match redirect URIs by exact string comparison (RFC 6749 §3.1.2.3). If a v1 run registered such a URI via Dynamic Client Registration (as http://localhost:8080/) and your TokenStorage still returns that client information, clear it so the client re-registers, or write the URI with an explicit trailing slash. Redirect URIs with a path (http://localhost:3000/callback) and values passed as AnyUrl(...)/AnyHttpUrl(...) objects are unchanged.

Likewise, a path-less string issuer_url or resource_server_url in AuthSettings is now advertised as https://as.example.com rather than https://as.example.com/; update anything that pinned the old string form.

OAuth callback_handler returns AuthorizationCodeResult

The callback_handler passed to OAuthClientProvider must return an AuthorizationCodeResult instead of a (code, state) tuple; an unchanged tuple-returning handler crashes the authorization flow (AttributeError). Forward the redirect's iss parameter as well: the provider now validates it against the authorization server's issuer (RFC 9207, SEP-2468) and raises OAuthFlowError on a mismatch, or on a missing iss when the server advertises authorization_response_iss_parameter_supported.

Before (v1):

async def callback_handler() -> tuple[str, str | None]:
    params = parse_qs(urlparse(await wait_for_redirect()).query)
    return params["code"][0], params.get("state", [None])[0]

After (v2):

from mcp.client.auth import AuthorizationCodeResult


async def callback_handler() -> AuthorizationCodeResult:
    params = parse_qs(urlparse(await wait_for_redirect()).query)
    return AuthorizationCodeResult(
        code=params["code"][0],
        state=params.get("state", [None])[0],
        iss=params.get("iss", [None])[0],
    )

See OAuth clients for the full handler contract.

scopes= renamed to scope= on the client-credentials providers

ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider renamed the scopes keyword to scope; the value is still a single space-separated string, and an unchanged scopes= call raises TypeError.

Before (v1):

provider = ClientCredentialsOAuthProvider(server_url, storage, client_id, client_secret, scopes="read write")

After (v2):

provider = ClientCredentialsOAuthProvider(
    server_url, storage, client_id, client_secret, scope="read write", issuer="https://auth.example.com"
)

Also pass issuer=, the issuer URL of the authorization server that issued the credentials: token requests are then only built from discovered metadata naming that issuer, otherwise the flow stops with OAuthFlowError, and constructing either provider without it emits MCPDeprecationWarning (see OAuth clients). With token_endpoint_auth_method="client_secret_post" the token request body now carries client_id next to client_secret (RFC 6749 §2.3.1), so update any mock authorization server or test that pins the exact form body.

timeout parameter removed from OAuthClientProvider

OAuthClientProvider no longer accepts a timeout argument, and OAuthContext.timeout is gone. The value was stored but never read, so dropping it changes no behavior.

Before (v1):

provider = OAuthClientProvider(server_url, client_metadata, storage, timeout=120.0)

After (v2):

provider = OAuthClientProvider(server_url, client_metadata, storage)

If you passed timeout intending to bound the wait for the user to authorize, apply that bound where you actually wait: in your redirect_handler/callback_handler, e.g. with anyio.fail_after(120): ....

Client rejects authorization server metadata with a mismatched issuer

OAuthClientProvider (and the client-credentials providers built on it) now requires the authorization server metadata issuer to be string-equal to the URL discovery started from (RFC 8414 section 3.3, SEP-2468): the entry taken from the protected resource metadata's authorization_servers list, or, when the MCP server publishes no protected resource metadata, the MCP server's own origin (scheme://host[:port], with or without a trailing slash). v1 never compared the two, so a deployment whose values disagree (even by a trailing slash) authenticated under v1 and now fails discovery. For example, protected resource metadata advertising "authorization_servers": ["https://as.example.com"] against authorization server metadata with "issuer": "https://as.example.com/" raises:

OAuthFlowError: Authorization server metadata issuer mismatch: https://as.example.com/ != https://as.example.com

There is no client-side override; make the two strings identical. If the MCP server also uses this SDK, AuthSettings(issuer_url=...) is what it advertises in authorization_servers, so set it to exactly the authorization server's issuer. See OAuth metadata URLs no longer gain a trailing slash for how v2 preserves the exact string form of these URLs.

The origin fallback itself now requires a clean miss: if a protected resource metadata URL answers 5xx or 429 and none succeeds, discovery stops with OAuthFlowError: Protected resource metadata request failed: HTTP <status> where v1 fell through to the origin, so a server without that document must answer 404 for it.

OAuth client requests offline_access and sends prompt=consent

When the authorization server's metadata lists offline_access in scopes_supported and the client's grant_types include refresh_token (the default), the client now appends offline_access to the requested scope (SEP-2207); whenever offline_access is in the scope, the authorization request also carries prompt=consent. Unchanged v1 code sends a different authorization URL:

Before (v1):

https://as.example.com/authorize?...&scope=read

After (v2):

https://as.example.com/authorize?...&scope=read+offline_access&prompt=consent

End users see the provider's consent screen on every authorization instead of being re-authorized silently, an authorization server that ties refresh tokens to offline_access now issues them, and one that does not allow offline_access for the client can fail the flow with invalid_scope.

To send the v1 authorization request again, remove the refresh_token grant:

from pydantic import AnyUrl

from mcp.shared.auth import OAuthClientMetadata

client_metadata = OAuthClientMetadata(
    client_name="my-client",
    redirect_uris=[AnyUrl("http://localhost:3000/callback")],
    grant_types=["authorization_code"],
)

This also drops refresh_token from the Dynamic Client Registration payload, so authorization servers that gate refresh tokens on the registered grants stop issuing them. If offline_access is already in the selected scope (from a WWW-Authenticate challenge or a scopes_supported list), prompt=consent is sent regardless of grant_types; there is no separate switch for it.

Dynamic Client Registration now sends application_type

The registration request now always includes application_type (SEP-837), defaulting to "native", the correct value for clients with loopback (localhost) redirect URIs. A browser-based client served from a non-local host must set "web", or an OIDC authorization server may reject its non-loopback redirect URIs:

from pydantic import AnyUrl

from mcp.shared.auth import OAuthClientMetadata

client_metadata = OAuthClientMetadata(
    redirect_uris=[AnyUrl("https://app.example.com/callback")],
    application_type="web",
)

Non-OIDC authorization servers ignore the parameter.

OAuthClientInformationFull no longer subclasses OAuthClientMetadata

OAuthClientMetadata (the registration request) and OAuthClientInformationFull (the registered-client record) are now siblings over a shared OAuthClientMetadataBase instead of the record subclassing the request. So isinstance(client_info, OAuthClientMetadata) is False, a record is not assignable where an OAuthClientMetadata is annotated, and validate_scope()/validate_redirect_uri() exist only on the record. On the record, client_id is now required, token_endpoint_auth_method widens to str | None, and redirect_uris is optional (it holds whatever an authorization server echoes). Code that treated a record as a request must name OAuthClientInformationFull, or OAuthClientMetadataBase where either model is acceptable:

Before (v1):

from pydantic import AnyUrl

from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata


def log_registration(metadata: OAuthClientMetadata) -> None:
    print(metadata.client_name, metadata.scope)


client_info = OAuthClientInformationFull(  # client_id defaulted to None
    redirect_uris=[AnyUrl("http://localhost:3000/callback")],
)
log_registration(client_info)  # a record IS-A request
assert isinstance(client_info, OAuthClientMetadata)

After (v2):

from pydantic import AnyUrl

from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadataBase


def log_registration(metadata: OAuthClientMetadataBase) -> None:  # request or record
    print(metadata.client_name, metadata.scope)


client_info = OAuthClientInformationFull(
    client_id="my-client",  # now required
    redirect_uris=[AnyUrl("http://localhost:3000/callback")],
)
log_registration(client_info)

On the server side, a custom OAuthAuthorizationServerProvider.register_client behind the SDK's registration endpoint now receives client_secret_expires_at=0 (RFC 7591's "never expires") rather than None when a secret is issued and ClientRegistrationOptions.client_secret_expiry_seconds is unset; treat 0 as no expiry, not as a timestamp.

Stricter client authentication at /token and /revoke

Two behavior changes affect authorization servers embedded via auth_server_provider= / create_auth_routes.

Failed client authentication at /token returns invalid_client. v1 answered every client-authentication failure at /token (unknown client_id; missing, wrong, or expired secret) with 401 unauthorized_client; v2 sends 401 invalid_client (RFC 6749 §5.2). Update tests, dashboards, or non-SDK clients that match on the error code.

Before (v1):

401 {"error":"unauthorized_client","error_description":"Invalid client_id"}

After (v2):

401 {"error":"invalid_client","error_description":"Invalid client_id"}

Secret-method client records with no stored secret are rejected. In v1, a client record whose token_endpoint_auth_method was client_secret_post or client_secret_basic but whose client_secret was None authenticated without a valid secret. v2 rejects it: /token returns 401 invalid_client and /revoke returns 401 unauthorized_client, both with "Client is registered for secret-based authentication but has no stored secret". Clients registered through /register are unaffected (secret-based registrations always receive a secret); hand-provisioned records need a stored secret or a public auth method.

Before (v1):

from pydantic import AnyUrl

from mcp.shared.auth import OAuthClientInformationFull

LEGACY_CLIENT = OAuthClientInformationFull(
    client_id="legacy-client",
    client_secret=None,  # no secret stored
    token_endpoint_auth_method="client_secret_post",  # but a secret-based method
    redirect_uris=[AnyUrl("http://localhost:1234/cb")],
)

After (v2):

from pydantic import AnyUrl

from mcp.shared.auth import OAuthClientInformationFull

LEGACY_CLIENT = OAuthClientInformationFull(
    client_id="legacy-client",
    token_endpoint_auth_method="none",  # public client, no secret expected
    redirect_uris=[AnyUrl("http://localhost:1234/cb")],
)

Stricter protocol validation and wire behavior

Server handler results are validated against the protocol schema

Server handler results are now validated against the negotiated protocol version's schema before they are sent. A non-conforming result no longer reaches the client: the server logs handler for '<method>' returned an invalid result with the pydantic error, and the client gets -32603 (INTERNAL_ERROR, "Handler returned an invalid result"). Validation runs at serialization, not construction, so a bad model still builds and fails only when a handler returns it.

The case most existing code hits is a hand-built Tool whose input schema lacks the required "type": "object" (schemas generated by @mcp.tool() already conform):

Before (v1):

from mcp.types import Tool

Tool(name="ping", inputSchema={})

After (v2):

from mcp.types import Tool

Tool(name="ping", input_schema={"type": "object"})

Client validates inbound traffic against the protocol schema

The client (Client, and ClientSession beneath it) validates server results against the negotiated protocol version's schema before parsing them. Spec-invalid output that v1's lenient parse accepted now raises pydantic.ValidationError from list_tools(), call_tool(), and the other request methods; for example a tool whose inputSchema (or outputSchema) is {}, since the spec requires "type": "object". There is no opt-out: catch pydantic.ValidationError around calls to servers you don't control, or fix the server.

Every outbound request now carries a _meta envelope; OpenTelemetry is on by default

Every request the SDK sends (client-to-server and server-to-client, at every negotiated protocol version) now includes params._meta, even where v1 sent no params at all; request ids also start at 1 instead of 0 and key order differs. No setting restores the v1 wire shape, so update fixtures, mock servers, and snapshot tests that assert on raw request bytes.

Before (v1): ClientSession against a 2025-11-25 server:

{"method":"ping","jsonrpc":"2.0","id":1}
{"method":"tools/list","jsonrpc":"2.0","id":2}

After (v2): the same ClientSession code (or Client(..., mode="legacy")) against the same server:

{"jsonrpc":"2.0","id":2,"method":"ping","params":{"_meta":{}}}
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"_meta":{}}}

The envelope is the carrier for OpenTelemetry trace propagation (SEP-414), which v2 enables by default: every server wraps each inbound message in a span and the client opens a span per outbound request, both under the mcp-python-sdk tracer. With no OpenTelemetry SDK configured these are no-ops. If your application already installs a global tracer provider, upgrading starts recording MCP client and server spans and injects a W3C traceparent into every outbound _meta, propagating your trace ids to the servers you call. To opt out, filter the mcp-python-sdk instrumentation scope in your span pipeline, or remove the server middleware as shown in OpenTelemetry; the client-side span and traceparent injection have no switch.

Testing utilities

create_connected_server_and_client_session removed

mcp.shared.memory.create_connected_server_and_client_session is gone. Client takes the MCPServer or lowlevel Server instance directly and connects in-process (see Testing).

Before (v1):

from mcp.shared.memory import create_connected_server_and_client_session

async with create_connected_server_and_client_session(server) as session:
    result = await session.call_tool("my_tool", {"x": 1})

After (v2):

from mcp import Client

async with Client(server) as client:
    result = await client.call_tool("my_tool", {"x": 1})

Client accepts the helper's keyword arguments (sampling_callback, list_roots_callback, logging_callback, message_handler, elicitation_callback, client_info, raise_exceptions, and read_timeout_seconds, now a float; see Timeouts take float seconds instead of timedelta). Request methods live on client, and client.session is the underlying ClientSession; Client's list_*() methods paginate with cursor= rather than params=PaginatedRequestParams(...) (see cursor parameter removed from ClientSession list methods).

Unlike the helper, which always ran the pre-2026 initialize handshake, Client(server) negotiates 2026-07-28 by default, so unchanged sampling, elicitation, roots, logging, and change-notification code behaves differently: see Behavior changes on v2's default connection. Also on that connection: every result's meta carries an io.modelcontextprotocol/serverInfo stamp, so whole-result assertions such as assert result == CallToolResult(...) fail until you clear result.meta or compare fields; send_ping() raises MCPError -32601 (Method not found), as do set_logging_level() and subscribe_resource()/unsubscribe_resource() against a lowlevel Server that handles them; and client-to-server progress notifications are not delivered, nor do handlers receive a progress_token (report_progress() delivers either way). Client(server, mode="legacy") restores the pre-2026 handshake and all of the above.

create_client_server_memory_streams() remains for driving a ClientSession by hand, but the streams it yields no longer expose anyio's send_nowait, receive_nowait, or statistics().

Deprecated v1 calls now warn (breaks warnings-as-errors test runs)

v2 still ships the APIs the 2026-07-28 spec retires, but using them now emits MCPDeprecationWarning, a UserWarning subclass exported from mcp: the Context logging helpers (await ctx.info(...) and friends), the sampling, roots, resource-subscription and client-to-server progress methods on the sessions, the lowlevel Server's on_set_logging_level=-style parameters (at construction), and a few SDK helpers; Deprecated features has the replacements. A test suite configured with filterwarnings = ["error"] therefore fails on unchanged v1 code. Inside an @mcp.tool() handler the raised warning surfaces as CallToolResult(is_error=True) with text Error executing tool <name>, and the MCPDeprecationWarning traceback appears only in the server log. Strict type checkers flag the same calls (reportDeprecated). Replace the calls, or keep the warnings visible but non-fatal with a default filter after the error entry:

[tool.pytest.ini_options]
filterwarnings = [
    "error",
    "default::mcp.MCPDeprecationWarning",
]

Behavior changes on v2's default connection

v2's Client (including the in-process Client(server) that replaces v1's test helper) negotiates the 2026-07-28 protocol by default, so the v1 patterns below change behavior even though the server code is untouched. Client(..., mode="legacy") reproduces v1's initialize handshake and restores each of them (see Protocol versions); connections negotiated at 2025-11-25 or earlier (a ClientSession you initialize() yourself, any 2025-era peer) are unaffected.

Server-initiated sampling, elicitation, and roots raise NoBackChannelError

A handler that reaches back to the client mid-request (ctx.elicit(), ctx.elicit_url(), ctx.session.create_message(), ctx.session.list_roots(), or any other ServerSession request helper) raises NoBackChannelError where the connection has no server-to-client channel: every 2026-07-28 connection (the protocol has no server-initiated requests), a legacy session against a stateless_http=True server, and ctx.elicit() / ctx.elicit_url() on a legacy session against a json_response=True server (their request-scoped channel is a single JSON body). In the last two cases the v1 call hung until a client-side timeout fired; v2 fails fast. So an unchanged v1 sampling or elicitation tool fails on its first Client(server) test, and sampling_callback= / elicitation_callback= / list_roots_callback= change nothing: no request ever reaches the client.

NoBackChannelError (mcp.shared.exceptions) subclasses MCPError with code -32600 and message Cannot send '<method>': this transport context has no back-channel for server-initiated requests.. Raised inside a handler it reaches the client as an MCPError, not CallToolResult(is_error=True) (see MCPError raised from an @mcp.tool() handler now surfaces as a JSON-RPC error). Notification helpers never raise it: undeliverable notifications are dropped instead (see the next two sections). UrlElicitationRequiredError is unaffected; it is an error response, not a request.

Two ways to migrate:

  • Keep the push behavior for now by connecting at a pre-2026 version: Client(server, mode="legacy", sampling_callback=..., elicitation_callback=..., list_roots_callback=...); a lowlevel ClientSession you initialize() yourself already negotiates a 2025-era version. Sampling and roots stay deprecated on this path (Deprecated features).
  • Port to the era-portable form: a Resolve(...)-backed parameter whose resolver returns Elicit, Sample, or ListRoots makes the server return the question instead of pushing it. The SDK elicits directly on a legacy connection and drives the InputRequiredResult round trip at 2026-07-28, with one handler body for both eras; see Elicitation, Sampling and roots, and Multi-round-trip requests.

Before (v1):

@mcp.tool()
async def book_table(date: str, ctx: Context) -> str:
    result = await ctx.elicit(f"Book a table for {date}?", schema=Confirmation)
    if result.action == "accept" and result.data.confirm:
        return f"Booked for {date}."
    return "No booking made."

After (v2), era-portable:

from typing import Annotated

from mcp.server.mcpserver import Elicit, ElicitationResult, Resolve


async def ask_to_confirm(date: str) -> Elicit[Confirmation]:
    return Elicit(f"Book a table for {date}?", Confirmation)


@mcp.tool()
async def book_table(date: str, answer: Annotated[ElicitationResult[Confirmation], Resolve(ask_to_confirm)]) -> str:
    if answer.action == "accept" and answer.data.confirm:
        return f"Booked for {date}."
    return "No booking made."

The client keeps the same elicitation_callback; it now answers the returned question.

Log messages are delivered only to requests that opt in

A migrated test whose logging_callback received every notifications/message in v1 now receives none: on a 2026-07-28 connection, protocol logging is a per-request opt-in, so ctx.info(...) / ctx.log(...) on MCPServer's Context and ctx.session.send_log_message(...) are dropped (debug-logged) unless the request's _meta carries io.modelcontextprotocol/logLevel, and then only messages at that level or above are sent. logging/setLevel is gone there too: client.set_logging_level(...) raises MCPError (-32601, Method not found) even when the server registered a handler. Opt in with log_level= on Client (a per-call meta= entry with the same key overrides it), or pin Client(server, mode="legacy", ...) to keep v1's push delivery. See Client callbacks.

from mcp import Client
from mcp.types import LOG_LEVEL_META_KEY

async with Client(server, logging_callback=on_log, log_level="info") as client:
    await client.call_tool("chatty", {})  # on_log receives info and above
    await client.call_tool("chatty", {}, meta={LOG_LEVEL_META_KEY: "debug"})  # this call: debug and above

Change notifications travel only on subscriptions/listen streams

On a 2026-07-28 connection the v1 helpers ctx.session.send_tool_list_changed(), send_prompt_list_changed(), send_resource_list_changed(), and send_resource_updated(uri) are dropped with a debug log, so a ported Client(server) test's message_handler never sees them: there the four change notifications reach a client only through a subscriptions/listen stream it opened (client.listen(...)). Their 2026-07-28 counterparts are await ctx.notify_tools_changed() / notify_prompts_changed() / notify_resources_changed() / notify_resource_updated(uri) on MCPServer's Context (Subscriptions); neither the helper nor its counterpart reaches the other era's clients, so to notify everyone call both (Serving legacy clients).

Before (v1):

from mcp.server.fastmcp import Context, FastMCP

mcp = FastMCP("Dynamic Tools")


@mcp.tool()
async def register_plugin(name: str, ctx: Context) -> str:
    # ... register the plugin's tools ...
    await ctx.session.send_tool_list_changed()
    return f"Plugin '{name}' registered"

After (v2):

from mcp.server import MCPServer
from mcp.server.mcpserver import Context

mcp = MCPServer("Dynamic Tools")


@mcp.tool()
async def register_plugin(name: str, ctx: Context) -> str:
    # ... register the plugin's tools ...
    await ctx.notify_tools_changed()  # 2026-07-28 clients (subscriptions/listen)
    await ctx.session.send_tool_list_changed()  # legacy (<= 2025-11-25) clients
    return f"Plugin '{name}' registered"

On a lowlevel Server, publish to your own SubscriptionBus (await bus.publish(ToolsListChanged()) from mcp.server.subscriptions) served by ListenHandler(bus) on on_subscriptions_listen=; see the low-level composition.

Need Help?

If you encounter issues during migration:

  1. Check the API Reference for current signatures
  2. Review the examples
  3. Open an issue on GitHub if something here is wrong or missing