From 1873295110e9b56f3ec19b27272764dea27acae7 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:45:09 +0000 Subject: [PATCH 1/2] docs: fill migration-guide gaps found by automated v1-to-v2 migration runs A batch of automated v1-to-v2 migrations over the SDK's example directories plus several real-world consumers surfaced recurring gaps in docs/migration.md - places where migrating code had to fall back to reading SDK source. This addresses the highest-frequency ones: - Add "what is unchanged" inventories for MCPServer, the lowlevel Server serving scaffolding, and the auth surface (the single most common gap: the guide listed what changed but never confirmed what survived). - Document server-initiated requests raising NoBackChannelError on 2026-07-28 connections (including the default in-process Client(server)), with cross-links from the mode, testing, tasks, and deprecation sections. - State the real streamable_http_client signature, the per-parameter porting map for the removed streamablehttp_client kwargs, and sse_client's retained parameters; note that mcp no longer installs httpx and that the auth providers now subclass httpx2.Auth. - Fix the mounted-app example to include the required lifespan, and spell out which transport parameters live on run() vs the app factories. - Document the client_secret_post client_id fix, JSONRPCError.id becoming RequestId | None, and MCPDeprecationWarning behavior under warnings-as-errors. - Add porting guidance for the removed experimental Tasks API, the ProgressContext delta-vs-absolute semantics, and RootModel-union consequences for isinstance and .root access. - Note the Client vs ClientSession pagination shapes, the per-request ServerSession identity change, and the current tool-only scope of the parametrized Context[X] annotation. No-Verification-Needed: doc-only change --- docs/migration.md | 561 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 494 insertions(+), 67 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 5eb7659c2..b7789ee29 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -6,7 +6,7 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t ## Find your changes -Every section heading below names the API it affects, so searching this page for the symbol your code uses is the fastest route to the change that broke it. +Every section heading below names the API it affects, so searching this page for the symbol your code uses is the fastest route to the change that broke it. The guide lists changes only: an SDK API not mentioned here behaves as it did in v1, and the "what did not change" summaries — [`MCPServer`](#what-is-unchanged-on-mcpserver), [lowlevel `Server`](#lowlevel-server-what-did-not-change), and [auth](#unchanged-auth-surfaces) — spell out the surfaces most migrators stop to check. ### Changes almost every project hits @@ -17,7 +17,9 @@ Every section heading below names the API it affects, so searching this page for | `mcp.types` moved to the `mcp-types` package | `ModuleNotFoundError: No module named 'mcp.types'` | [`mcp.types` moved](#mcptypes-moved-to-the-mcp-types-package) | | `McpError` renamed to `MCPError` | `ImportError: cannot import name 'McpError' from 'mcp'` | [`McpError` renamed](#mcperror-renamed-to-mcperror) | | Resource URIs are `str`, not `AnyUrl` | `AttributeError: 'str' object has no attribute 'host'` | [URI type](#resource-uri-type-changed-from-anyurl-to-str) | +| Message unions (`ServerNotification`, `JSONRPCMessage`, ...) are plain unions, not `RootModel` | `AttributeError: 'LoggingMessageNotification' object has no attribute 'root'` | [`RootModel` → unions](#replace-rootmodel-by-union-types-with-typeadapter-validation) | | `streamablehttp_client` removed | `ImportError: cannot import name 'streamablehttp_client'` | [`streamablehttp_client`](#streamablehttp_client-removed) | +| `httpx` and `httpx-sse` replaced by `httpx2` | `ModuleNotFoundError: No module named 'httpx'`, or `TypeError: Invalid "auth" argument` from `httpx.AsyncClient(auth=provider)` | [`httpx2` swap](#httpx-and-httpx-sse-replaced-by-httpx2) | | `Client` defaults to `mode='auto'` | servers log an unexpected `server/discover` request | [`mode='auto'`](#client-defaults-to-modeauto) | | Transport parameters moved off the `MCPServer` constructor | `TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'` | [constructor parameters](#transport-specific-parameters-moved-from-mcpserver-constructor-to-runapp-methods) | | Sync handlers run on a worker thread | `asyncio.get_running_loop()` in a `def` handler raises `RuntimeError` | [worker threads](#sync-handler-functions-now-run-on-a-worker-thread) | @@ -99,11 +101,13 @@ The SDK now depends on [`httpx2`](https://pypi.org/project/httpx2/) instead of `httpx`) with server-sent events support built in, so the separate `httpx-sse` dependency is gone. -The swap itself does not change any SDK signatures - `streamable_http_client` -and `sse_client` accept the same arguments as elsewhere in v2 - but the client -type they expect is now `httpx2.AsyncClient`. If you construct your own client to pass as -`http_client` (or build an `httpx2.Auth` subclass for `auth`), import from -`httpx2`: +The swap changes types, not parameter lists: `streamable_http_client` and `sse_client` +keep their keyword arguments (covered, with the removed `streamablehttp_client` alias and the +`get_session_id` callback, under [Transports](#transports)), and only the objects they take +become `httpx2` types — the pre-built `http_client` you hand `streamable_http_client`, +`sse_client`'s `auth=` (an `httpx2.Auth`, the base class `OAuthClientProvider` now uses), and +the client a custom `httpx_client_factory` returns. Import from `httpx2` when building any of +them: **Before (v1):** @@ -125,21 +129,45 @@ http_client = httpx2.AsyncClient(follow_redirects=True) changes. To consume SSE directly, use `httpx2.EventSource` (or `AsyncClient.sse()`) instead of the `httpx-sse` helpers. +mcp no longer installs `httpx` at all. If your own code imports `httpx` and relied on mcp +v1 to pull it in, that import now fails with +`ModuleNotFoundError: No module named 'httpx'` — a traceback that never mentions mcp. Either +add `httpx` to your own dependencies (the two packages install side by side; only objects +handed to the SDK via `http_client=` or `auth=` have to be `httpx2` types) or port those +calls to `httpx2`, whose `Client` and `AsyncClient` are drop-in replacements. + Exception handlers need the same rename: the SDK now raises `httpx2` exceptions (`httpx2.ConnectError`, `httpx2.HTTPStatusError`, and so on), and -this failure mode is silent. `httpx` usually stays installed as a transitive -dependency of other packages, so an old `except httpx.ConnectError:` block +this failure mode is silent. If `httpx` is still installed — your own code or another +package depends on it — an old `except httpx.ConnectError:` block keeps importing fine and simply never matches again. Audit `except httpx.` -clauses and `isinstance` checks along with the imports. The same identity -split applies to objects: `httpx` and `httpx2` types are not interchangeable -at runtime, so an `httpx.AsyncClient` passed as `http_client` degrades in -subtle ways (server-initiated messages stop arriving) instead of raising -immediately. +clauses and `isinstance` checks along with the imports, and switch test fixtures in the +same change: `pytest.raises(httpx.ConnectError)`, an `httpx.MockTransport`, or a test-only +`httpx.Auth` subclass all target the wrong types once the code under test moves to `httpx2`. +The same identity split applies to objects: `httpx` and `httpx2` types are not +interchangeable at runtime, so an `httpx.AsyncClient` passed as `http_client` degrades in +subtle ways (server-initiated messages stop arriving) instead of raising immediately. + +Retry and error-classification logic keyed to HTTP status codes needs a look too: through +the SDK's client, timeouts and non-2xx responses surface as `MCPError` with JSON-RPC codes, +not `408`s or `httpx.HTTPStatusError` — see +[Client request timeouts now raise `-32001`](#client-request-timeouts-now-raise--32001-request_timeout-instead-of-408) +and [Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors](#streamable-http-non-2xx-responses-now-surface-as-per-request-json-rpc-errors). + +The SDK's own auth providers made the same move: `OAuthClientProvider`, +`ClientCredentialsOAuthProvider`, `PrivateKeyJWTOAuthProvider`, and +`IdentityAssertionOAuthProvider` now subclass `httpx2.Auth` (v1: `httpx.Auth`), +so the client you attach one to must be an `httpx2.AsyncClient`. Unlike the +silent `http_client` degradation, this direction fails loudly at +construction: `httpx.AsyncClient(auth=provider)` raises +`TypeError: Invalid "auth" argument`. See [OAuth clients](client/oauth-clients.md) +for the `httpx2.AsyncClient(auth=...)` wiring. The client also identifies itself differently: the default User-Agent is now `python-httpx2/`, and log lines come from the `httpx2` and `httpcore2.*` loggers, so a `logging.getLogger("httpx")` or -`logging.getLogger("httpcore")` suppression no longer matches anything. +`logging.getLogger("httpcore")` suppression no longer matches anything — target +`logging.getLogger("httpx2")` and `logging.getLogger("httpcore2")` instead. Telemetry integrations keyed to the `httpx` module (such as OpenTelemetry's httpx instrumentation) stop seeing the SDK's traffic as well. @@ -417,6 +445,11 @@ await session.send_request(ClientRequest(PingRequest()), EmptyResult) ```python await session.send_notification(InitializedNotification()) await session.send_request(PingRequest(), EmptyResult) + +# Params are constructed as before; only the outer wrapper is gone +await session.send_notification( + CancelledNotification(params=CancelledNotificationParams(request_id=request_id, reason="timeout")) +) ``` **Available adapters:** @@ -433,6 +466,23 @@ await session.send_request(PingRequest(), EmptyResult) All adapters are exported from `mcp_types`. +These are ordinary `X | Y` unions of the concrete pydantic classes, so `isinstance(msg, ServerNotification)`, `isinstance(msg, LoggingMessageNotification)`, and `match`/`case` on the member classes keep working (unlike `ElicitationResult`, which became a `TypeAliasType` — see [`isinstance()` checks against `ElicitationResult` raise `TypeError`](#isinstance-checks-against-elicitationresult-raise-typeerror)). + +Values the SDK hands you are the member instances themselves, so delete `.root` accesses. A `message_handler`, for example, now receives the notification directly (v1 code fails with `AttributeError: 'LoggingMessageNotification' object has no attribute 'root'`): + +```python +# Before (v1) +if isinstance(message, ServerNotification): + if isinstance(message.root, LoggingMessageNotification): + print(message.root.params.data) + +# After (v2) +if isinstance(message, LoggingMessageNotification): + print(message.params.data) +``` + +Custom transports and `EventStore` implementations follow the same rule: `mcp.shared.message.SessionMessage` takes the member directly (`SessionMessage(JSONRPCNotification(...))`, not `SessionMessage(JSONRPCMessage(JSONRPCNotification(...)))`), and raw JSON parses with `jsonrpc_message_adapter.validate_json(raw)` instead of `JSONRPCMessage.model_validate_json(raw)`. + ### `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. @@ -528,11 +578,42 @@ raise MCPError(INVALID_REQUEST, "bad input") raise MCPError.from_error_data(error_data) ``` +### `JSONRPCError.id` is now `RequestId | None` + +In v1 `JSONRPCError.id` was typed `str | int`, so an error response with `"id": null` failed validation even though JSON-RPC 2.0 allows it (the id is null when the receiver could not determine the request id, e.g. a parse error). In v2 the field is `RequestId | None`: still required, but `None` is accepted, and `jsonrpc_message_adapter` parses a null-id error into a `JSONRPCError`. + +**Before (v1):** + +```python +from mcp.types import JSONRPCMessage + +# Raised ValidationError: id could not be None +JSONRPCMessage.model_validate( + {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}} +) +``` + +**After (v2):** + +```python +from mcp_types import PARSE_ERROR, ErrorData, JSONRPCError, jsonrpc_message_adapter + +message = jsonrpc_message_adapter.validate_python( + {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}} +) +assert isinstance(message, JSONRPCError) and message.id is None + +# Constructing one: `id` is required but nullable +JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")) +``` + +Delete any shim that accepted or synthesized null-id error responses. Code that assumed `error.id` was always a `str | int` must now handle `None`, and tests that pinned v1's rejection of `"id": null` now fail because validation succeeds. + ## MCPServer (formerly FastMCP) ### `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. +The `FastMCP` class has been renamed to `MCPServer` to better reflect its role as the main server class in the SDK. Beyond the name and import path, the changes to the class are covered in the sections that follow, and [What is unchanged on `MCPServer`](#what-is-unchanged-on-mcpserver) lists the everyday surface that carries over as-is. **Before (v1):** @@ -555,10 +636,23 @@ mcp = MCPServer("Demo") All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver.*` with the same structure. Common imports: - `Image`, `Audio` — from `mcp.server.mcpserver` (or `.utilities.types`) -- `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base` +- `Icon` — from `mcp.server.mcpserver` or `mcp_types` (not a top-level `mcp` export); its `mimeType` field is now `mime_type` per the [snake_case renames](#field-names-changed-from-camelcase-to-snake_case), though the `mimeType=` kwarg still constructs +- `Message`, `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base` - `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions` - `MCPServerError` (renamed from `FastMCPError`) — from `mcp.server.mcpserver.exceptions` +### What is unchanged on `MCPServer` + +Beyond the changes covered in this section, the everyday `FastMCP` surface carries over to `MCPServer` as-is: + +- **Decorators.** `@mcp.tool()`, `@mcp.resource()`, `@mcp.prompt()`, and `@mcp.completion()` take the same arguments and handler signatures as v1. The lowlevel [`on_completion` reshape](#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params) applies only to the lowlevel `Server`; a high-level `@mcp.completion()` handler is still called as `(ref, argument, context)`. +- **Tool return handling.** A returned `CallToolResult` (including an `Annotated[CallToolResult, YourModel]` output schema, and `_meta`) is passed through, `Image` and `Audio` convert to content blocks as before, ready-made content blocks are kept as-is, and dict, list, scalar, and model returns are wrapped into `content` and `structured_content` by the same rules. +- **Listing and registration methods.** `list_tools()`, `list_resources()`, `list_resource_templates()`, and `list_prompts()` return the same lists and are still what the protocol handlers call, so subclass overrides still take effect. `add_tool()`, `add_resource()`, and `add_prompt()` are unchanged. +- **Helpers.** `Image.to_image_content()`, `Audio.to_audio_content()`, and the prompt `Message`, `UserMessage`, and `AssistantMessage` classes. +- **Lifespan.** The `lifespan=` constructor argument and `ctx.request_context.lifespan_context` work as before, and the class is still generic over the lifespan result: `FastMCP[MyState]` becomes `MCPServer[MyState]`. (`Context`'s own type parameters did change; see [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified).) +- **Tool internals.** `Tool`, `Tool.from_function()`, `FuncMetadata`, `ArgModelBase`, and `func_metadata()` keep their v1 shapes; the one change is the now-required `context` argument to `Tool.run()`, described [below](#mcpservercall_tool-read_resource-get_prompt-now-accept-a-context-parameter). +- **Auxiliary import paths.** `TransportSecuritySettings` (`mcp.server.transport_security`) and `AcceptedElicitation`/`DeclinedElicitation`/`CancelledElicitation` (`mcp.server.elicitation`) have not moved; the server auth surface is inventoried under [Unchanged auth surfaces](#unchanged-auth-surfaces). + ### Default server name changed from `FastMCP` to `mcp-server` A server constructed without a name now defaults to `mcp-server` instead of `FastMCP`. This is the name reported to clients as `serverInfo.name` in the initialize result, so it is visible in client UIs, logs, and monitoring. Nothing raises when this changes; the migrated server simply reports a different identity. @@ -622,17 +716,19 @@ This parameter was redundant because the SSE transport already handles sub-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. +Transport-specific parameters have been moved off the `MCPServer` constructor and onto `run()`, `sse_app()`, and `streamable_http_app()`, so transport configuration is passed when starting or building the server. The rest of the constructor is unchanged: identity (`name`, `instructions`, `website_url`, `icons`, plus the newly added positional `title`, `description`, and `version` covered [above](#mcpserver-constructor-title-description-and-version-added-to-the-positional-parameters)), authentication (`auth`, `token_verifier`, `auth_server_provider`), `lifespan`, `dependencies`, `tools`, `debug`, `log_level`, and the `warn_on_duplicate_*` flags; the new keyword-only parameters (`resources`, `extensions`, `resource_security`, `request_state_security`, `cache_hints`, `subscriptions`) are additive. **Parameters moved:** -- `host`, `port` - HTTP server binding -- `sse_path`, `message_path` - SSE transport paths -- `streamable_http_path` - StreamableHTTP endpoint path -- `json_response`, `stateless_http` - StreamableHTTP behavior -- `max_request_body_size` - StreamableHTTP request-body limit -- `event_store`, `retry_interval` - StreamableHTTP event handling -- `transport_security` - DNS rebinding protection +- `host`, `port` - HTTP server binding, on `run()` only. The app factories have no `port` (`streamable_http_app(port=...)` raises `TypeError`; a mounted app binds wherever the outer ASGI server does) but do take `host` (default `"127.0.0.1"`), used only to decide whether DNS rebinding protection auto-enables (see the note below) +- `sse_path`, `message_path` - SSE transport paths, on `run(transport="sse", ...)` and `sse_app()` +- `streamable_http_path` - StreamableHTTP endpoint path, on `run(transport="streamable-http", ...)` and `streamable_http_app()` +- `json_response`, `stateless_http` - StreamableHTTP behavior, same two places +- `max_request_body_size` - StreamableHTTP request-body limit, same two places +- `event_store`, `retry_interval` - StreamableHTTP event handling, same two places +- `transport_security` - DNS rebinding protection, on `run()` for both HTTP transports and on both app methods + +`run()` is `@overload`ed per transport, so type checkers validate the keywords each transport accepts (`transport="stdio"` takes none); at runtime the HTTP transports raise `TypeError` on an unrecognised keyword when they start. **Before (v1):** @@ -655,7 +751,7 @@ 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) +mcp.run(transport="streamable-http", host="0.0.0.0", port=9000, json_response=True, stateless_http=True) # Or for SSE mcp = MCPServer("Server") @@ -664,25 +760,43 @@ 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: +When mounting in a Starlette app, pass transport params to `streamable_http_app()`. As in v1, the host app's lifespan must enter `mcp.session_manager.run()` — a mounted sub-app's own lifespan never runs, so nothing else starts the session manager: ```python +import contextlib + # 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))]) + + +# Unchanged from v1: the host app's lifespan runs the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Before (v1) +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) + +# After (v2) +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))], lifespan=lifespan) ``` -**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. +Without `lifespan=lifespan` the app starts but every request to the mounted path fails with `RuntimeError: Task group is not initialized. Make sure to use run().` — `mcp.session_manager` is the same public property v1's `FastMCP.session_manager` was, and it still exists only after `streamable_http_app()` has been called, so build the routes at module level and touch the manager only inside the lifespan. See [Add to an existing app](run/asgi.md) for the full pattern, including several servers in one app. + +v2 has no settings object that carries transport configuration, so if the module that configures the server is not the one that builds the ASGI app, carry the keywords yourself, e.g. `build_app = functools.partial(mcp.streamable_http_app, json_response=True, stateless_http=True)` next to the server definition and `Mount("/", app=build_app())` wherever it is mounted. -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. +**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1` and no `transport_security` is passed. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor, and because those default to `host="127.0.0.1"`, a mounted app has protection on until you configure it. The auto-allowlist entries are `host:port` patterns (`127.0.0.1:*`, `localhost:*`, `[::1]:*`), so a request whose `Host` header carries no port (some in-process test clients send a bare `Host: localhost`) is rejected with `421 Invalid Host header`. To serve a real hostname, pass `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` (from `mcp.server.transport_security`); [Deploy & scale](run/deploy.md) and [Troubleshooting](troubleshooting.md) cover the allowlist and the `421` in detail. + +`Settings` (what `mcp.settings` holds) now has only the constructor-owned fields: `debug`, `log_level`, the `warn_on_duplicate_*` flags, `dependencies`, `lifespan`, and `auth`. If you were mutating transport values via `mcp.settings` after construction (e.g. `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead: assigning a removed field now raises `ValueError: "Settings" object has no field "port"`. `settings.lifespan` is read once, at construction, so reassigning it afterwards has no effect. Once `streamable_http_app()` has been called, the values it was built with live on the runtime objects (e.g. `mcp.session_manager.stateless`, `mcp.session_manager.json_response`). ### `MCP_*` environment variables and `.env` files are no longer read @@ -721,6 +835,17 @@ When serving streamable HTTP (stateful or `stateless_http=True`), the server's ` 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` (a public way to reach it from high-level `@mcp.tool()` handlers is planned). +### Streamable HTTP: session manager, `EventStore`, and stateless mode unchanged + +Beyond the constructor parameters that moved to `run()`/`streamable_http_app()` and the lifespan change above, the server-side Streamable HTTP machinery is as in v1: + +- `mcp.server.streamable_http` still exports the `EventStore` ABC (`store_event()`, `replay_events_after()`), `EventMessage`, `EventCallback`, `EventId`, and `StreamId` with unchanged signatures; a custom `EventStore` only needs `JSONRPCMessage` imported from `mcp_types` instead of `mcp.types`. +- `StreamableHTTPSessionManager` keeps its constructor and its `run()` / `handle_request()` methods (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)); its `stateless=` parameter is unrelated to the removed [`Server.run(stateless=)` flag](#serverrun-no-longer-takes-a-stateless-flag). +- `mcp.session_manager` still returns the manager once `streamable_http_app()` has been called, with the same `stateless`, `json_response`, `event_store`, and `retry_interval` attributes. +- `stateless_http=True` still serves each request with a fresh transport, no `Mcp-Session-Id`, and no state carried between requests; `ctx.close_sse_stream()` and `ctx.close_standalone_sse_stream()` are still available on the handler `Context`. + +Only private attributes moved: `mcp._mcp_server` is now `mcp._lowlevel_server` (see [Registering lowlevel handlers from `MCPServer`](#registering-lowlevel-handlers-from-mcpserver)), and `_session_manager` now lives on that lowlevel `Server`. Prefer the public `mcp.session_manager` property to either. + ### `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. @@ -733,7 +858,7 @@ Lifespans that set up process-wide state (connection pools, caches, background t @mcp.tool() async def my_tool(x: int) -> str: ctx = mcp.get_context() - await ctx.info("Processing...") + await ctx.report_progress(1, 2) return str(x) ``` @@ -744,7 +869,7 @@ from mcp.server.mcpserver import Context @mcp.tool() async def my_tool(x: int, ctx: Context) -> str: - await ctx.info("Processing...") + await ctx.report_progress(1, 2) return str(x) ``` @@ -836,6 +961,25 @@ 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. +The client sees this change too. `Client.call_tool()` and +`ClientSession.call_tool()` raise on a JSON-RPC error response, so a tool that +rejects with `MCPError` now raises `MCPError` on the calling side (`code`, +`message`, and `data` intact) instead of returning a `CallToolResult` with +`isError=True` and the message in `content`: + +```python +# Before (v1) +result = await session.call_tool("book_flight", {"date": "yesterday"}) +if result.isError: + ... # error text is in result.content + +# After (v2) +try: + result = await client.call_tool("book_flight", {"date": "yesterday"}) +except MCPError as e: + ... # e.code, e.message, e.data +``` + ### 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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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. @@ -935,10 +1079,25 @@ 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. +could never be read). This now raises `ValueError` at decoration time +— resource `Context` injection is only wired up for templates. What to +do instead depends on why the handler wanted the context. For lifespan +or application state (what you would read from +`ctx.request_context.lifespan_context`), a static handler is an +ordinary function, so read that state from a module-level object (or +closure) that your `lifespan` populates. For anything on the request +itself (logging, progress, the session), keep the `Context` parameter +and add a template variable so the handler registers as a template; an +optional query variable is enough, and a plain `notes://recent` read +still matches with the default filled in: + +```python +@mcp.resource("notes://recent{?limit}") +async def recent_notes(ctx: Context, limit: int = 10) -> str: ... +``` + +Such a resource is advertised by `resources/templates/list` rather than +`resources/list`. See [URI templates](servers/uri-templates.md) for the full template syntax, security configuration, and filesystem safety utilities. @@ -963,6 +1122,8 @@ await ctx.log(level="info", data="hello") Positional calls (`await ctx.info("hello")`) are unaffected. +These helpers are themselves deprecated by [SEP-2577](#roots-sampling-and-logging-methods-deprecated-sep-2577) and emit `mcp.MCPDeprecationWarning` on every call, so treat the rename as a keep-it-working fix rather than a migration target: nothing in-protocol replaces pushing log messages to the client, so log with the standard `logging` module instead (see [Logging](handlers/logging.md)) and use `ctx.report_progress()` for progress the client should see. + ### `Context.client_id` removed `Context.client_id` has been removed. It never returned an authenticated client identity: it echoed a non-standard `client_id` key from the request's `_meta`, which nothing in the SDK or the MCP spec populates, so it was `None` unless a caller injected `meta={"client_id": ...}` by hand. The name also collided with the OAuth `client_id`, which is what callers usually mean by "the client". @@ -988,13 +1149,19 @@ client_id = token.client_id if token else None 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()` or `session.send_progress_notification()` directly. +The replacement is `Context.report_progress(progress, total=None, message=None)` in an `MCPServer` handler, or `ctx.session.report_progress(progress, total, message)` from a lowlevel `Server` handler. Two differences from `ProgressContext.progress(amount, message)`: + +- **`report_progress` takes the absolute current value, not a delta.** `ProgressContext.progress(amount)` accumulated into a running total, so calling `p.progress(10)` twice reported `20`. Passing the same deltas to `report_progress` reports `10` twice — progress that jitters instead of increasing, with no error. Keep the running total yourself. +- `progress()` raised `ValueError` when the request carried no progress token; `report_progress` is a no-op when the caller did not request progress. The optional `message=` argument is unchanged. + **Before (v1):** ```python from mcp.shared.progress import progress with progress(ctx, total=100) as p: - await p.progress(25) + await p.progress(25, message="step 1") # running total: 25 + await p.progress(25) # running total: 50 ``` **After — use `Context.report_progress()` (recommended):** @@ -1002,20 +1169,19 @@ with progress(ctx, total=100) as p: ```python @mcp.tool() async def my_tool(x: int, ctx: Context) -> str: - await ctx.report_progress(25, 100) + await ctx.report_progress(25, 100, message="step 1") + await ctx.report_progress(50, 100) # absolute value, not a delta return "done" ``` -**After — use `session.send_progress_notification()` (low-level):** +**After — lowlevel `Server`:** ```python -await session.send_progress_notification( - progress_token=progress_token, - progress=25, - total=100, -) +await ctx.session.report_progress(50, 100, message="halfway") ``` +`ctx.session.report_progress()` also works on the in-process `Client(server)` path (see [Testing utilities](#testing-utilities)); `ctx.session.send_progress_notification(progress_token, progress, total, message)` remains for code that reads `ctx.meta["progress_token"]` itself, and takes the same absolute-value `progress`. + ### `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. @@ -1034,7 +1200,7 @@ if isinstance(result, AcceptedElicitation): ... # result.data is a Confirm ``` -Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected. +Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected. The `TypeError` is specific to `TypeAliasType` aliases like `ElicitationResult`; the `mcp_types` message unions (`ClientRequest`, `ServerNotification`, `JSONRPCMessage`, ...) are ordinary unions and stay `isinstance`-compatible (see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation)). ### Registering lowlevel handlers from `MCPServer` @@ -1077,6 +1243,29 @@ mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequest ## Lowlevel Server +### Lowlevel `Server`: what did not change + +Handler registration, signatures, and return values changed (the sections below); the serving scaffolding around them keeps its v1 import paths and call shapes: + +- `server.run(read_stream, write_stream, initialization_options)`, including `raise_exceptions=` (narrowed, see [transport errors no longer re-raised](#lowlevel-serverrunraise_exceptionstrue-transport-errors-no-longer-re-raised)). Only the `stateless=` flag is gone (see [`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). +- `server.create_initialization_options(notification_options=..., experimental_capabilities=...)`, `server.get_capabilities(...)` (its arguments are now optional), and `NotificationOptions(prompts_changed=, resources_changed=, tools_changed=)`. Both methods gained an optional `extensions=` argument. `create_initialization_options()` is still how you build the `InitializationOptions` passed to `run()`; the only value that differs is `server_version` (see [Unversioned servers report an empty version](#unversioned-servers-report-an-empty-version)). +- `InitializationOptions` (`from mcp.server import InitializationOptions`, also `mcp.server.models`) gained optional `title`/`description` fields; `NotificationOptions` is importable from `mcp.server` and `mcp.server.lowlevel` as before. +- `lifespan=` keeps its contract — an async-context-manager factory that receives the `Server` and whose yielded value handlers read as `ctx.lifespan_context` — but is now keyword-only (see [constructor parameters are now keyword-only](#lowlevel-server-constructor-parameters-are-now-keyword-only)) and, under streamable HTTP, entered once at manager startup (see [Streamable HTTP: lifespan now entered once at manager startup](#streamable-http-lifespan-now-entered-once-at-manager-startup)). +- Server-side transports keep their v1 signatures: `mcp.server.stdio.stdio_server()`, `mcp.server.sse.SseServerTransport(endpoint)` (`connect_sse` / `handle_post_message`), and `mcp.server.streamable_http_manager.StreamableHTTPSessionManager`; the one stdio behavior change is [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors). +- Import paths: `from mcp.server import Server` (preferred), `from mcp.server.lowlevel import Server`, and `from mcp.server.lowlevel.server import Server` all resolve; only the `request_ctx` contextvar left `mcp.server.lowlevel.server` (see [`request_context` property removed](#lowlevel-server-request_context-property-removed)). `mcp.server.lowlevel.helper_types.ReadResourceContents` still exists (it is `MCPServer.read_resource()`'s return type), but lowlevel `on_read_resource` handlers return `ReadResourceResult` (see [automatic return value wrapping removed](#lowlevel-server-automatic-return-value-wrapping-removed)). + +So a v1 `main()` carries over untouched: + +```python +async def main() -> None: + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(notification_options=NotificationOptions(tools_changed=True)), + ) +``` + ### 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. @@ -1382,6 +1571,8 @@ 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=...))`. +**Note:** `Server.middleware` and the `ServerMiddleware` / `CallNext` / `HandlerResult` types in `mcp.server.context` are marked provisional in the source — their signature and semantics may change — so use middleware to observe (log, time, trace) rather than as a foundation. See [Middleware](advanced/middleware.md). + ### 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. @@ -1392,7 +1583,7 @@ Previously it also re-raised exceptions yielded by the transport onto the read s 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. -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. In v1 there was no dedicated exception for this case: the transport silently dropped the outbound message and the awaiting call stalled. +Server-initiated requests that have no channel to travel on — a legacy session against a `stateless_http=True` server, or any connection negotiated at 2026-07-28 — now raise `NoBackChannelError` instead of stalling as they did in v1 (the transport silently dropped the outbound message), so a stateless-HTTP `ctx.elicit()` that used to hang now fails fast; see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror) for the exception and the migration paths. ### Lowlevel `Server`: `request_context` property removed @@ -1461,6 +1652,8 @@ server_ctx: ServerRequestContext[LifespanContextT, RequestT] One field is newly optional: `request_id` is now `RequestId | None` (in v1 it was always a `RequestId`). The same context class is passed to notification handlers, where `request_id` is `None`, so code that forwards `ctx.request_id` as a definite `RequestId` needs a `None` check to satisfy type checkers. +`ClientRequestContext` (importable from `mcp.client` or `mcp.client.context`) is smaller: a keyword-only dataclass with just `session: ClientSession`, `request_id: RequestId`, and `meta: RequestParamsMeta | None` — no `lifespan_context` or `request` on the client side. Its `request_id` is always a concrete `RequestId`, since the context is only built for the server-initiated `sampling`, `elicitation`, and `roots` requests it is passed to. + 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):** @@ -1477,11 +1670,21 @@ async def my_tool(ctx: Context) -> str: ... async def my_tool(ctx: Context[MyLifespanState]) -> str: ... ``` +The parametrized `Context[MyLifespanState]` annotation currently works only on `@mcp.tool()` handlers. On `@mcp.prompt()` and templated `@mcp.resource("scheme://{param}")` handlers, annotate the parameter as bare `Context` for now: these handlers are wrapped in `pydantic.validate_call`, which re-validates the injected `Context` into a fresh `Context[MyLifespanState]` detached from the request, so the first access to `ctx.request_id`, `ctx.session`, or `ctx.request_context` raises `ValueError: Context is not available outside of a request` (the client sees an internal server error, or `Error creating resource from template ...`). Bare `Context` still exposes `ctx.request_context.lifespan_context`; only its static type is lost. + ### `ServerSession` is now a thin proxy (no longer a `BaseSession`) -`ServerSession` no longer subclasses `BaseSession`. It is now a small per-request 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` no longer subclasses `BaseSession`. It is now a small per-request proxy that exposes `send_request`, `send_notification`, the typed convenience helpers — `create_message`, `elicit` / `elicit_form` / `elicit_url`, `send_elicit_complete`, `list_roots`, `send_log_message`, `send_resource_updated`, `send_resource_list_changed` / `send_tool_list_changed` / `send_prompt_list_changed`, `send_ping`, `send_progress_notification`, and the new `report_progress` — plus `check_client_capability` and the read-only `client_params`, `client_capabilities`, `protocol_version`, and `can_send_request` properties. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`. + +The helpers keep their v1 signatures, so calls through `ctx.session` are source-compatible: `send_notification(notification, related_request_id=None)`, `send_log_message(level, data, logger=None, related_request_id=None)` (now [SEP-2577-deprecated](#roots-sampling-and-logging-methods-deprecated-sep-2577)), `send_progress_notification(progress_token, progress, total=None, message=None, related_request_id=None)`, `related_request_id=` on `elicit_form` / `elicit_url` / `send_elicit_complete`, and `metadata=ServerMessageMetadata(related_request_id=...)` on `send_request` (used by `create_message`). As in v1, a present `related_request_id` routes the message onto that request's own stream (the POST response in streamable HTTP) and an absent one uses the connection's standalone stream. Two adjustments: `send_resource_updated(uri)` accepts `str | AnyUrl`, and `send_notification` takes the notification model itself — the `types.ServerNotification(...)` wrapper is gone with the other `RootModel` unions (`await session.send_notification(types.ResourceListChangedNotification())`; see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation)). + +Behavior changes: + +- **A new `ServerSession` proxy is built for every inbound message.** In v1 one `ServerSession` lived for the whole connection, and servers commonly keyed per-client state on `ctx.session` identity (a `WeakKeyDictionary[ServerSession, ...]`, `id(ctx.session)`, a set of captured sessions to notify later). In v2 each request and notification gets a fresh proxy over the same connection, so those idioms silently misbehave: a session-keyed dict never finds an earlier key, and a broadcast set grows by one entry per request, sending duplicates. Key on something connection-stable instead — on stateful streamable HTTP the `mcp-session-id` request header names the transport session (read it via `ctx.headers` on `MCPServer` or `ctx.request.headers` in a lowlevel handler); on stdio there is one connection per process. The per-connection object the proxies share is `mcp.server.connection.Connection` (`state`, `session_id`, `exit_stack`), which is not currently reachable from `ctx`. +- **A captured `ctx.session` stays usable after the handler returns.** The proxy holds the connection, not the request, so a background task can keep calling `send_resource_updated()` / `send_tool_list_changed()` on it while the client stays connected; with `related_request_id` omitted these ride the standalone stream as in v1. A request-scoped send is only meaningful while that request is in flight — once the handler returns, that stream is closed and the message is dropped with a debug log. +- **Notifications after the connection has closed are dropped instead of raising.** In v1 the notification helpers raised `anyio.ClosedResourceError`/`anyio.BrokenResourceError` on a dead connection, and broadcast loops used that exception to prune sessions. In v2 the send returns normally (the drop is debug-logged), so probe with a request instead: `await session.send_ping()` raises `MCPError` once the connection has closed. On a 2026-07-28 connection, though, every server-initiated request raises `NoBackChannelError` (an `MCPError`) regardless, so a ping is a liveness probe only on connections negotiated at 2025-11-25 or earlier. -`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: +`ServerSession` is normally constructed for you by `Server.run()` and reached via `ctx.session` in handlers, so beyond the behavior changes above, most servers are unaffected. If you were constructing or subclassing it directly: **Constructor change:** @@ -1538,7 +1741,7 @@ result = await ctx.session.elicit_form( ) ``` -Positional callers (`session.elicit_form(message, schema)`) are unaffected. `elicit_url()` already used snake_case parameters in v1; only `elicit()` and `elicit_form()` changed. +Positional callers (`session.elicit_form(message, schema)`) are unaffected, and so are the return types: `elicit()`, `elicit_form()`, and `elicit_url()` still return `ElicitResult` (`action` of `"accept"`/`"decline"`/`"cancel"` plus `content`), and `create_message()` still returns `CreateMessageResult` (or `CreateMessageResultWithTools` when `tools`/`tool_choice` are passed). `elicit_url()` already used snake_case parameters in v1; only `elicit()` and `elicit_form()` changed. ## Clients @@ -1546,11 +1749,11 @@ Positional callers (`session.elicit_form(message, schema)`) are unaffected. `eli 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 pre-2026 connection sequence (the per-request wire shape still differs from v1; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default)), or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing. -The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport. +The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation, roots), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport with `NoBackChannelError` (see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror)). -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. +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 — or if the server pushes sampling, elicitation, or roots requests, which the default 2026-07-28 in-process connection refuses with `NoBackChannelError` even when the matching callback is set (see the section linked above). `mode` is a `Client` argument only: a lowlevel `ClientSession` you `initialize()` yourself always performs the pre-2026 handshake, and `ClientSession.discover()` is the explicit 2026-07-28 entry point. -`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it. +`Client.send_ping()` is deprecated (ping is removed in 2026-07-28) and emits `mcp.MCPDeprecationWarning` when called; pin `mode='legacy'` if you need it. The lowlevel `ClientSession.send_ping()` carries no deprecation marker. ### `ClientSession.get_server_capabilities()` replaced by era-neutral accessors @@ -1585,7 +1788,7 @@ The deprecated `cursor` parameter has been removed from the following `ClientSes - `list_prompts()` - `list_tools()` -Use `params=PaginatedRequestParams(cursor=...)` instead. +Each method now takes a single keyword-only argument, `params: PaginatedRequestParams | None = None`. Pass `params=PaginatedRequestParams(cursor=...)` to continue from a `next_cursor`; omit `params` for the first page. **Before (v1):** @@ -1603,6 +1806,20 @@ result = await session.list_resources(params=PaginatedRequestParams(cursor="next result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) ``` +To walk every page, feed each result's `next_cursor` back in until it comes back `None`: + +```python +tools = [] +cursor = None +while True: + page = await session.list_tools(params=PaginatedRequestParams(cursor=cursor)) + tools.extend(page.tools) + if (cursor := page.next_cursor) is None: + break +``` + +The high-level `Client` (including the `Client(server)` replacement described under [Testing utilities](#testing-utilities)) does not accept `params=` — passing it raises `TypeError`. Its list methods keep pagination as a plain keyword, `await client.list_tools(cursor="next_page_token")`, alongside `meta=` and a per-call `cache_mode=` (`"use"` by default, or `"refresh"`/`"bypass"`) for the client's built-in [response cache](client/caching.md); `client.session.list_tools(params=...)` reaches the underlying `ClientSession` if you want the `params` form. + ### `args` parameter removed from `ClientSessionGroup.call_tool()` The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. @@ -1707,21 +1924,45 @@ except MCPError as e: ### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed -`ClientSession`'s public surface is unchanged — same constructor apart from timeout parameters (see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), 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). +`ClientSession`'s public surface is unchanged — same constructor apart from timeout parameters (see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), 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). Passing one of the SDK's own dispatchers (`JSONRPCDispatcher`, or `DirectDispatcher` from `mcp.shared.direct_dispatcher`) is the supported use; the `Dispatcher` protocol's `run()` lifecycle (`mcp.shared.dispatcher`) is documented as provisional, so treat a hand-written implementation as experimental. 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 sends `notifications/cancelled` (the request is then answered with an error). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that 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. +- **Notification routing is unchanged.** Each server notification is still delivered to its typed callback first — `logging_callback` for log messages, the per-request `progress_callback` whose `progressToken` matches a request you issued (`progress_callback=` still stamps `params._meta.progressToken` with the outbound request id) — and then teed to `message_handler`. `notifications/cancelled` is applied by the dispatcher and never surfaced, also as in v1. +- **Cancellation now reaches the server.** Cancelling the task or cancel scope awaiting a request (e.g. `anyio.move_on_after()` around `session.call_tool(...)`), or a request hitting its read timeout, now sends `notifications/cancelled` for that request, so the server interrupts the handler instead of leaving it running; v1 sent nothing ([#2507](https://github.com/modelcontextprotocol/python-sdk/issues/2507)). A test that pinned the v1 gap with a strict `xfail` now passes — drop the marker. The cancelled peer still answers with `ErrorData(code=0, message="Request cancelled")` as in v1 (discarded, since the caller's waiter is already gone). There is no public request-id or cancel handle (v1's private `session._request_id` went with `BaseSession`): cancel the awaiting task or scope and the dispatcher sends the cancel for you. - **A raising request callback** is answered with `code=0` and the exception text; v1 flattened every callback exception to `INVALID_PARAMS`. For a specific error response, return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: pydantic's `ValidationError` is still answered with `INVALID_PARAMS`, as in v1. - **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. After the connection has closed it raises `MCPError` (`CONNECTION_CLOSED`) instead. `send_notification` before entry still works. - **`send_notification` after the connection has closed is dropped with a debug log instead of raising.** In v1 the send raised `anyio.BrokenResourceError` (peer gone) or `anyio.ClosedResourceError` (session torn down), and this applied to the typed helpers (`send_roots_list_changed`, `send_progress_notification`) too. Code that used the exception as its disconnect signal should probe with a request instead (`send_request` still raises `MCPError` after close, see above) or scope the sending task to the session's lifetime. -- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected. -- **Client callbacks now receive `mcp.client.ClientRequestContext`** (its `request_id` is always populated); the `mcp.shared.context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession, Any]` become `ClientRequestContext` (details in [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified)). +- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected. This is client-side only: the server's `ServerSession` helpers keep `related_request_id` (see [`ServerSession` is now a thin proxy](#serversession-is-now-a-thin-proxy-no-longer-a-basesession)). +- **Client callbacks now receive `mcp.client.ClientRequestContext`** (its `request_id` is always populated); the `mcp.shared.context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession, Any]` become `ClientRequestContext` (details in [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified)). Otherwise the callback surface is unchanged: the `sampling_callback=`, `elicitation_callback=`, `list_roots_callback=`, `logging_callback=`, and `message_handler=` keywords; the `SamplingFnT`, `ElicitationFnT`, `ListRootsFnT`, `LoggingFnT`, and `MessageHandlerFnT` protocols (still in `mcp.client.session`); and the params/result types (`CreateMessageRequestParams` → `CreateMessageResult | CreateMessageResultWithTools | ErrorData`, `ElicitRequestParams` → `ElicitResult | ErrorData`, `ListRootsResult | ErrorData` — returning `ErrorData` is not new). The `mcp.client.session`, `mcp.client.stdio`, `mcp.client.sse`, and `mcp.client.streamable_http` module paths are unchanged too, so `unittest.mock.patch` string targets still resolve. +- **`message_handler` no longer receives requests.** Server-initiated requests are answered by the typed callbacks (`sampling_callback`, `elicitation_callback`, `list_roots_callback`), so the handler's parameter is now `IncomingMessage = ServerNotification | Exception`, exported from `mcp.client`. Replace the hand-written v1 union `RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception` with `IncomingMessage`; `RequestResponder` is gone (below), so the old annotation no longer imports. Delivered notifications are the concrete member instances rather than the v1 `RootModel` wrapper, so drop `.root` (`message.params`, not `message.root.params`); see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation). + +The `mcp.shared.session` module is gone. `RequestResponder` is removed — `respond()`, the cancellation-tracking members (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument) and `BaseSession._in_flight` have no replacement; inbound cancellation is handled by `JSONRPCDispatcher`. `ProgressFnT` now lives only in `mcp.shared.dispatcher`, and `RequestId` in `mcp_types`. The module's generic typing helpers (`SendRequestT`, `SendResultT`, `SendNotificationT`, `ReceiveRequestT`, `ReceiveResultT`, `ReceiveNotificationT`) went with it and have no re-export — the sessions are no longer generic; `ClientSession.send_request` takes a concrete request model plus a result model class (or `pydantic.TypeAdapter`), so an override that needs a type parameter can declare its own `TypeVar` bound to `pydantic.BaseModel`. + +Subclassing `ClientSession` remains a valid interception point: every typed helper routes through `send_request`, and the notification helpers through `send_notification`, so overriding those two still sees that traffic (the 2026-era `discover()`/`send_discover()` are the exception — they call the dispatcher directly). For wire-level interception, use the `dispatcher=` argument instead (with the caveat above on hand-written dispatchers). + +Migrating a request callback is a signature-only change (sampling and roots callbacks have the same shape): + +**Before (v1):** + +```python +async def elicitation_callback( + context: RequestContext[ClientSession, Any], params: types.ElicitRequestParams +) -> types.ElicitResult | types.ErrorData: ... +``` -- **`message_handler` no longer receives requests.** Server-initiated requests are answered by the typed callbacks (`sampling_callback`, `elicitation_callback`, `list_roots_callback`), so the handler's parameter is now `IncomingMessage = ServerNotification | Exception`, exported from `mcp.client`. Replace the hand-written v1 union `RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception` with `IncomingMessage`; `RequestResponder` is gone (below), so the old annotation no longer imports. +**After (v2):** + +```python +from mcp.client import ClientRequestContext +from mcp_types import ElicitRequestParams, ElicitResult, ErrorData -The `mcp.shared.session` module is gone. `RequestResponder` is removed — `respond()`, the cancellation-tracking members (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument) and `BaseSession._in_flight` have no replacement; inbound cancellation is handled by `JSONRPCDispatcher`. `ProgressFnT` now lives only in `mcp.shared.dispatcher`, and `RequestId` in `mcp_types`. + +async def elicitation_callback( + context: ClientRequestContext, params: ElicitRequestParams +) -> ElicitResult | ErrorData: ... +``` ### Experimental Tasks support removed @@ -1729,8 +1970,54 @@ Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/i The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet. +There is no drop-in replacement for the tasks runtime (`server.experimental.enable_tasks()`, `ctx.experimental.run_task()`, `ServerTaskContext`, and the client's `session.experimental.call_tool_as_task()` / `poll_task()` / `get_task_result()`); the port depends on what the code used tasks for. + +**Status updates on a long-running tool.** Run the work inline in the tool handler and replace `ServerTaskContext.update_status()` with progress reporting: `ctx.report_progress(progress, total, message)` on `MCPServer`, or `ctx.session.report_progress(...)` in a lowlevel handler (a no-op when the caller did not request progress). The client no longer creates a task and polls `tasks/get`; it passes `progress_callback=` to `call_tool()` and receives `notifications/progress` while the single call is in flight. + +**Before (v1):** + +```python +# server: hand the work to the task runtime and report status from inside it +async def work(task: ServerTaskContext) -> types.CallToolResult: + await task.update_status("Processing step 1...") + ... + +result = await ctx.experimental.run_task(work) + +# client: create the task, poll its status, then fetch the result +result = await session.experimental.call_tool_as_task("long_running_task", arguments={}, ttl=60000) +async for status in session.experimental.poll_task(result.task.taskId): + print(status.statusMessage) +task_result = await session.experimental.get_task_result(result.task.taskId, CallToolResult) +``` + +**After (v2):** + +```python +# server +@mcp.tool() +async def long_running_task(ctx: Context) -> str: + await ctx.report_progress(1, total=3, message="Processing step 1...") + ... + return "Task completed!" + +# client +async def on_progress(progress: float, total: float | None, message: str | None) -> None: + print(message) + +result = await client.call_tool("long_running_task", {}, progress_callback=on_progress) +``` + +**Gathering user input mid-work** (`task.elicit()`, `task.create_message()`). Don't port these to inline `ctx.elicit()` / `ctx.session.create_message()` calls: those are server-initiated requests, refused with `NoBackChannelError` on 2026-07-28 connections (the default for an in-process `Client(server)`). Use the resolver dependencies (`Elicit`, `Sample`) or return an `InputRequiredResult` — both work on every protocol version, and `Client.call_tool()` retries the `InputRequiredResult` rounds automatically; see [Multi-round-trip requests](handlers/multi-round-trip.md) and [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror). The client's existing `elicitation_callback` / `sampling_callback` serve both eras. + +**Detached work** (create the task now, fetch its result on a later connection or after a client restart) has no v2 equivalent until the SEP-2663 extension is implemented. + +Also drop `execution=ToolExecution(taskSupport=types.TASK_REQUIRED)` from tool definitions: the `TASK_REQUIRED` / `TASK_OPTIONAL` / `TASK_FORBIDDEN` constants are gone from `mcp_types` (`ToolExecution.task_support` takes the plain `"required"` / `"optional"` / `"forbidden"` literal), and no v2 client or server reads the field. + ## Transports +Server-side transport entry points (`stdio_server()`, `SseServerTransport`, `StreamableHTTPSessionManager`) keep their v1 import paths and signatures (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)), so the sections below are client-side apart from [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors); the other server-side transport changes ([lifespan entered once](#streamable-http-lifespan-now-entered-once-at-manager-startup), the [4 MiB request-body limit](#streamable-http-request-bodies-are-limited-to-4-mib)) sit under MCPServer. + ### `streamablehttp_client` removed The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead. @@ -1774,6 +2061,14 @@ async with http_client: v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx2.AsyncClient` to preserve that behavior. +`streamable_http_client` itself keeps a small signature — `streamable_http_client(url, *, http_client=None, terminate_on_close=True)` — and now yields a 2-tuple (next section). The removed function's other parameters map onto the client you build: + +- `headers`, `timeout`, `sse_read_timeout`, `auth`: set them on the `httpx2.AsyncClient` as above. `streamablehttp_client` defaulted to `httpx.Timeout(30, read=300)`; a bare `httpx2.AsyncClient()` falls back to httpx2's flat 5-second timeout, too short for the long-lived GET stream, so set `timeout=httpx2.Timeout(30, read=300)` (as shown) to keep v1's values. Omitting `http_client` still gives you a default client with those timeouts and `follow_redirects=True`. +- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`. +- `terminate_on_close`: unchanged (default `True`). + +Client-side stream resumption is also unchanged: the transport reconnects a dropped GET stream with `Last-Event-ID` on its own, and `session.send_request(..., metadata=ClientMessageMetadata(resumption_token=..., on_resumption_token_update=...))` (from `mcp.shared.message`) works as in v1. + ### `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. @@ -1824,11 +2119,15 @@ async with http_client: session_id = captured_session_ids[0] if captured_session_ids else None ``` +The hook fires on every response the client sees, so `captured_session_ids` gains one entry per response carrying an `mcp-session-id` header (all the same value on one connection; if you reuse an `httpx2.AsyncClient` across reconnects, take the last entry). A hook can also be appended to an existing client: `client.event_hooks["response"].append(capture_session_id)`. + +`terminate_on_close` still defaults to `True`, so `streamable_http_client` sends its own `DELETE` for the session on exit; if your test deletes the session itself, pass `terminate_on_close=False`, or the transport's follow-up `DELETE` hits an already-terminated session and logs a `Session termination failed: 404` warning. + ### `StreamableHTTPTransport` parameters removed The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx2.AsyncClient` instead (see example above). -Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed. +`sse_client` is unchanged apart from the `httpx2` retyping: it still takes `url`, `headers`, `timeout`, `sse_read_timeout`, `httpx_client_factory` (which must now return an `httpx2.AsyncClient`), `auth` (now `httpx2.Auth | None`), and `on_session_created`. Only the streamable HTTP transport dropped its transport-level parameters; `StreamableHTTPTransport(url)` now takes just the URL. ### `StreamableHTTPTransport.protocol_version` attribute removed @@ -1969,6 +2268,57 @@ The WebSocket transport has been removed: `mcp.client.websocket.websocket_client ## OAuth and server auth +### Unchanged auth surfaces + +Most of the auth API carries over from v1; if a survey of your `mcp.client.auth` / +`mcp.server.auth` usage only turns up the changes documented in the sections below, that is +expected. In particular: + +- **OAuth client core.** `OAuthClientProvider` keeps its v1 constructor apart from the + [removed `timeout`](#timeout-parameter-removed-from-oauthclientprovider) and the + [`AuthorizationCodeResult`-returning `callback_handler`](#oauth-callback_handler-returns-authorizationcoderesult), + and gains an optional `validate_resource_url` callback for overriding the RFC 8707 resource + check: `OAuthClientProvider(server_url, client_metadata, storage, redirect_handler=None, + callback_handler=None, client_metadata_url=None, validate_resource_url=None)`. `PKCEParameters`, + `TokenStorage`, and the exceptions exported by `mcp.client.auth` (`OAuthFlowError`, + `OAuthTokenError`, `OAuthRegistrationError`) are unchanged; import `OAuthTokenError` from + `mcp.client.auth`, since `mcp.client.auth.extensions.client_credentials` no longer happens to + re-export it. `provider.context` (`OAuthContext`: `current_tokens`, `token_expiry_time`, + `is_token_valid()`, `can_refresh_token()`, `clear_tokens()`) also carries over, but remains an + internal object with no stability guarantee. +- **Client-credentials extension.** `ClientCredentialsOAuthProvider`, + `PrivateKeyJWTOAuthProvider`, `SignedJWTParameters`, and `static_assertion_provider` in + `mcp.client.auth.extensions.client_credentials` keep their v1 signatures apart from the + [`scopes=` → `scope=` rename](#scopes-renamed-to-scope-on-the-client-credentials-providers) + and the [`RFC7523OAuthClientProvider`/`JWTParameters` removal](#rfc7523oauthclientprovider-and-jwtparameters-removed). + Their base class is now `httpx2.Auth` (see + [`httpx` and `httpx-sse` replaced by `httpx2`](#httpx-and-httpx-sse-replaced-by-httpx2)), + and `token_endpoint_auth_method="client_secret_post"` changes the token request body (see + [`client_secret_post` token requests now include `client_id`](#client_secret_post-token-requests-now-include-client_id)). +- **Discovery and registration helpers.** `mcp.client.auth.utils` keeps its v1 helpers + (`build_protected_resource_metadata_discovery_urls`, + `build_oauth_authorization_server_metadata_discovery_urls`, the `handle_*_response` + coroutines, `extract_field_from_www_auth`/`extract_scope_from_www_auth`, + `get_client_metadata_scopes`), retyped from `httpx` to `httpx2` request/response objects. + The additions — `union_scopes`, `validate_metadata_issuer`, + `validate_authorization_response_iss`, `credentials_match_issuer`, and an optional + `client_grant_types` on `get_client_metadata_scopes` — are new, not renames. +- **Resource-server surface.** `TokenVerifier`, `AccessToken`, and + `OAuthAuthorizationServerProvider` (`mcp.server.auth.provider`), `AuthSettings`, + `create_auth_routes`/`create_protected_resource_routes`, + `BearerAuthBackend`/`RequireAuthMiddleware`, `AuthContextMiddleware`/`get_access_token`, + `mcp.shared.auth`, `mcp.shared.auth_utils`, and `MCPServer`'s + `auth=`/`auth_server_provider=`/`token_verifier=` keywords all carry over. `AccessToken` has + had optional `subject` and `claims` fields since v1.27.2, so a subclass that existed only to + add them can be dropped. The SDK-hosted authorization server changes only per + [Stricter client authentication at `/token` and `/revoke`](#stricter-client-authentication-at-token-and-revoke), + plus the additive [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) + identity-assertion pieces (`AuthSettings(identity_assertion_enabled=True)` / + `create_auth_routes(..., identity_assertion_enabled=True)` and the overridable + `OAuthAuthorizationServerProvider.exchange_identity_assertion`, which rejects the grant by + default). The `mcp.shared.auth` metadata models keep their fields, with the additions covered + in the sections below. + ### `RFC7523OAuthClientProvider` and `JWTParameters` removed `RFC7523OAuthClientProvider` (deprecated since 1.23.0) and its `JWTParameters` model have been @@ -2067,6 +2417,19 @@ ClientCredentialsOAuthProvider(..., scopes="read write") ClientCredentialsOAuthProvider(..., scope="read write") ``` +### `client_secret_post` token requests now include `client_id` + +With `token_endpoint_auth_method="client_secret_post"`, the token request body now carries both `client_id` and `client_secret`, as [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §2.3.1 requires; v1 sent only `client_secret`. The authorization-code and refresh requests already carried `client_id`, so the observable difference is the `client_credentials` exchange sent by `ClientCredentialsOAuthProvider(..., token_endpoint_auth_method="client_secret_post")` (plus `resource`/`scope` when configured): + +```text +# v1 +grant_type=client_credentials&client_secret=SECRET +# v2 +grant_type=client_credentials&client_id=CLIENT_ID&client_secret=SECRET +``` + +Authorization servers that require both parameters answered the v1 request with `401 invalid_client`, so under v1 this provider effectively only worked with the default `client_secret_basic`. Drop any manual `client_id` injection or a test that pinned the 401 — the exchange now succeeds as configured. + ### `timeout` parameter removed from `OAuthClientProvider` `OAuthClientProvider` no longer accepts a `timeout` argument, and `OAuthContext.timeout` is gone. The value was stored but never read, so it never bounded anything — removing it changes nothing at runtime. @@ -2083,7 +2446,7 @@ provider = OAuthClientProvider(server_url, client_metadata, storage, timeout=120 provider = OAuthClientProvider(server_url, client_metadata, storage) ``` -If you passed `timeout` to bound how long you wait for the user to complete authorization, apply that bound where you actually wait — inside your `redirect_handler`/`callback_handler`, e.g. `with anyio.fail_after(120): ...`. +If you passed `timeout` to bound how long you wait for the user to complete authorization, apply that bound where you actually wait — inside your `redirect_handler`/`callback_handler`, e.g. `with anyio.fail_after(120): ...`. The full v2 constructor (v1's parameters minus `timeout`, plus a new optional `validate_resource_url` callback) is listed under [Unchanged auth surfaces](#unchanged-auth-surfaces). ### Client rejects authorization server metadata with a mismatched `issuer` @@ -2225,6 +2588,8 @@ LEGACY_CLIENT = OAuthClientInformationFull( 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. +Validation runs when the result is serialized onto the wire, not when the model is constructed: `Tool(name="t", input_schema={})` still constructs, so a fixture that builds such a tool only fails once a `tools/list` handler returns it. Your handler returns normally; the server then logs the `pydantic.ValidationError` (`handler for 'tools/list' returned an invalid result`) and answers the request with `INTERNAL_ERROR`, so the failure shows up on the client, not at the line that built the model. + ### 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). @@ -2279,7 +2644,9 @@ 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`), keeps `raise_exceptions` for surfacing server-side errors and `read_timeout_seconds` (now a plain `float` of seconds rather than a `timedelta`; see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), and adds `mode` to control version negotiation (`'auto'` by default; `'legacy'` reproduces v1's initialize-only handshake). +`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`), keeps `raise_exceptions` for surfacing server-side errors and `read_timeout_seconds` (now a plain `float` of seconds rather than a `timedelta`; see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), and adds `mode` to control version negotiation (`'auto'` by default; `'legacy'` reproduces v1's initialize-only handshake). Its method signatures are not identical to `ClientSession`'s: the `list_*()` methods paginate with a plain `cursor=` keyword rather than `params=PaginatedRequestParams(...)` (see [`cursor` parameter removed from `ClientSession` list methods](#cursor-parameter-removed-from-clientsession-list-methods)). + +One consequence to plan for: unlike the old helper, `Client(server)` negotiates 2026-07-28 by default, where server-initiated requests are refused. A v1 test that drove `ctx.elicit()`, `ctx.session.create_message()`, or `list_roots()` through the helper now fails with `NoBackChannelError` even with the callbacks set. Pin the era — `Client(server, mode="legacy", sampling_callback=..., elicitation_callback=..., list_roots_callback=...)` — or port the handler to a resolver dependency; see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror). 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`: @@ -2305,6 +2672,20 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve ## Deprecations +Every deprecation below is a runtime warning as well as a type-checker one: deprecated methods and helpers emit `mcp.MCPDeprecationWarning` on each call, and the deprecated `Server(...)` constructor parameters (`on_set_logging_level`, `on_roots_list_changed`, `on_progress`) emit it at construction time. The category subclasses `UserWarning`, not `DeprecationWarning`, so it is visible by default; [Deprecated features](deprecated.md) has the full list and each replacement. + +Under pytest's `filterwarnings = ["error"]`, that warning becomes an exception at the first deprecated call. Inside an `@mcp.tool()` handler the exception is caught like any other and returned as `CallToolResult(is_error=True)` (`Error executing tool ...: The logging capability is deprecated as of 2026-07-28 (SEP-2577).`), which reads as a failing tool rather than a warning. Keep the warnings visible but non-fatal with: + +```toml +[tool.pytest.ini_options] +filterwarnings = [ + "error", + "default::mcp.MCPDeprecationWarning", +] +``` + +Use `"ignore::mcp.MCPDeprecationWarning"` (or the `warnings.filterwarnings` call [below](#roots-sampling-and-logging-methods-deprecated-sep-2577)) to silence them instead, and wrap a test that deliberately exercises a deprecated path in `pytest.warns(MCPDeprecationWarning)`. + ### Client resource-subscription methods deprecated (SEP-2575) [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2575) removes `resources/subscribe` and `resources/unsubscribe` from the 2026-07-28 wire; per-URI subscriptions travel in the `subscriptions/listen` filter instead. The client verbs now carry `typing_extensions.deprecated`: @@ -2312,7 +2693,7 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve - `Client.subscribe_resource()` / `Client.unsubscribe_resource()` - `ClientSession.subscribe_resource()` / `ClientSession.unsubscribe_resource()` -They keep working against 2025-era servers; a 2026-07-28 server answers them with `-32601` (method not found). Migrate to the listen driver: +Calling them emits `mcp.MCPDeprecationWarning`. They keep working against 2025-era servers — where they are still the only way to watch a resource, so code that talks to 2025-11-25 (or earlier) servers should keep calling them and filter the warning rather than migrate. A 2026-07-28 server answers them with `-32601` (method not found); on those connections migrate to the listen driver, `Client.listen()`: ```python async with client.listen(resource_subscriptions=["board://sprint"]) as sub: @@ -2320,12 +2701,14 @@ async with client.listen(resource_subscriptions=["board://sprint"]) as sub: ... ``` -See the [Subscriptions](client/subscriptions.md#watching-the-stream) page under Clients for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`). +On a bare `ClientSession` (no high-level `Client`), the same stream is `listen(session, resource_subscriptions=[...])` from `mcp.client.subscriptions` — the function `Client.listen()` wraps — which requires a 2026-07-28 connection and raises `ListenNotSupportedError` on an older one. See the [Subscriptions](client/subscriptions.md#watching-the-stream) page under Clients for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`). ### Roots, Sampling, and Logging methods deprecated (SEP-2577) [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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 deprecation and the back-channel are separate axes. Sampling, roots, and push elicitation are server-initiated *requests*, so on a connection negotiated at 2026-07-28 — including the default in-process `Client(server)` — `create_message()`, `list_roots()`, and `elicit()` / `elicit_form()` raise `NoBackChannelError` rather than working with a warning; the resolver markers `Sample`, `ListRoots`, and `Elicit` are the era-portable form (see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror)). + 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()` @@ -2334,7 +2717,7 @@ The user-facing methods for these features now carry `typing_extensions.deprecat 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: +To silence the warnings in code, filter the category: ```python import warnings @@ -2355,8 +2738,52 @@ On the server side, prefer the new dispatcher-agnostic `ServerSession.report_pro Everything below this heading describes behavior that only activates on connections negotiated at protocol 2026-07-28 or later. Migrated v1 code talking to 2025-11-25 (or -earlier) peers is unaffected. It is collected here so the rest of this guide stays -focused on the v1-to-v2 upgrade itself. +earlier) peers is unaffected — the notable exception being an in-process `Client(server)`, +which negotiates 2026-07-28 by default (first subsection below). It is collected here so the +rest of this guide stays focused on the v1-to-v2 upgrade itself. + +### Server-initiated sampling, elicitation, and roots raise `NoBackChannelError` + +The 2026-07-28 protocol has no server-initiated requests, so a handler that reaches back to the client mid-request — `ctx.elicit()`, `ctx.elicit_url()`, `ctx.session.create_message()`, `ctx.session.list_roots()`, or any other `ServerSession` request helper — raises `NoBackChannelError` on such a connection instead of sending. An in-process `Client(server)` negotiates 2026-07-28 by default (see [`Client` defaults to `mode='auto'`](#client-defaults-to-modeauto)), so the first smoke test of an unchanged v1 sampling or elicitation tool fails, and setting `sampling_callback=` / `elicitation_callback=` on the client changes nothing because no request ever reaches the client. + +`NoBackChannelError` lives in `mcp.shared.exceptions` and subclasses `MCPError` (code `-32600`, message `Cannot send '': this transport context has no back-channel for server-initiated requests.`). Raised inside an `@mcp.tool()` it reaches the client as a top-level JSON-RPC error, not `CallToolResult(is_error=True)` — see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error) — and the [Troubleshooting](troubleshooting.md) page walks through the client-side traceback. The same exception is raised on a legacy session against a `stateless_http=True` server, where v1 dropped the message and stalled ([`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). Notifications never raise it: `send_log_message()`, `send_tool_list_changed()`, and the other notification helpers are dropped with a debug log where no channel exists, and `UrlElicitationRequiredError` from a tool is unaffected (it is an error response, not a request). + +Two ways to migrate: + +- **Keep the push behavior for now** by connecting at a pre-2026 version: `Client(server, mode="legacy", sampling_callback=..., elicitation_callback=...)` reproduces v1's `initialize` handshake, in-process included; a lowlevel `ClientSession` you `initialize()` yourself already negotiates a 2025-era version, so hand-rolled test harnesses are unaffected. Sampling and roots stay deprecated on this path ([SEP-2577](#roots-sampling-and-logging-methods-deprecated-sep-2577)). +- **Port to the era-portable form**: return the question instead of pushing it — a `Resolve(...)`-backed parameter whose resolver returns `Elicit`, `Sample`, or `ListRoots` (all in `mcp.server.mcpserver`). The SDK elicits directly on a legacy connection and drives the `InputRequiredResult` multi-round trip at 2026-07-28, with one tool body for both eras; see [Dependencies](handlers/dependencies.md), [Multi-round-trip requests](handlers/multi-round-trip.md), and [Serving legacy clients](run/legacy-clients.md). + +**Before (v1):** + +```python +@mcp.tool() +async def book_table(date: str, ctx: Context) -> str: + result = await ctx.elicit(f"Book a table for {date}?", schema=Confirmation) + if result.action == "accept" and result.data.confirm: + return f"Booked for {date}." + return "No booking made." +``` + +**After (v2), era-portable:** + +```python +from typing import Annotated + +from mcp.server.mcpserver import Elicit, Resolve + + +async def ask_to_confirm(date: str) -> Elicit[Confirmation]: + return Elicit(f"Book a table for {date}?", Confirmation) + + +@mcp.tool() +async def book_table(date: str, answer: Annotated[Confirmation, Resolve(ask_to_confirm)]) -> str: + if answer.confirm: + return f"Booked for {date}." + return "No booking made." +``` + +The client's same `elicitation_callback` answers both; the resolver lets the server *return* the question instead of pushing it. ### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) From 5e479ca0ee76d596d3db367ae887633277643c21 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:59:19 +0000 Subject: [PATCH 2/2] docs: drop cross-link whose anchor slug differs between checkers The "client request timeouts" heading contains a leading-hyphen number, which GitHub-style slugging (markdownlint MD051) and python-markdown (the site build) render as different anchors, so any fragment link to it fails one of the two strict checks. Refer to the section in prose instead. No-Verification-Needed: doc-only change --- docs/migration.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index b7789ee29..55217179c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -150,9 +150,9 @@ subtle ways (server-initiated messages stop arriving) instead of raising immedia Retry and error-classification logic keyed to HTTP status codes needs a look too: through the SDK's client, timeouts and non-2xx responses surface as `MCPError` with JSON-RPC codes, -not `408`s or `httpx.HTTPStatusError` — see -[Client request timeouts now raise `-32001`](#client-request-timeouts-now-raise--32001-request_timeout-instead-of-408) -and [Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors](#streamable-http-non-2xx-responses-now-surface-as-per-request-json-rpc-errors). +not `408`s or `httpx.HTTPStatusError` — see the client request timeouts section +(`REQUEST_TIMEOUT`, `-32001`) under [Clients](#clients) and +[Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors](#streamable-http-non-2xx-responses-now-surface-as-per-request-json-rpc-errors). The SDK's own auth providers made the same move: `OAuthClientProvider`, `ClientCredentialsOAuthProvider`, `PrivateKeyJWTOAuthProvider`, and