Review-feedback round on the conformance burn-down: - Cancelled requests no longer leave the legacy streamable-HTTP POST hanging. The dispatcher emits a RequestSettled marker when a handler is cancelled without producing a response; the transport consumes it by closing the per-request stream, so the POST's SSE stream terminates without a response frame and JSON-response mode completes with 204 No Content (the client treats 202/204 alike). Per-request streams are released instead of leaking until session teardown, and a handler that survives the cancellation still delivers its normal response. The marker is type-visible on the dispatcher write stream and is stripped by every serializing transport, so it can never appear on a wire. - A bearer token whose audience cannot be canonicalized (out-of-range or non-numeric port) is now rejected with the standard 401 invalid_token instead of raising through the auth middleware as a 500. - The bundled authorization server's /register now accepts only https redirect URIs or http on a loopback host; other schemes on loopback hosts (ftp, ws, javascript, custom) are rejected. - OAuth client scope selection falls back to the caller-configured OAuthClientMetadata.scope when neither the WWW-Authenticate challenge nor protected-resource metadata names scopes, matching the TypeScript SDK, so the documented migration path works as written. - The cross-dispatcher contract that handler-raised MCPError subclasses surface to callers as plain MCPError is now pinned by an explicit test and documented; rehydrate with from_error when the subclass matters. - Docs: migration notes for the bearer-challenge wire-shape changes and the cancellation wire spellings; story READMEs updated to the landed error contract; strict-capabilities doc corrected to state that resources/unsubscribe is gated by the base resources capability only.
97 KiB
Migration Guide: v1 to v2
This guide covers the breaking changes introduced in v2 of the MCP Python SDK and how to update your code.
Overview
Version 2 of the MCP Python SDK introduces several breaking changes to improve the API, align with the MCP specification, and provide better type safety.
Breaking Changes
MCPServer.call_tool() returns CallToolResult
MCPServer.call_tool() now returns a CallToolResult (or an
InputRequiredResult when a multi-round tool requests further input). It previously
advertised Sequence[ContentBlock] | dict[str, Any] and leaked the internal
conversion shapes (a bare content sequence or a (content, structured_content)
tuple), forcing callers to re-assemble a CallToolResult themselves.
If you call MCPServer.call_tool() directly, read .content and
.structured_content off the returned CallToolResult instead of branching on
the result type.
MCPError raised from an @mcp.tool() handler now surfaces as a JSON-RPC error
Raising MCPError (or a subclass such as UrlElicitationRequiredError) inside
an @mcp.tool() handler now produces a top-level JSON-RPC error response with
the raised code, message, and data intact. Previously the tool wrapper
caught it like any other exception and returned CallToolResult(isError=True),
which discarded the error code and structured data.
MCPError carries ErrorData and is the SDK's protocol-error type — raise it
when the request itself should be rejected (missing client capability,
elicitation required, invalid parameters). For tool execution failures the
calling LLM should see and react to, raise any other exception or return
CallToolResult(is_error=True, ...) directly; that path is unchanged.
Unhandled handler exceptions return INTERNAL_ERROR; cancelled requests get no reply
An unhandled exception in a request handler now produces JSON-RPC error -32603
(INTERNAL_ERROR) with the opaque message "Internal server error". v1 returned
code 0 with str(exc) as the message, leaking handler internals to the peer; the
exception is still logged server-side via logger.exception. To send a specific
code and message, raise MCPError (unchanged); a pydantic ValidationError is
still mapped to INVALID_PARAMS. An MCPError subclass raised by a handler
(e.g. UrlElicitationRequiredError) reaches the caller as plain MCPError with
the same code, message, and data on every dispatch path — including the
in-process Client(server) — so catch MCPError and match on error.code
rather than on the subclass type.
A request cancelled via notifications/cancelled now receives no response at all,
per the spec's SHOULD. v1 answered the cancelled request with an error
(code=0, message="Request cancelled"). The sender's awaiting call already fails
with anyio cancellation when its scope is cancelled, so no reply is needed to
unblock it. On legacy streamable HTTP this means the cancelled request's POST SSE
stream now terminates without a response frame, and in JSON-response mode the POST
completes with 204 No Content.
A second initialize on an already-initialized session is rejected
A server that has already completed the initialize handshake on a connection now answers a
repeated initialize request on that same connection with JSON-RPC error -32600
(INVALID_REQUEST, message "Session already initialized") instead of re-running the handshake.
Previously the repeat was answered as a fresh handshake and silently overwrote the session's
recorded client_params and negotiated protocol version, so a check_capability call made after
that point answered against the second client's declared capabilities
(#2605).
No compliant client is affected: the spec makes initialization the first interaction of a session,
and the SDK's own ClientSession.initialize() is idempotent (a repeat call returns the first
result without sending anything). A peer that needs a fresh handshake should open a new
connection — on streamable HTTP, a POST without an Mcp-Session-Id header. This applies only to
the legacy (2025-11-25 and earlier) handshake; the 2026-07-28 protocol removes initialize
entirely.
streamablehttp_client removed
The deprecated streamablehttp_client function has been removed. Use streamable_http_client instead.
Before (v1):
from mcp.client.streamable_http import streamablehttp_client
async with streamablehttp_client(
url="http://localhost:8000/mcp",
headers={"Authorization": "Bearer token"},
timeout=30,
sse_read_timeout=300,
auth=my_auth,
) as (read_stream, write_stream, get_session_id):
...
After (v2):
import httpx
from mcp.client.streamable_http import streamable_http_client
# Configure headers, timeout, and auth on the httpx.AsyncClient
http_client = httpx.AsyncClient(
headers={"Authorization": "Bearer token"},
timeout=httpx.Timeout(30, read=300),
auth=my_auth,
follow_redirects=True,
)
async with http_client:
async with streamable_http_client(
url="http://localhost:8000/mcp",
http_client=http_client,
) as (read_stream, write_stream):
...
v1's internal client set follow_redirects=True; set it explicitly when supplying your own httpx.AsyncClient to preserve that behavior.
OAuth callback_handler returns AuthorizationCodeResult
The callback_handler passed to OAuthClientProvider now returns an AuthorizationCodeResult instead of a tuple[str, str | None] of (code, state). The new object adds an iss field so the client can validate the RFC 9207 authorization-response issuer (SEP-2468): when the redirect carries an iss query parameter it must match the authorization server's issuer, and a missing iss is rejected when the server advertised 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],
)
Forward the iss query parameter from the redirect so the validation can run: omitting it makes the flow fail with OAuthFlowError against servers that advertise authorization_response_iss_parameter_supported, and silently skips the check for servers that send iss without advertising it.
OAuth client refuses to authorize when AS metadata does not advertise S256 PKCE
The OAuth client now verifies PKCE support before starting the authorization-code grant, as the MCP authorization specification's Authorization Code Protection section requires. When an authorization-server metadata document was discovered and its code_challenge_methods_supported is absent (which the spec defines as "the authorization server does not support PKCE") or does not list S256 (the only method the SDK sends), the flow raises OAuthFlowError instead of redirecting to the authorization endpoint. The symptom is OAuthFlowError: Authorization server metadata does not include code_challenge_methods_supported; PKCE support cannot be verified. Previously the SDK never inspected the field and proceeded with an S256 challenge regardless.
When no authorization-server metadata document could be discovered at all, the flow proceeds as before — absence of a document is not evidence of non-support. Grants that never issue an authorization code (ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider) are unaffected. There is no SDK-side opt-out: an authorization server that supports S256 but omits the field from its published metadata needs that metadata fixed (RFC 8414 §2 defines code_challenge_methods_supported).
OAuth client no longer reads scopes_supported from AS metadata to choose a scope
The specification's scope-selection chain is two steps: the scope parameter from the WWW-Authenticate challenge, then scopes_supported from the Protected Resource Metadata document, otherwise the scope parameter is omitted. The SDK inserted an extra fallback between those two steps — the authorization-server metadata's scopes_supported — which over-requests (an authorization server may serve many resource servers, so its list is a superset of any one resource's) and caused real access_denied failures (#1307). That fallback is removed: when neither the challenge nor the PRM names scopes, the client now omits the scope parameter and lets the authorization server apply its defaults.
This also affects the SEP-2207 offline_access augmentation, which only fires once a base scope was selected: if the authorization server's scopes_supported was your only scope source, the client now sends no scope at all (not even offline_access) and the authorization server's defaults decide whether a refresh token is issued. In either case, if you relied on the removed fallback, pass an explicit scope on the OAuthClientMetadata you give to OAuthClientProvider. That explicit scope ranks below the spec's two sources (matching the TypeScript SDK): a scope on the WWW-Authenticate challenge or a PRM scopes_supported still wins, so the explicit value only takes effect when both are silent.
get_session_id callback removed from streamable_http_client
The get_session_id callback (third element of the returned tuple) has been removed from streamable_http_client. The function now returns a 2-tuple (read_stream, write_stream) instead of a 3-tuple.
If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers:
Before (v1):
from mcp.client.streamable_http import streamable_http_client
async with streamable_http_client(url) as (read_stream, write_stream, get_session_id):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
session_id = get_session_id() # Get session ID via callback
After (v2):
import httpx
from mcp.client.streamable_http import streamable_http_client
# Option 1: Simply ignore if you don't need the session ID
async with streamable_http_client(url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# Option 2: Capture session ID via httpx event hooks if needed
captured_session_ids: list[str] = []
async def capture_session_id(response: httpx.Response) -> None:
session_id = response.headers.get("mcp-session-id")
if session_id:
captured_session_ids.append(session_id)
http_client = httpx.AsyncClient(
event_hooks={"response": [capture_session_id]},
follow_redirects=True,
)
async with http_client:
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
session_id = captured_session_ids[0] if captured_session_ids else None
StreamableHTTPTransport parameters removed
The headers, timeout, sse_read_timeout, and auth parameters have been removed from StreamableHTTPTransport. Configure these on the httpx.AsyncClient instead (see example above).
Note: sse_client retains its headers, timeout, sse_read_timeout, and auth parameters — only the streamable HTTP transport changed.
StreamableHTTPTransport.protocol_version attribute removed
The transport no longer holds per-connection protocol state; era-dependent headers (e.g. MCP-Protocol-Version) are now supplied per-message by the session. If you were reading transport.protocol_version to learn the negotiated version, read session.protocol_version (or client.protocol_version on the high-level Client) instead.
The MCP_PROTOCOL_VERSION header-name constant has moved: import MCP_PROTOCOL_VERSION_HEADER from mcp.shared.inbound instead of MCP_PROTOCOL_VERSION from mcp.client.streamable_http.
terminate_windows_process removed
The deprecated mcp.os.win32.utilities.terminate_windows_process function has been
removed. Process termination is handled internally by the stdio_client context
manager; there is no replacement API. The Windows tree-termination helper
terminate_windows_process_tree no longer accepts a timeout_seconds argument —
the value was never used (Job Object termination is immediate).
stdio_client no longer kills children of a gracefully-exited server on POSIX
When a server exits on its own after stdio_client closes its stdin, background
child processes the server leaves behind are no longer killed on POSIX — their
lifetime is the server's business. The old behavior was a side effect of a shutdown
wait gated on the stdio pipes closing rather than on process exit: a child holding
an inherited pipe made a well-behaved server look hung, so its whole process tree
was killed. (That gating is an asyncio behavior specific to Python 3.11+ — on
Python 3.10 and the trio backend the old wait already resolved on process exit, so
the spurious kill never fired there.) A server that does not exit within the grace
period is still terminated
along with its entire process group. On Windows, children stay in the server's Job
Object and are still killed at shutdown — now deterministically when the job handle
is closed, rather than whenever the handle happened to be garbage-collected.
If you relied on stdio_client killing everything the server spawned, make the
server terminate its own children on shutdown (its stdin reaching EOF is the
shutdown signal), or clean up the process tree from the host application after
stdio_client exits.
Two related shutdown refinements: stdio_client now closes its end of the pipes
deterministically at shutdown, so a surviving child that keeps writing to an
inherited stdout receives EPIPE/SIGPIPE once the client is gone (previously the
pipe lingered until garbage collection); and a failed write to a server that is
still running now surfaces as a closed connection (CONNECTION_CLOSED) on the read
side instead of leaving requests waiting indefinitely.
terminate_posix_process_tree now requires the process to lead its own process
group (spawned with start_new_session=True); the getpgid() lookup and the
per-process terminate/kill fallback are gone. The win32 utilities logger is now
named mcp.os.win32.utilities (was client.stdio.win32).
WebSocket transport removed
The WebSocket transport has been removed: mcp.client.websocket.websocket_client, mcp.server.websocket.websocket_server, and the ws optional dependency extra (mcp[ws]) no longer exist. WebSocket was never part of the MCP specification. Use the streamable HTTP transport instead (mcp.client.streamable_http.streamable_http_client on the client, streamable_http_app() on the server), which supports bidirectional communication with server-to-client streaming over standard HTTP.
mcp.types moved to the mcp-types package
The protocol wire types now live in a standalone distribution, mcp-types, imported as
mcp_types. Its only runtime dependencies are pydantic and typing-extensions, so code
that just needs to (de)serialize MCP traffic can install it without the full SDK. The mcp package depends on mcp-types and
continues to re-export the type names at the top level, so from mcp import Tool is
unchanged. Only the mcp.types submodule and mcp.shared.version were removed. The
package's API reference is at mcp_types.
Why: keeping the wire types in their own package lets tooling and lightweight clients
depend on the protocol schema without pulling in httpx, starlette, uvicorn, and the
rest of the server/transport stack.
Before (v1):
from mcp.types import Tool, Resource
from mcp.shared.version import LATEST_PROTOCOL_VERSION
After (v2):
from mcp_types import Tool, Resource
from mcp_types.version import LATEST_PROTOCOL_VERSION
# Names `mcp` already re-exported at the top level are unchanged:
from mcp import Tool, Resource
Removed type aliases and classes
The following deprecated type aliases and classes have been removed from mcp_types:
| Removed | Replacement |
|---|---|
Content |
ContentBlock |
ResourceReference |
ResourceTemplateReference |
Cursor |
Use str directly |
MethodT |
Internal TypeVar, not intended for public use |
RequestParamsT |
Internal TypeVar, not intended for public use |
NotificationParamsT |
Internal TypeVar, not intended for public use |
Before (v1):
from mcp.types import Content, ResourceReference, Cursor
After (v2):
from mcp_types import ContentBlock, ResourceTemplateReference
# Use `str` instead of `Cursor` for pagination cursors
Field names changed from camelCase to snake_case
All Pydantic model fields in mcp_types now use snake_case names for Python attribute access. The JSON wire format is unchanged — serialization still uses camelCase via 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 |
Because populate_by_name=True is set, the old camelCase names still work as constructor kwargs (e.g., Tool(inputSchema={...}) is accepted), but attribute access must use snake_case (tool.input_schema).
Server handler results are validated against the protocol schema
Results returned from server handlers are now validated against the negotiated protocol version's schema before being sent. A result that does not conform raises on the server side and the client receives an INTERNAL_ERROR response. The case most existing code will hit is Tool.inputSchema: the spec requires it to contain "type": "object", so an empty {} is now rejected.
Client validates inbound traffic against the protocol schema
ClientSession now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into mcp_types models. Spec-invalid server output that the previous monolith parse tolerated may now raise pydantic.ValidationError from list_tools(), call_tool(), and similar calls. _meta remains the sanctioned place for result extras (and experimental for capability extras).
args parameter removed from ClientSessionGroup.call_tool()
The deprecated args parameter has been removed from ClientSessionGroup.call_tool(). Use arguments instead.
Before (v1):
result = await session_group.call_tool("my_tool", args={"key": "value"})
After (v2):
result = await session_group.call_tool("my_tool", arguments={"key": "value"})
cursor parameter removed from ClientSession list methods
The deprecated cursor parameter has been removed from the following ClientSession methods:
list_resources()list_resource_templates()list_prompts()list_tools()
Use params=PaginatedRequestParams(cursor=...) instead.
Before (v1):
result = await session.list_resources(cursor="next_page_token")
result = await session.list_tools(cursor="next_page_token")
After (v2):
from mcp_types import PaginatedRequestParams
result = await session.list_resources(params=PaginatedRequestParams(cursor="next_page_token"))
result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token"))
ClientSession.get_server_capabilities() replaced by era-neutral accessors
ClientSession now exposes the negotiated server metadata as properties: server_capabilities, server_info, instructions, and protocol_version. These are populated by whichever connection step ran (initialize() for ≤2025-11-25 servers, discover() for 2026-07-28+), and are None if none has — matching v1's get_server_capabilities(). The get_server_capabilities() method has been removed.
Before (v1):
capabilities = session.get_server_capabilities()
# server_info, instructions, protocol_version were not stored — had to capture initialize() return value
After (v2):
capabilities = session.server_capabilities
server_info = session.server_info
instructions = session.instructions
version = session.protocol_version
The raw handshake result is also retained: session.initialize_result is set after initialize() (≤2025-11-25 servers — including stateless_http=True servers, which still answer initialize); session.discover_result is set after discover() (2026-07-28+ servers). At most one is non-None.
On the high-level Client, client.server_capabilities, client.server_info, and client.protocol_version are non-nullable inside the context manager. client.instructions remains str | None since the server may omit it. (The lowlevel ClientSession still lets you call methods before any handshake, as in v1; Client always connects on enter — by default it probes server/discover and falls back to the initialize handshake.)
Client defaults to mode='auto'
In v1, connecting to a server always performed the initialize handshake. In v2, Client defaults to mode='auto': on enter it probes server/discover and, if the server doesn't support it, falls back to the initialize handshake. Pass mode='legacy' to force the initialize handshake and reproduce v1's byte-identical pre-2026 behavior, or pass a modern protocol-version string (e.g. mode='2026-07-28') to pin a version without probing.
For an in-process Client(server) (where server is a Server or MCPServer instance), mode='auto' dispatches calls directly through DirectDispatcher with no JSON-RPC framing. Pass mode='legacy' if you need the in-memory JSON-RPC transport that v1 used.
Client.send_ping() is deprecated (ping is removed in 2026-07-28); pin mode='legacy' if you need it.
Client gains an opt-in strict_capabilities flag
Client(..., strict_capabilities=True) makes the client reject, before any request reaches
the transport, a call to a method whose required server capability the connected server did
not advertise -- for example list_resources() against a server that only advertised
tools, or subscribe_resource() against a server whose resources capability does not set
subscribe. The rejection is an MCPError with code -32601 (METHOD_NOT_FOUND), the same
code a compliant server returns for an unadvertised capability, so existing error handling is
unaffected.
The default is False and is unchanged from v1: every request is sent and the server's
answer is surfaced. This mirrors the TypeScript SDK's enforceStrictCapabilities option, and
the same keyword-only parameter exists directly on ClientSession(..., strict_capabilities=)
for low-level users -- Client just forwards it. The check reads
client.server_capabilities, so a bare version pin (mode="2026-07-28" with no
prior_discover=) -- where the client never asks the server what it supports and so every
capability-gated method would be rejected -- is refused at construction with a ValueError
that names the fix: supply prior_discover= or use mode="auto". Which method needs which
capability is exported as mcp_types.methods.SERVER_CAPABILITY_REQUIREMENTS.
Unhandled elicitation/create returns -32602; unhandled roots/list returns -32601
When a server sends elicitation/create to a client that registered no elicitation_callback, or roots/list to a client that registered no list_roots_callback, the SDK still answers on the client's behalf with a JSON-RPC error. In v1 both answers used code -32600 (INVALID_REQUEST). They now use the code the spec assigns to each case: elicitation/create is answered with -32602 (INVALID_PARAMS), per the elicitation error-handling section (a client with no callback declared no elicitation modes, and a request for an undeclared mode MUST be answered with -32602), and roots/list is answered with -32601 (METHOD_NOT_FOUND), per the roots error-handling section. The error messages (Elicitation not supported, List roots not supported) are unchanged, and sampling/createMessage without a sampling_callback still answers -32600 — the spec assigns no code to that case.
Server-side code that branched on error.code == INVALID_REQUEST to detect a client without elicitation or roots support should switch to INVALID_PARAMS and METHOD_NOT_FOUND respectively — or, better, check the client's declared capabilities before sending, which is the condition these codes describe.
InputRequiredResult handling differs between Client and ClientSession
For protocol 2026-07-28, tools/call, prompts/get, and resources/read may return an InputRequiredResult asking the client to supply additional input (sampling, elicitation, roots) and retry.
On the high-level Client, call_tool, get_prompt, and read_resource resolve this automatically: they dispatch each requested input to the matching callback (sampling_callback, elicitation_callback, list_roots_callback) and retry until a final result is returned, so the call still returns the bare CallToolResult / GetPromptResult / ReadResourceResult. The round limit is Client(input_required_max_rounds=...) (default 10). Earlier v2 prereleases exposed an allow_input_required parameter on these Client methods; that parameter has been removed. For manual control use client.session.call_tool(..., allow_input_required=True). Note that read_timeout_seconds now bounds each underlying round, not the whole loop; wrap the call in anyio.fail_after(...) for a whole-loop bound.
On ClientSession, call_tool / get_prompt / read_resource still return the bare result and raise RuntimeError if the server requests input. Pass allow_input_required=True to receive the InputRequiredResult instead, then drive the loop yourself with input_responses= / request_state=. ClientSessionGroup.call_tool accepts the same flag.
call_tool mirrors x-mcp-header arguments into Mcp-Param-* headers (SEP-2243)
For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an x-mcp-header annotation. When a tool the client has listed is called, each annotated argument is mirrored into an Mcp-Param-<name> request header (string verbatim, integer as decimal, boolean as true/false, base64-sentinel-wrapped when not header-safe; null/absent arguments are omitted). The argument is also left in the request body. list_tools caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no Mcp-Param-* headers. Other transports ignore the annotation.
McpError renamed to MCPError
The McpError exception class has been renamed to MCPError for consistent naming with the MCP acronym style used throughout the SDK.
Before (v1):
from mcp.shared.exceptions import McpError
try:
result = await session.call_tool("my_tool")
except McpError as e:
print(f"Error: {e.error.message}")
After (v2):
from mcp.shared.exceptions import MCPError
try:
result = await session.call_tool("my_tool")
except MCPError as e:
print(f"Error: {e.message}")
MCPError is also exported from the top-level mcp package:
from mcp import MCPError
The constructor signature also changed — it now takes code, message, and optional data directly instead of wrapping an ErrorData:
Before (v1):
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, INVALID_REQUEST
raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input"))
After (v2):
from mcp.shared.exceptions import MCPError
from mcp_types import INVALID_REQUEST
raise MCPError(INVALID_REQUEST, "bad input")
# or, if you already have an ErrorData:
raise MCPError.from_error_data(error_data)
FastMCP renamed to MCPServer
The FastMCP class has been renamed to MCPServer to better reflect its role as the main server class in the SDK. This is a simple rename with no functional changes to the class itself.
Before (v1):
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Demo")
After (v2):
from mcp.server.mcpserver import MCPServer, Context
mcp = MCPServer("Demo")
Context is the type annotation for the ctx parameter injected into tools, resources, and prompts (see get_context() removed below).
All submodules under mcp.server.fastmcp.* are now under mcp.server.mcpserver.* with the same structure. Common imports:
Image,Audio— frommcp.server.mcpserver(or.utilities.types)UserMessage,AssistantMessage— frommcp.server.mcpserver.prompts.baseToolError,ResourceError— frommcp.server.mcpserver.exceptions
mount_path parameter removed from MCPServer
The mount_path parameter has been removed from MCPServer.__init__(), MCPServer.run(), MCPServer.run_sse_async(), and MCPServer.sse_app(). It was also removed from the Settings class.
This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard root_path mechanism. When using Starlette's Mount("/path", app=mcp.sse_app()), Starlette automatically sets root_path in the ASGI scope, and the SseServerTransport uses this to construct the correct message endpoint path.
Transport-specific parameters moved from MCPServer constructor to run()/app methods
Transport-specific parameters have been moved from the MCPServer constructor to the run(), sse_app(), and streamable_http_app() methods. This provides better separation of concerns - the constructor now only handles server identity and authentication, while transport configuration is passed when starting the server.
Parameters moved:
host,port- HTTP server bindingsse_path,message_path- SSE transport pathsstreamable_http_path- StreamableHTTP endpoint pathjson_response,stateless_http- StreamableHTTP behaviorevent_store,retry_interval- StreamableHTTP event handlingtransport_security- DNS rebinding protection
Before (v1):
from mcp.server.fastmcp import FastMCP
# Transport params in constructor
mcp = FastMCP("Demo", json_response=True, stateless_http=True)
mcp.run(transport="streamable-http")
# Or for SSE
mcp = FastMCP("Server", host="0.0.0.0", port=9000, sse_path="/events")
mcp.run(transport="sse")
After (v2):
from mcp.server.mcpserver import MCPServer
# Transport params passed to run()
mcp = MCPServer("Demo")
mcp.run(transport="streamable-http", json_response=True, stateless_http=True)
# Or for SSE
mcp = MCPServer("Server")
mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events")
For mounted apps:
When mounting in a Starlette app, pass transport params to the app methods:
# Before (v1)
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("App", json_response=True)
app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())])
# After (v2)
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("App")
app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))])
Note: DNS rebinding protection is automatically enabled when host is 127.0.0.1, localhost, or ::1. This now happens in sse_app() and streamable_http_app() instead of the constructor.
If you were mutating these via mcp.settings after construction (e.g., mcp.settings.port = 9000), pass them to run() / sse_app() / streamable_http_app() instead — these fields no longer exist on Settings. The debug and log_level parameters remain on the constructor.
Streamable HTTP: lifespan now entered once at manager startup
When serving streamable HTTP (stateful or stateless_http=True), the server's lifespan context manager is now entered once when StreamableHTTPSessionManager.run() starts, and the resulting state is shared across all sessions and requests. Previously each session (stateful) or each request (stateless) entered and exited lifespan independently.
Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of per session/request. If your lifespan was acquiring per-connection resources, move that acquisition into the handler body; per-connection cleanup belongs on the connection's exit_stack (the public surface for reaching it from high-level @mcp.tool() handlers is being finalised as part of the public-surface review).
Server.run() no longer takes a stateless flag; StatelessModeNotSupported removed
The stateless: bool parameter on the lowlevel Server.run() has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready Connection per request), not a flag the loop driver inspects.
StatelessModeNotSupported has been removed. Server-initiated requests that have no channel to travel on now raise NoBackChannelError (an MCPError subclass) — the same exception regardless of why the channel is absent. If you were catching StatelessModeNotSupported, catch NoBackChannelError instead.
MCPServer.get_context() removed
MCPServer.get_context() has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.
If you were calling get_context() from inside a tool/resource/prompt: use the ctx: Context parameter injection instead.
Before (v1):
@mcp.tool()
async def my_tool(x: int) -> str:
ctx = mcp.get_context()
await ctx.info("Processing...")
return str(x)
After (v2):
from mcp.server.mcpserver import Context
@mcp.tool()
async def my_tool(x: int, ctx: Context) -> str:
await ctx.info("Processing...")
return str(x)
MCPServer.call_tool(), read_resource(), get_prompt() now accept a context parameter
MCPServer.call_tool(), MCPServer.read_resource(), and MCPServer.get_prompt() now accept an optional context: Context | None = None parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit context, a Context with no active request is constructed for you — tools that don't use ctx work normally, but any attempt to use ctx.session, ctx.request_id, etc. will raise.
The internal layers (ToolManager.call_tool, Tool.run, Prompt.render, ResourceTemplate.create_resource, etc.) now require context as a positional argument.
Resource not found returns -32602 and resource lookups raise typed exceptions (SEP-2164)
Reading a missing resource now returns JSON-RPC error code -32602 (invalid params) with the requested URI in error.data ({"uri": ...}), per SEP-2164. Previously the server returned code 0 with no data. Clients can now reliably distinguish not-found from other errors; a template handler that raises ResourceNotFoundError (from mcp.server.mcpserver.exceptions) produces this same response.
The underlying lookups now raise typed exceptions instead of ValueError. ResourceManager.get_resource() raises ResourceNotFoundError when no resource or template matches the URI, and ResourceTemplate.create_resource() raises ResourceError when the template function fails. Neither subclasses ValueError, so callers catching ValueError should switch to ResourceNotFoundError / ResourceError (both importable from mcp.server.mcpserver.exceptions; ResourceNotFoundError subclasses ResourceError).
Resource templates: matching behavior changes
Resource template matching has been rewritten with RFC 6570 support. Several behaviors have changed:
Path-safety checks applied by default. Extracted parameter values
containing .. as a path component, a null byte, or looking like an
absolute path (/etc/passwd, C:\Windows) now cause the read to
fail — the client receives an "Unknown resource" error and template
iteration stops, so a strict template's rejection does not fall
through to a later permissive template. This is checked on the
decoded value, so ..%2Fetc, %2E%2E, and %00 are caught too.
Note that .. is only flagged as a standalone path component, so
values like v1.0..v2.0 or HEAD~3..HEAD are unaffected.
If a parameter legitimately needs to receive absolute paths or traversal sequences, exempt it:
from mcp.server.mcpserver import ResourceSecurity
@mcp.resource(
"inspect://file/{+target}",
security=ResourceSecurity(exempt_params={"target"}),
)
def inspect_file(target: str) -> str: ...
Template literals and structural delimiters match exactly. The
previous matcher built a regex without escaping, so . matched any
character and simple {var} swallowed ?, #, &, and ,. Now
data://v1.0/{id} no longer matches data://v1X0/42, and
api://{id} no longer matches api://foo?x=1 — use api://{id}{?x}
to capture the query parameter.
{var} now matches an empty value. A simple expression captures
zero or more characters, so tickets://{ticket_id} now matches
tickets:// with ticket_id="" (v1.x's [^/]+ regex required at
least one). This makes match round-trip expand for empty values — RFC 6570
expands an empty string to nothing — but handlers that assumed a
non-empty value should validate it explicitly.
Template syntax errors surface at decoration time. Unclosed
braces, duplicate variable names, and unsupported syntax raise
InvalidUriTemplate when the decorator runs rather than re.error
on first match. Two variables with no literal between them are also
rejected — matching cannot tell where one ends and the next begins —
so {name}{+path} raises. Write {name}/{+path}, or use an operator
that emits its own delimiter: {+path}{.ext} is fine because the .
operator contributes a literal . between the two. A handler
parameter bound to a query variable in the template's trailing
{?...}/{&...} run — the variables match() treats as optional,
listed by UriTemplate.query_variable_names — must declare a Python
default: a client may omit those, so a handler that requires one now
raises ValueError when the decorator runs instead of failing on the
first request that leaves it out. (A {&...} expression with no
preceding {?...} is not in that run: it is matched strictly, may
not be omitted, and needs no default.)
Static URIs with Context-only handlers now error. A non-template
URI paired with a handler that takes only a Context parameter
previously registered but was silently unreachable (the resource
could never be read). This now raises ValueError at decoration time.
Context injection for static resources is not supported — use a
template with at least one variable or access context through other
means.
See URI templates for the full template syntax, security configuration, and filesystem safety utilities.
Registering lowlevel handlers from MCPServer
MCPServer does not expose public APIs for subscribe_resource, unsubscribe_resource, or set_logging_level handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods:
Before (v1):
@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage]
async def handle_set_logging_level(level: str) -> None:
...
mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage]
In v2, the lowlevel Server supports arbitrary request handlers directly via add_request_handler (the decorator methods are gone; handlers are otherwise constructor-only). From MCPServer, access it via _lowlevel_server:
After (v2):
from mcp.server import ServerRequestContext
from mcp_types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams
async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult:
...
return EmptyResult()
async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult:
...
return EmptyResult()
mcp._lowlevel_server.add_request_handler("logging/setLevel", SetLevelRequestParams, handle_set_logging_level) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequestParams, handle_subscribe) # pyright: ignore[reportPrivateUsage]
_lowlevel_server is private and may change. A public way to register these handlers on MCPServer is planned; until then, use this workaround or use the lowlevel Server directly.
MCPServer's Context logging: message renamed to data, extra removed
On the high-level Context object (mcp.server.mcpserver.Context), log(), .debug(), .info(), .warning(), and .error() now take data: Any instead of message: str, matching the MCP spec's LoggingMessageNotificationParams.data field which allows any JSON-serializable value. The extra parameter has been removed — pass structured data directly as data.
The lowlevel ServerSession.send_log_message(data: Any) already accepted arbitrary data and is unchanged.
Context.log() also now accepts all eight RFC-5424 log levels (debug, info, notice, warning, error, critical, alert, emergency) via the LoggingLevel type, not just the four it previously allowed.
# Before
await ctx.info("Connection failed", extra={"host": "localhost", "port": 5432})
await ctx.log(level="info", message="hello")
# After
await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432})
await ctx.log(level="info", data="hello")
Positional calls (await ctx.info("hello")) are unaffected.
Context.elicit() schema gate validates the rendered schema
Context.elicit() (and elicit_with_validation()) now render the schema first and validate each property against the spec's PrimitiveSchemaDefinition, raising TypeError at the call site for anything outside it. Optional[T] fields render as {"type": ...} with the field omitted from required (previously the non-spec anyOf shape). A bare list[str] field is rejected because it renders without the required enum items; use list[Literal[...]] or list[str] with json_schema_extra supplying the items. Unions of multiple primitives (e.g. int | str) and nested models are rejected.
ServerSession.elicit_form() takes a typed ElicitRequestedSchema
ServerSession.elicit_form() (and the deprecated elicit() alias, and ClientPeer.elicit_form())
now take an mcp_types.ElicitRequestedSchema -- a Pydantic model of the spec's restricted
requested-schema subset -- instead of an arbitrary dict[str, Any]. ElicitRequestedSchema was
previously a TypeAlias for dict[str, Any]; it is now that model. A schema with a nested-object
property, an array-of-objects property, or an anyOf union is rejected at construction.
Why: the spec restricts form-mode requested schemas to flat objects with primitive-typed properties only ("complex nested structures, arrays of objects ... are intentionally not supported"). Typing the send side makes a non-conforming schema impossible to construct rather than silently forwarded.
How to migrate: build the model in place of the dict, or validate an existing JSON Schema dict:
from mcp_types import BooleanSchema, ElicitRequestedSchema, StringSchema
await ctx.session.elicit_form(
"Choose a username.",
ElicitRequestedSchema(
properties={"username": StringSchema(type="string"), "newsletter": BooleanSchema(type="boolean")},
required=["username"],
),
)
# Or, if you already have a JSON Schema dict:
await ctx.session.elicit_form("Choose a username.", ElicitRequestedSchema.model_validate(my_schema))
The high-level Context.elicit() / elicit_with_validation() path, which generates the schema
from a Pydantic model class, is unchanged. The wire type ElicitRequestFormParams.requested_schema
is still a plain dict[str, Any]: the client's inbound parsing deliberately tolerates
non-conforming schemas so older servers (which emit anyOf for Optional form fields) still
reach the elicitation callback.
Replace RootModel by union types with TypeAdapter validation
The following union types are no longer RootModel subclasses:
ClientRequestServerRequestClientNotificationServerNotificationClientResultServerResultJSONRPCMessage
This means you can no longer access .root on these types or use model_validate() directly on them. Instead, use the provided TypeAdapter instances for validation.
Before (v1):
from mcp.types import ClientRequest, ServerNotification
# Using RootModel.model_validate()
request = ClientRequest.model_validate(data)
actual_request = request.root # Accessing the wrapped value
notification = ServerNotification.model_validate(data)
actual_notification = notification.root
After (v2):
from mcp_types import client_request_adapter, server_notification_adapter
# Using TypeAdapter.validate_python()
request = client_request_adapter.validate_python(data)
# No .root access needed - request is the actual type
notification = server_notification_adapter.validate_python(data)
# No .root access needed - notification is the actual type
The same applies when constructing values — the wrapper call is no longer needed:
Before (v1):
await session.send_notification(ClientNotification(InitializedNotification()))
await session.send_request(ClientRequest(PingRequest()), EmptyResult)
After (v2):
await session.send_notification(InitializedNotification())
await session.send_request(PingRequest(), EmptyResult)
Available adapters:
| Union Type | Adapter |
|---|---|
ClientRequest |
client_request_adapter |
ServerRequest |
server_request_adapter |
ClientNotification |
client_notification_adapter |
ServerNotification |
server_notification_adapter |
ClientResult |
client_result_adapter |
ServerResult |
server_result_adapter |
JSONRPCMessage |
jsonrpc_message_adapter |
All adapters are exported from mcp_types.
RequestParams.Meta replaced with RequestParamsMeta TypedDict
The nested RequestParams.Meta Pydantic model class has been replaced with a top-level RequestParamsMeta TypedDict. This affects the ctx.meta field in request handlers and any code that imports or references this type.
Key changes:
RequestParams.Meta(Pydantic model) →RequestParamsMeta(TypedDict)- Attribute access (
meta.progress_token) → Dictionary access (meta.get("progress_token")) progress_tokenfield changed fromProgressToken | None = NonetoNotRequired[ProgressToken]
In request context handlers:
# Before (v1)
@server.call_tool()
async def handle_tool(name: str, arguments: dict) -> list[TextContent]:
ctx = server.request_context
if ctx.meta and ctx.meta.progress_token:
await ctx.session.send_progress_notification(ctx.meta.progress_token, 0.5, 100)
# After (v2)
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
if ctx.meta and "progress_token" in ctx.meta:
await ctx.session.report_progress(0.5, 100)
...
server = Server("my-server", on_call_tool=handle_call_tool)
RequestContext type parameters simplified
The mcp.shared.context module has been removed. RequestContext is now split into ClientRequestContext (in mcp.client.context) and ServerRequestContext (in mcp.server.context).
RequestContext changes:
- The
RequestContext[SessionT, LifespanContextT, RequestT]generic no longer exists; useClientRequestContextorServerRequestContext[LifespanContextT, RequestT] - Server-specific fields (
lifespan_context,request,close_sse_stream,close_standalone_sse_stream) moved to newServerRequestContextclass inmcp.server.context
Before (v1):
from mcp.client.session import ClientSession
from mcp.shared.context import RequestContext, LifespanContextT, RequestT
# RequestContext with 3 type parameters
ctx: RequestContext[ClientSession, LifespanContextT, RequestT]
After (v2):
from mcp.client.context import ClientRequestContext
from mcp.server.context import ServerRequestContext, LifespanContextT, RequestT
# For client-side context (sampling, elicitation, list_roots callbacks)
ctx: ClientRequestContext
# For server-specific context with lifespan and request types
server_ctx: ServerRequestContext[LifespanContextT, RequestT]
ServerRequestContext is now a standalone dataclass — it no longer subclasses RequestContext[ServerSession]. It carries the same fields (session, request_id, meta, lifespan_context, request, close_sse_stream, close_standalone_sse_stream) plus new protocol_version: str, method: str, and raw params: Mapping[str, Any] | None fields (the last two let middleware read and rewrite the inbound message), so handler code is unaffected, but isinstance(ctx, RequestContext) checks and RequestContext[ServerSession] annotations need updating to ServerRequestContext.
The high-level Context class (injected into @mcp.tool() etc.) similarly dropped its ServerSessionT parameter: Context[ServerSessionT, LifespanContextT, RequestT] → Context[LifespanContextT, RequestT]. Both remaining parameters have defaults, so bare Context is usually sufficient:
Before (v1):
async def my_tool(ctx: Context[ServerSession, None]) -> str: ...
After (v2):
async def my_tool(ctx: Context) -> str: ...
# or, with an explicit lifespan type:
async def my_tool(ctx: Context[MyLifespanState]) -> str: ...
Version constants
SUPPORTED_PROTOCOL_VERSIONS is deprecated — it's now the union of HANDSHAKE_PROTOCOL_VERSIONS (initialize-handshake versions) and MODERN_PROTOCOL_VERSIONS (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to HANDSHAKE_PROTOCOL_VERSIONS. Named scalars derived from these tuples are now exported alongside them — LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION — so prefer those over indexing the tuples directly. All of these live in mcp_types.version (previously mcp.shared.version): from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS.
ProgressContext and progress() context manager removed
The mcp.shared.progress module (ProgressContext, Progress, and the progress() context manager) has been removed. This module had no real-world adoption — all users send progress notifications via Context.report_progress() directly.
Before (v1):
from mcp.shared.progress import progress
with progress(ctx, total=100) as p:
await p.progress(25)
After — use Context.report_progress() (recommended):
@server.tool()
async def my_tool(x: int, ctx: Context) -> str:
await ctx.report_progress(25, 100)
return "done"
Handler progress reporting: prefer ctx.report_progress() over manual progress_token
Reading ctx.meta["progress_token"] and calling session.send_progress_notification(token, ...) is specific to the JSON-RPC transport path. On the in-process modern path (DirectDispatcher / Client(server)), there is no wire token in _meta, so handlers that gate progress on the token's presence go silent.
ctx.report_progress(progress, total, message) works on every dispatcher: it sends a progress notification when a token is present and routes the update through the dispatcher's progress channel otherwise, no-opping only when the caller did not request progress at all. ServerSession.send_progress_notification(progress_token, ...) is now deprecated; see Progress API deprecations below.
create_connected_server_and_client_session removed
The create_connected_server_and_client_session helper in mcp.shared.memory has been removed. Use mcp.client.Client instead — it accepts a Server or MCPServer instance directly and handles the in-memory transport and session setup for you.
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.client import Client
async with Client(server) as client:
result = await client.call_tool("my_tool", {"x": 1})
Client accepts the same callback parameters the old helper did (sampling_callback, list_roots_callback, logging_callback, message_handler, elicitation_callback, client_info) plus raise_exceptions to surface server-side errors and mode to control version negotiation ('auto' by default; 'legacy' reproduces v1's initialize-only handshake).
If you need direct access to the underlying ClientSession and memory streams (e.g., for low-level transport testing), create_client_server_memory_streams is still available in mcp.shared.memory:
import anyio
from mcp.client.session import ClientSession
from mcp.shared.memory import create_client_server_memory_streams
async with create_client_server_memory_streams() as (client_streams, server_streams):
async with anyio.create_task_group() as tg:
tg.start_soon(lambda: server.run(*server_streams, server.create_initialization_options()))
async with ClientSession(*client_streams) as session:
await session.initialize()
...
tg.cancel_scope.cancel()
Resource URI type changed from AnyUrl to str
The uri field on resource-related types now uses str instead of Pydantic's AnyUrl. This aligns with the MCP specification schema which defines URIs as plain strings (uri: string) without strict URL validation. This change allows relative paths like users/me that were previously rejected.
Before (v1):
from pydantic import AnyUrl
from mcp.types import Resource
# Required wrapping in AnyUrl
resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validation
After (v2):
from mcp_types import Resource
# Plain strings accepted
resource = Resource(name="test", uri="users/me") # Works
resource = Resource(name="test", uri="custom://scheme") # Works
resource = Resource(name="test", uri="https://example.com") # Works
If your code passes AnyUrl objects to URI fields, convert them to strings:
# If you have an AnyUrl from elsewhere
uri = str(my_any_url) # Convert to string
Affected types:
Resource.uriReadResourceRequestParams.uriResourceContents.uri(and subclassesTextResourceContents,BlobResourceContents)SubscribeRequestParams.uriUnsubscribeRequestParams.uriResourceUpdatedNotificationParams.uri
The Client and ClientSession methods read_resource(), subscribe_resource(), and unsubscribe_resource() now only accept str for the uri parameter. If you were passing AnyUrl objects, convert them to strings:
# Before (v1)
from pydantic import AnyUrl
await client.read_resource(AnyUrl("test://resource"))
# After (v2)
await client.read_resource("test://resource")
# Or if you have an AnyUrl from elsewhere:
await client.read_resource(str(my_any_url))
Lowlevel Server: constructor parameters are now keyword-only
All parameters after name are now keyword-only. If you were passing version or other parameters positionally, use keyword arguments instead:
# Before (v1)
server = Server("my-server", "1.0")
# After (v2)
server = Server("my-server", version="1.0")
Lowlevel Server: type parameter reduced from 2 to 1
The Server class previously had two type parameters: Server[LifespanResultT, RequestT]. The RequestT parameter has been removed — handlers now receive typed params directly rather than a generic request type.
# Before (v1)
from typing import Any
from mcp.server.lowlevel.server import Server
server: Server[dict[str, Any], Any] = Server(...)
# After (v2)
from typing import Any
from mcp.server import Server
server: Server[dict[str, Any]] = Server(...)
Lowlevel Server: request_handlers and notification_handlers attributes removed
The public server.request_handlers and server.notification_handlers dictionaries have been removed. Handler registration is now done exclusively through constructor on_* keyword arguments. There is no public API to register handlers after construction.
# Before (v1) — direct dict access
from mcp.types import ListToolsRequest
if ListToolsRequest in server.request_handlers:
...
# After (v2) — no public access to handler dicts
# Use the on_* constructor params to register handlers
server = Server("my-server", on_list_tools=handle_list_tools)
If you need to check whether a handler is registered, track this yourself — there is currently no public introspection API.
Lowlevel Server: add_request_handler is now public and takes params_type
The private _add_request_handler(method, handler) escape hatch is now the public add_request_handler(method, params_type, handler), alongside a matching add_notification_handler. Each takes a params_type model that incoming params are validated against before the handler runs. A message with no params member validates {} against the model, so handlers never receive None: all-optional models arrive with their defaults, and models with required fields reject the message as INVALID_PARAMS before the handler runs (matching the Go SDK).
# Before (v1 / earlier v2 prereleases)
server._add_request_handler("custom/method", my_handler)
# After (v2)
server.add_request_handler("custom/method", MyParams, my_handler)
server.add_notification_handler("notifications/custom", MyNotifyParams, my_notify_handler)
Lowlevel Server: private _handle_* dispatch methods removed
Server._handle_message, _handle_request, and _handle_notification have been removed. The receive loop and per-message dispatch now live in JSONRPCDispatcher and ServerRunner, which Server.run() drives internally.
These were private, but some users subclassed Server and overrode them to intercept requests. Use middleware instead:
from typing import Any
from mcp.server import Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
async def logging_middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
print(f"handling {ctx.method}")
result = await call_next(ctx)
print(f"done {ctx.method}")
return result
server = Server("my-server", on_call_tool=...)
server.middleware.append(logging_middleware)
The method and the raw inbound params are ctx.method and ctx.params (params is None when the message carries none). Middleware runs before params validation and also wraps unknown methods. To rewrite the method or params before the handler runs, pass an adjusted context through: await call_next(replace(ctx, params=...)).
Lowlevel Server.run(raise_exceptions=True): transport errors no longer re-raised
raise_exceptions=True now only governs handler exceptions: an exception raised by an on_* handler propagates out of run(). The JSON-RPC error response is still written to the client first, regardless of the flag.
Previously it also re-raised exceptions yielded by the transport onto the read stream (e.g. JSON parse errors). Those are now debug-logged and dropped regardless of raise_exceptions. If you relied on run() exiting on a transport-level parse error, that no longer happens.
Lowlevel Server: decorator-based handlers replaced with constructor on_* params
The lowlevel Server class no longer uses decorator methods for handler registration. Instead, handlers are passed as on_* keyword arguments to the constructor.
Before (v1):
from mcp.server.lowlevel.server import Server
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}")],
is_error=False,
)
server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
Key differences:
- Handlers receive
(ctx, params)instead of the full request object or unpacked arguments.ctxis aServerRequestContextwithsessionandlifespan_contextfields (plusrequest_id,meta, etc. for request handlers).paramsis the typed request params object. - Handlers return the full result type (e.g.
ListToolsResult) rather than unwrapped values (e.g.list[Tool]). - The automatic
jsonschemainput/output validation that the oldcall_tool()decorator performed has been removed. There is no built-in replacement — if you relied on schema validation in the lowlevel server, you will need to validate inputs yourself in your handler.
Complete handler reference:
All handlers receive ctx: ServerRequestContext as the first argument. The second argument and return type are:
| 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 |
| — | on_ping |
RequestParams | None |
EmptyResult |
@server.progress_notification() |
on_progress |
ProgressNotificationParams |
None |
| — | on_roots_list_changed |
NotificationParams | None |
None |
All params and return types are importable from mcp_types.
Notification handlers:
from mcp.server import Server, ServerRequestContext
from mcp_types import ProgressNotificationParams
async def handle_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None:
print(f"Progress: {params.progress}/{params.total}")
server = Server("my-server", on_progress=handle_progress)
Lowlevel Server: automatic return value wrapping removed
The old decorator-based handlers performed significant automatic wrapping of return values. This magic has been removed — handlers now return fully constructed result types. If you want these conveniences, use MCPServer (previously FastMCP) instead of the lowlevel Server.
call_tool() — structured output wrapping removed:
The old decorator accepted several return types and auto-wrapped them into CallToolResult:
# Before (v1) — returning a dict auto-wrapped into structured_content + JSON TextContent
@server.call_tool()
async def handle(name: str, arguments: dict) -> dict:
return {"temperature": 22.5, "city": "London"}
# Before (v1) — returning a list auto-wrapped into CallToolResult.content
@server.call_tool()
async def handle(name: str, arguments: dict) -> list[TextContent]:
return [TextContent(type="text", text="Done")]
# After (v2) — construct the full result yourself
import json
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,
)
Note: params.arguments can be None (the old decorator defaulted it to {}). Use params.arguments or {} to preserve the old behavior.
read_resource() — content type wrapping removed:
The old decorator auto-wrapped Iterable[ReadResourceContents] (and the deprecated str/bytes shorthand) into TextResourceContents/BlobResourceContents, handling base64 encoding and mime-type defaulting:
# Before (v1) — Iterable[ReadResourceContents] auto-wrapped
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")]
# Before (v1) — str/bytes shorthand (already deprecated in v1)
@server.read_resource()
async def handle(uri: str) -> str:
return "file contents"
@server.read_resource()
async def handle(uri: str) -> bytes:
return b"\x89PNG..."
# After (v2) — construct TextResourceContents or BlobResourceContents yourself
import base64
async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
# Text content
return ReadResourceResult(
contents=[TextResourceContents(uri=str(params.uri), text="file contents", mime_type="text/plain")]
)
async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
# Binary content — you must base64-encode it yourself
return ReadResourceResult(
contents=[BlobResourceContents(
uri=str(params.uri),
blob=base64.b64encode(b"\x89PNG...").decode("utf-8"),
mime_type="image/png",
)]
)
list_tools(), list_resources(), list_prompts() — list wrapping removed:
The old decorators accepted bare lists and wrapped them into the result type:
# Before (v1)
@server.list_tools()
async def handle() -> list[Tool]:
return [Tool(name="my_tool", ...)]
# After (v2)
async def handle(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="my_tool", ...)])
Using MCPServer instead:
If you prefer the convenience of automatic wrapping, use MCPServer which still provides these features through its @mcp.tool(), @mcp.resource(), and @mcp.prompt() decorators. The lowlevel Server is intentionally minimal — it provides no magic and gives you full control over the MCP protocol types.
Lowlevel Server: request_context property removed
The server.request_context property has been removed. Request context is now passed directly to handlers as the first argument (ctx). The request_ctx module-level contextvar has been removed entirely.
Before (v1):
from mcp.server.lowlevel.server import request_ctx
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict):
ctx = server.request_context # or request_ctx.get()
await ctx.session.send_log_message(level="info", data="Processing...")
return [types.TextContent(type="text", text="Done")]
After (v2):
from mcp.server import ServerRequestContext
from mcp_types import CallToolRequestParams, CallToolResult, TextContent
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
await ctx.session.send_log_message(level="info", data="Processing...")
return CallToolResult(
content=[TextContent(type="text", text="Done")],
is_error=False,
)
ServerRequestContext: request-specific fields are now optional
ServerRequestContext now uses optional fields for request-specific data (request_id, meta, etc.) so it can be used for both request and notification handlers. In notification handlers, these fields are None.
from mcp.server import ServerRequestContext
# request_id, meta, etc. are available in request handlers
# but None in notification handlers
ServerSession is now a thin proxy (no longer a BaseSession)
ServerSession no longer subclasses BaseSession. It is now a small connection-scoped proxy that exposes send_request, send_notification, the typed convenience helpers (create_message, elicit_form, send_log_message, send_tool_list_changed, ...), client_params, protocol_version, and check_client_capability. The receive loop, initialize handling, and per-request task isolation that previously lived in ServerSession have moved to JSONRPCDispatcher and ServerRunner.
ServerSession is normally constructed for you by Server.run() and reached via ctx.session in handlers, so most servers are unaffected. If you were constructing or subclassing it directly:
Constructor change:
# Before (v1)
session = ServerSession(read_stream, write_stream, init_options, stateless=False)
# After (v2)
session = ServerSession(request_outbound, connection)
# where `request_outbound` is an Outbound and `connection` is a Connection
In practice, replace direct ServerSession use with Server.run(read_stream, write_stream, init_options) and let the framework wire it up.
Removed from mcp.server.session:
InitializationStateenum andServerSession._initialization_state— initialization tracking is now onConnection(connection.initializedis ananyio.Event,connection.client_paramsholds the init params).ServerRequestRespondertype alias.ServerSession.incoming_messagesstream — there is no longer a public stream of inbound messages to iterate. Register handlers via theon_*constructor params (oradd_request_handler) and useServer.middlewareto observe every inbound request and notification (initialize, unknown methods, validation failures, andnotifications/initializedincluded).ServerSession.__aenter__/__aexit__—ServerSessionis no longer an async context manager.- The private
_receive_loop,_received_request,_received_notification, and_handle_incomingoverrides — there is nothing to override onServerSessionanymore. To intercept inbound messages, useServer.middleware(see the_handle_*removal section above).
BaseSession / RequestResponder: server-side cancellation tracking removed
BaseSession._in_flight and the RequestResponder members that supported it (cancel(), the cancelled and in_flight properties, the on_complete constructor argument, and the internal CancelScope) have been removed. These existed to let ServerSession cancel a handler when a CancelledNotification arrived; ServerSession no longer drives a receive loop, so they were dead code. Inbound-cancellation handling for the server now lives in JSONRPCDispatcher.
BaseSession itself has since been removed entirely; see the next section.
ClientSession now runs on JSONRPCDispatcher; BaseSession removed
ClientSession's public surface is unchanged — same constructor, typed methods, manual initialize(), and async context-manager lifecycle — but BaseSession, the v1 receive loop underneath it, is removed with no shim. The engine now lives in JSONRPCDispatcher (mcp.shared.jsonrpc_dispatcher). To customize client behavior, use the ClientSession constructor callbacks, or pass a pre-built dispatcher via the new keyword-only dispatcher= constructor argument (e.g. a DirectDispatcher for in-process embedding).
Behavior changes:
- Callbacks and notifications now run concurrently. In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (
sampling,elicitation,roots) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sendsnotifications/cancelled(the callback is interrupted; no response is sent for the cancelled request). Notification callbacks (logging_callback,progress_callback,message_handler) may interleave, and aprogress_callbackmay run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reachmessage_handlerthe same way, and amessage_handlerthat raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves. - Timeouts: a timed-out or abandoned request is now followed by
notifications/cancelled, so the server stops the handler instead of leaving it running. - A raising request callback is answered with
INTERNAL_ERROR(-32603) and a generic message — the exception text is logged client-side, not sent; v1 flattened every callback exception toINVALID_PARAMS. For a specific error response, returnErrorData(unchanged) or raiseMCPError. One carve-out: pydantic'sValidationErroris still answered withINVALID_PARAMS, as in v1. send_requestbefore entering the context manager raisesRuntimeErrorimmediately; v1 wrote to the transport and hung until the timeout. After the connection has closed it raisesMCPError(CONNECTION_CLOSED) instead.send_notificationbefore entry still works.send_notificationno longer takesrelated_request_id, andsend_requestno longer acceptsServerMessageMetadata. No client transport ever serialized these hints; progress and response correlation viaprogressTokenand the request id is unaffected.- Client callbacks now receive
mcp.client.ClientRequestContext(itsrequest_idis always populated); the privatemcp.shared._context.RequestContextgeneric is deleted. Annotations spelledRequestContext[ClientSession]becomeClientRequestContext.
mcp.shared.session is now a compatibility module: ProgressFnT is re-exported (its home is mcp.shared.dispatcher), and RequestResponder remains as a typing-only stub so MessageHandlerFnT annotations keep importing. RequestResponder.respond() no longer exists.
Experimental Tasks support removed
Tasks (SEP-1686) have been removed from the MCP specification and are no longer part of this SDK. The mcp.client.experimental, mcp.server.experimental, mcp.shared.experimental, and mcp.server.lowlevel.experimental modules have been removed, along with the experimental properties on ClientSession, ServerSession, Server, and ServerRequestContext. The corresponding Task* types remain in mcp_types as types-only definitions.
Tasks are expected to return as a separate MCP extension in a future release.
Deprecations
Roots, Sampling, and Logging methods deprecated (SEP-2577)
SEP-2577 deprecates the Roots, Sampling, and Logging features as of the 2026-07-28 spec. The deprecation is advisory only: there are no wire-level changes, capability negotiation is unchanged, and every method keeps working for sessions negotiating 2025-11-25 and earlier.
The user-facing methods for these features now carry typing_extensions.deprecated, so type checkers, IDEs, and the runtime surface a deprecation warning where they are called:
- Sampling:
ServerSession.create_message(),ClientPeer.sample() - Roots:
ServerSession.list_roots(),ClientPeer.list_roots(),ClientSession.send_roots_list_changed(),Client.send_roots_list_changed() - Logging:
ServerSession.send_log_message(),Connection.log(),ClientSession.set_logging_level(),Client.set_logging_level(),mcp.server.context.Context.log()(the lowlevelContext), and theMCPServerContexthelperslog(),debug(),info(),warning(),error()
Registering a handler for a deprecated capability is deprecated too. The Server.__init__ parameters on_set_logging_level (Logging) and on_roots_list_changed (Roots) are now split out into a typing_extensions.deprecated overload, so passing either is flagged by type checkers and emits mcp.MCPDeprecationWarning at construction time. on_progress follows the same pattern (see below). The non-deprecated overload omits these parameters, so the common case stays warning-free.
The runtime warning is emitted as mcp.MCPDeprecationWarning, which subclasses UserWarning (not DeprecationWarning) so it is visible by default. To silence it, filter that category:
import warnings
from mcp import MCPDeprecationWarning
warnings.filterwarnings("ignore", category=MCPDeprecationWarning)
No migration is required during the deprecation window. New code should avoid building on these features, since they may be removed in a future spec version.
Progress API deprecations (2026-07-28)
The 2026-07-28 spec restricts notifications/progress to the server-to-client direction only — ProgressNotification is no longer in ClientNotification. Client.send_progress_notification() and ClientSession.send_progress_notification() now carry typing_extensions.deprecated and emit mcp.MCPDeprecationWarning at runtime. They continue to work against servers negotiating 2025-11-25 or earlier.
On the server side, ServerSession.send_progress_notification(progress_token, ...) is also deprecated. It takes an explicit progress token decoupled from any request's lifetime, so it can emit progress for a request that has already completed -- which the spec forbids ("Progress notifications MUST stop after completion"). Use Context.report_progress(progress, total, message) (or ServerSession.report_progress(...) from a low-level handler): it reports against the inbound request's own progress token, works on every dispatcher, no-ops when the caller did not request progress, and is closed with the request, so a late call after the handler has returned sends nothing.
# Before
token = ctx.meta.get("progress_token")
if token is not None:
await ctx.session.send_progress_notification(token, 0.5, total=1.0)
# After
await ctx.report_progress(0.5, total=1.0)
The deprecated method still works during the advisory window and emits mcp.MCPDeprecationWarning.
Bug Fixes
OAuth metadata URLs no longer gain a trailing slash
OAuthMetadata, ProtectedResourceMetadata, and OAuthClientMetadata now set
url_preserve_empty_path=True (Pydantic 2.12+). A path-less URL parsed from the wire keeps its
empty path instead of acquiring a trailing slash, so e.g. an issuer of https://as.example.com
round-trips as https://as.example.com rather than https://as.example.com/. This matters for
RFC 9207 / RFC 8414 issuer comparisons, which require simple string comparison (RFC 3986 §6.2.1).
URLs constructed in Python from an already-built AnyHttpUrl object are unaffected (they were
normalized at construction); only values parsed from strings/JSON change.
This also changes the wire form of OAuthClientMetadata.redirect_uris: a path-less redirect URI
passed as a string (e.g. redirect_uris=['http://localhost:8080']) now serializes as
http://localhost:8080 instead of http://localhost:8080/, and the client sends it verbatim in
the /authorize and token-exchange requests. RFC 6749 §3.1.2.3 requires authorization servers to
match redirect URIs by exact string comparison, so if you registered such a URI with a previous SDK
release (with the trailing slash) and the registration is persisted in TokenStorage, re-register
the client so the stored value matches what the SDK now transmits.
AuthSettings now sets url_preserve_empty_path=True for the same reason: a path-less
issuer_url (or resource_server_url) passed as a string keeps its empty path, so the authorization
server advertises issuer as https://as.example.com rather than https://as.example.com/ in its
metadata. Previously the trailing slash was added before the model saw the value, leaving the served
issuer inconsistent with what clients compare against under RFC 8414 / RFC 9207. Passing an
already-built AnyHttpUrl object still normalizes at construction; pass a string to get the
preserved form.
Bearer tokens are rejected unless their audience names this server
BearerAuthBackend now compares AccessToken.resource against AuthSettings.resource_server_url and answers any token whose RFC 8707 resource indicator does not name this server — including a token that carries no resource indicator at all — with 401 invalid_token. The comparison is canonical-URI equality, so a token issued for https://host/ is not accepted by a server at https://host/mcp.
To migrate, do exactly one of:
- Populate
AccessToken.resourcefrom the token'saudclaim (an introspection response'saud, or the decoded JWT'saud) in yourTokenVerifier. This is the recommended path and what the SDK's examples now show. - Set
AuthSettings(verifier_validates_audience=True)if your verifier already validates the audience itself and cannot surface it — for example a JWT library configured withaudience=that fails decoding on a mismatch. This tells the bearer gate not to repeat a check your verifier already performed. Do not set it just to make the401go away: with it set, the SDK performs no audience validation of its own at all.
Leaving resource_server_url=None continues to disable the check entirely (there is no audience to compare against), but a protected server should configure it: it is also the value published as RFC 9728 Protected Resource Metadata. If your authorization server does not support RFC 8707 resource indicators, your tokens will not carry an audience — audit that before opting out, because accepting audience-unbound tokens is what the MCP specification's audience-validation MUST exists to prevent.
RefreshToken gains an optional resource field so an OAuthAuthorizationServerProvider can propagate the original grant's audience binding through exchange_refresh_token; without it a refreshed access token would carry no audience and be rejected. BearerAuthBackend.__init__ gains a keyword-only resource_server_url: AnyHttpUrl | None = None, wired automatically from AuthSettings.enforced_audience; None (the default, and what the SDK passes when verifier_validates_audience is set) means no audience is enforced.
The error responses the bearer gate sends changed shape in the same release, following RFC 6750 §3:
- A request presenting no credentials at all (no
Authorization: Bearerheader) is now answered with a bare challenge —WWW-Authenticate: Bearer scope="…", resource_metadata="…"and an empty JSON body{}— instead oferror="invalid_token", error_description="Authentication required"in both the header and the body. RFC 6750 §3.1 says theerrorattribute should only appear when the request actually carried a token, so "no token" and "rejected token" are now distinguishable; anything matching on the literalAuthentication requiredstring or expecting a non-empty 401 body must be updated. - Every challenge — the
401s and the403— now advertises the configuredrequired_scopesin an RFC 6750scope="…"parameter, which spec-conformant clients (including this SDK's OAuth client) read as the first step of scope selection and step-up authorization. - The
error_descriptionstrings changed. A rejected token now states the failure:The access token is malformed or unknown,The access token has expired,The access token carries no audience claim, orThe access token was issued for a different resource(previously all of these — where they were rejected at all — saidAuthentication required). The403 insufficient_scopedescription is now the fixed stringThe access token lacks a required scopeinstead ofRequired scope: {scope}; the required scope moved to the machine-readablescope=challenge parameter. Treaterror_descriptionas human-readable prose, not a contract.
Bundled authorization server: RFC-correct redirect-URI handling
Two fixes to the optional bundled OAuth authorization server (the auth_server_provider= path).
The token endpoint now answers an authorization-code exchange whose redirect_uri does not match the one used at /authorize with error=invalid_grant instead of error=invalid_request. RFC 6749 §5.2 assigns this case to invalid_grant ("does not match the redirection URI used in the authorization request"). The exchange was already rejected with HTTP 400; only the error field changes.
The registration endpoint now rejects a redirect_uris entry that is neither HTTPS nor a loopback host (localhost, 127.0.0.1, or [::1]) with 400 invalid_client_metadata. Previously any well-formed URL — including cleartext http:// on a non-loopback host, javascript:, and data: — was accepted and stored. The MCP authorization specification's Communication Security section requires every redirect URI to be either localhost or HTTPS; the SDK accepts the three loopback forms OAuth 2.1 §8.4.2 names. Local development against http://localhost:*, http://127.0.0.1:*, or http://[::1]:* is unaffected. Note that this also rejects RFC 8252 private-use URI schemes (such as com.example.app:/callback): MCP restricts redirect URIs to HTTPS or loopback, which is stricter than vanilla OAuth allows for native apps. A redirect URI carrying a fragment component is also rejected (OAuth 2.1 §2.3). Query strings remain permitted.
Lowlevel Server: subscribe capability now correctly reported
Previously, the lowlevel Server hardcoded subscribe=False in resource capabilities even when a subscribe_resource() handler was registered. The subscribe capability is now dynamically set to True when an on_subscribe_resource handler is provided. Clients that previously didn't see subscribe: true in capabilities will now see it when a handler is registered, which may change client behavior.
Unknown request methods now return -32601 (Method not found)
In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with -32602 ("Invalid request parameters", empty data). Any method the receiver doesn't serve — unrecognized, or a spec method with no registered handler — is now answered with the JSON-RPC-specified -32601 ("Method not found"), with the method name in data, on both the server and the client side, in every initialization state. Update anything that matched on the old code for this case.
Extra fields on MCP types are no longer preserved
In v1, MCP protocol types were configured with extra="allow": unknown fields passed to a constructor or received from a peer were kept on the model and re-serialized on output.
In v2, MCP types silently ignore extra fields. Unknown constructor keyword arguments and unknown keys in wire data are dropped during validation — no error is raised, and the values do not round-trip:
from mcp_types import CallToolRequestParams
params = CallToolRequestParams(
name="my_tool",
arguments={},
unknown_field="value", # silently ignored, not stored
)
"unknown_field" in params.model_dump() # False
# _meta remains the supported place for custom data, per the MCP spec
params = CallToolRequestParams(
name="my_tool",
arguments={},
_meta={"my_custom_key": "value", "another": 123}, # OK, preserved
)
If you relied on extra fields round-tripping through MCP types, move that data into _meta.
New Features
OAuth client credentials are bound to their authorization server (SEP-2352)
Persisted OAuth client credentials are now bound to the authorization server that issued them: OAuthClientInformationFull records an issuer, set by the SDK after registration. When a server's protected resource metadata later points at a different authorization server, the client discards the bound credentials (and the old tokens) and re-registers with the new server instead of presenting one server's client_id to another. URL-based client IDs (CIMD) are portable and unaffected; credentials with no recorded issuer (pre-registered, or stored before this change) are left as-is. No API change for existing TokenStorage implementations - the issuer round-trips through the unchanged get_client_info/set_client_info.
Step-up authorization unions previously requested scopes (SEP-2350)
When a 403 insufficient_scope challenge triggers step-up re-authorization, the OAuth client now requests the union of the previously requested scopes and the newly challenged scopes, instead of replacing the scope with only the challenged ones. This keeps permissions granted for earlier operations from being dropped when a later operation escalates. No API change; the wider scope is sent automatically on the re-authorization request.
OAuth Dynamic Client Registration sends application_type (SEP-837)
OAuthClientMetadata now carries an application_type field that is sent during Dynamic Client Registration. It defaults to "native", which suits MCP clients that use loopback redirect URIs (CLI and desktop apps); browser-based clients served from a non-local host should set it to "web":
from mcp.shared.auth import OAuthClientMetadata
client_metadata = OAuthClientMetadata(
redirect_uris=["https://app.example.com/callback"],
application_type="web",
)
Under OIDC, omitting application_type defaults to "web", which an authorization server may reject for the localhost redirect URIs native clients use; sending "native" avoids that. Non-OIDC servers ignore the parameter.
Identity Assertion Authorization Grant for enterprise IdP flows (SEP-990)
The SDK now supports SEP-990's enterprise identity-provider policy controls. The client presents an Identity Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise IdP - to the MCP authorization server using the RFC 7523 jwt-bearer grant (grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, the ID-JAG as assertion), and receives an MCP access token. This matches the SEP-990 normative profile and interoperates with the other MCP SDKs. (Leg 1 - exchanging the user's IdP ID token for the ID-JAG against the IdP - is deployment-specific and out of scope for the SDK.) This is additive and opt-in on both sides; existing flows are unchanged.
On the client, IdentityAssertionOAuthProvider (in mcp.client.auth.extensions.identity_assertion) is an httpx.Auth that posts the jwt-bearer request. The ID-JAG is supplied lazily through an async assertion_provider(audience, resource) callback - audience is the authorization server's issuer (the ID-JAG aud) and resource is the MCP server's identifier (the ID-JAG resource claim):
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
async def fetch_id_jag(audience: str, resource: str) -> str:
# The ID-JAG must carry `audience` as `aud` and `resource` as its `resource` claim.
return await my_idp.issue_id_jag(audience=audience, resource=resource)
provider = IdentityAssertionOAuthProvider(
server_url="https://mcp.example.com/mcp",
storage=my_token_storage,
client_id="enterprise-mcp-client",
client_secret="enterprise-mcp-secret",
issuer="https://auth.example.com",
assertion_provider=fetch_id_jag,
)
SEP-990 §5.1 requires the client to authenticate; this SDK currently requires a shared secret, so client_secret is mandatory (token_endpoint_auth_method chooses client_secret_post (default) or client_secret_basic; the spec also permits private_key_jwt). The authorization server is configuration, not discovery: issuer is the AS the client is provisioned for, authorization-server metadata is fetched from that issuer's RFC 8414 well-known, and the resource server is never asked which AS to use - so a hostile resource server cannot redirect the ID-JAG or secret.
On the authorization server, set AuthSettings(identity_assertion_enabled=True) (or pass identity_assertion_enabled=True to create_auth_routes) and implement exchange_identity_assertion on your OAuthAuthorizationServerProvider. The method receives an IdentityAssertionParams (the ID-JAG assertion, requested scopes, and request resource) and returns a plain RFC 6749 OAuthToken. The flag gates both metadata advertisement and the token endpoint: when off, /token rejects the grant with unsupported_grant_type even if the provider implements the hook. When on, the metadata advertises the jwt-bearer grant and the urn:ietf:params:oauth:grant-profile:id-jag profile in authorization_grant_profiles_supported (the discovery mechanism per ext-auth §6).
The implementation is responsible for validating the assertion per RFC 7523 §3 and SEP-990 §5.1 - verify the signature/iss/exp/typ, require aud to be this AS, require the ID-JAG's client_id claim to match the authenticated client, audience-restrict the issued token to the ID-JAG's resource claim (not the client-controlled request resource), and derive scopes from the ID-JAG rather than granting the request verbatim. See examples/snippets/servers/identity_assertion_server.py, which fails closed. Two hardening points are enforced by the SDK: the handler rejects clients without a stored secret before calling the hook (and ClientAuthenticator itself now refuses a secret-based auth method registered without a secret), and Dynamic Client Registration refuses the jwt-bearer grant so the ID-JAG flow requires a pre-registered confidential client.
2025-11-25 and 2026-07-28 protocol fields modeled
mcp_types models the 2025-11-25 and 2026-07-28 protocol fields (e.g. resultType, ttlMs/cacheScope on cacheable results, inputResponses/requestState on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. ttlMs/cacheScope default to 0/"private" (immediately stale, not shared-cacheable); resultType defaults to "complete" on concrete results (None on EmptyResult); the server strips all of them from the wire at pre-2026 versions.
streamable_http_app() available on lowlevel Server
The streamable_http_app() method is now available directly on the lowlevel Server class, not just MCPServer. This allows using the streamable HTTP transport without the MCPServer wrapper.
from mcp.server import Server, ServerRequestContext
from mcp_types import ListToolsResult, PaginatedRequestParams
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[...])
server = Server("my-server", on_list_tools=handle_list_tools)
app = server.streamable_http_app(
streamable_http_path="/mcp",
json_response=False,
stateless_http=False,
)
The lowlevel Server also now exposes a session_manager property to access the StreamableHTTPSessionManager after calling streamable_http_app().
Need Help?
If you encounter issues during migration:
- Check the API Reference for updated method signatures
- Review the examples for updated usage patterns
- Open an issue on GitHub if you find a bug or need further assistance