d0dc9bdd91
The guide had accumulated entries that are not v1-to-v2 migration items: new v2 feature notes, "what did not change" reassurance sections, spec-adoption commentary, deprecations of APIs that still work, and internal fixes with nothing for a migrator to change. Remove those, including the whole Deprecations group; the two deprecation consequences a migrator does act on (warnings-as-errors test runs, and client calls that die on the default 2026-era connection) fold into the testing section. Correct code that was wrong for v1.x or stale for current main -- including the `mcp.types` alias now being permanent -- and verify every remaining before/after example against the v1.x and main trees. Reframe the 2026-era notes as behavior changes migrated code sees on v2's default connection, fold closely related entries together, standardize on the documented `from mcp.server import MCPServer` and `mcp.types` imports, regenerate the index and suggested-order blocks, and cut history and over-explanation throughout. Also drop two whats-new.md links whose target sections this removes (the strict docs build aborts on missing anchors). No-Verification-Needed: documentation-only change
2459 lines
124 KiB
Markdown
2459 lines
124 KiB
Markdown
# Migration Guide: v1 to v2
|
|
|
|
This guide covers the breaking changes in v2 of the MCP Python SDK and how to update code written against v1.x.
|
|
|
|
## Find your changes
|
|
|
|
Every section heading names the API it affects, so searching this page for the symbol your code uses is the fastest route to the change that broke it. The guide lists changes only: an SDK API not mentioned here behaves as it did in v1.
|
|
|
|
### Changes almost every project hits
|
|
|
|
| Change | First symptom | Section |
|
|
|---|---|---|
|
|
| `FastMCP` renamed to `MCPServer` | `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` | [`FastMCP` renamed](#fastmcp-renamed-to-mcpserver) |
|
|
| Fields renamed from camelCase to snake_case | `AttributeError: 'Tool' object has no attribute 'inputSchema'` | [snake_case fields](#field-names-changed-from-camelcase-to-snake_case) |
|
|
| `mcp.types` names removed | `ImportError: cannot import name 'Content' from 'mcp.types'` | [Removed types](#removed-type-aliases-and-classes) |
|
|
| `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) |
|
|
| Transport parameters moved off the `MCPServer` constructor | `TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'` | [constructor parameters](#transport-parameters-moved-off-the-mcpserver-constructor) |
|
|
| 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) |
|
|
| Lowlevel decorators replaced with `on_*` constructor params | `AttributeError: 'Server' object has no attribute 'list_tools'` | [`on_*` handlers](#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params) |
|
|
| Lowlevel return value wrapping removed | bare list or dict returns fail result validation instead of being wrapped | [wrapping removed](#lowlevel-server-automatic-return-value-wrapping-removed) |
|
|
| Lowlevel tool exceptions no longer become `isError: true` results | clients raise a JSON-RPC error instead of seeing the error text | [tool exceptions](#lowlevel-server-tool-handler-exceptions-no-longer-become-calltoolresultis_errortrue) |
|
|
| In-process test client negotiates 2026-07-28 | sampling/elicitation tests raise `NoBackChannelError`; logging callbacks go quiet | [default connection](#behavior-changes-on-v2s-default-connection) |
|
|
| Deprecated v1 calls emit `MCPDeprecationWarning` | warnings-as-errors test runs fail on unchanged code | [deprecation warnings](#deprecated-v1-calls-now-warn-breaks-warnings-as-errors-test-runs) |
|
|
|
|
### Find your area
|
|
|
|
| If you... | Read |
|
|
|---|---|
|
|
| pin dependencies or use the `mcp` CLI | [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli) |
|
|
| import `mcp.types` or touch protocol types (everyone does) | [Types and wire format](#types-and-wire-format) |
|
|
| run `FastMCP`/`MCPServer` servers | [MCPServer (formerly FastMCP)](#mcpserver-formerly-fastmcp) |
|
|
| use the lowlevel `Server` | [Lowlevel Server](#lowlevel-server), plus [Timeouts take `float` seconds](#timeouts-take-float-seconds-instead-of-timedelta) and [Experimental Tasks support removed](#experimental-tasks-support-removed) under Clients |
|
|
| write client code with `Client` or `ClientSession` | [Clients](#clients), plus [`streamablehttp_client` removed](#streamablehttp_client-removed) under Transports |
|
|
| use stdio or streamable HTTP directly, or maintain a custom transport | [Transports](#transports) |
|
|
| maintain OAuth client auth or a protected server | [OAuth and server auth](#oauth-and-server-auth) |
|
|
| relied on lenient handling of off-schema traffic, or assert on exact wire bytes | [Stricter protocol validation and wire behavior](#stricter-protocol-validation-and-wire-behavior) |
|
|
| test against in-memory server/client pairs | [Testing utilities](#testing-utilities) |
|
|
| use sampling, elicitation, roots, logging, or change notifications from handlers | [Behavior changes on v2's default connection](#behavior-changes-on-v2s-default-connection) |
|
|
|
|
## Suggested migration order
|
|
|
|
1. Update your dependency pins: [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli).
|
|
2. Apply the mechanical renames and import moves: [Types and wire format](#types-and-wire-format).
|
|
3. Port your server surface: [MCPServer (formerly FastMCP)](#mcpserver-formerly-fastmcp) or [Lowlevel Server](#lowlevel-server).
|
|
4. Port your client code: [Clients](#clients).
|
|
5. Update transport setup and auth: [Transports](#transports) and [OAuth and server auth](#oauth-and-server-auth).
|
|
6. Run your tests and check anything that now errors against [Stricter protocol validation and wire behavior](#stricter-protocol-validation-and-wire-behavior), [Testing utilities](#testing-utilities), and [Behavior changes on v2's default connection](#behavior-changes-on-v2s-default-connection).
|
|
|
|
## Packaging, dependencies, and CLI
|
|
|
|
### Dependency requirements changed
|
|
|
|
A pin or cap in your project that conflicts with a new floor makes dependency resolution fail (uv reports "No solution found when resolving dependencies"), and code that imported a dropped package without declaring it must now depend on it directly:
|
|
|
|
| Dependency | v1.x | v2 | Change |
|
|
|---|---|---|---|
|
|
| anyio | `>=4.5` | `>=4.9` (`>=4.10` on Python 3.14+) | floor raised |
|
|
| pydantic | `>=2.11,<3` (`>=2.12,<3` on Python 3.14+) | `>=2.12` | floor raised on Python <3.14; `<3` cap dropped |
|
|
| sse-starlette | `>=1.6.1` | `>=3.0.0` | floor raised to 3.x |
|
|
| typing-extensions | `>=4.9.0` | `>=4.13.0` | floor raised |
|
|
| pywin32 (Windows) | `>=310` (`>=311` on Python 3.14+) | `>=311` | floor raised on Python <3.14 |
|
|
| httpx | `>=0.27.1,<1.0.0` | removed | see [`httpx` and `httpx-sse` replaced by `httpx2`](#httpx-and-httpx-sse-replaced-by-httpx2) |
|
|
| httpx-sse | `>=0.4` | removed | see above |
|
|
| pydantic-settings | `>=2.5.2` | removed | no longer installed with `mcp`; declare it yourself if you import `pydantic_settings` |
|
|
| websockets (`ws` extra) | `>=15.0.1` | removed | see [WebSocket transport removed](#websocket-transport-removed) |
|
|
| httpx2 | not a dependency | `>=2.5.0` | new required dependency |
|
|
| mcp-types | not a dependency | pinned to the exact `mcp` version | new required dependency; do not pin it separately from `mcp` |
|
|
| opentelemetry-api | not a dependency | `>=1.28.0` | new required dependency; tracing is a no-op until you install an OpenTelemetry SDK ([OpenTelemetry](run/opentelemetry.md)) |
|
|
|
|
Relax any conflicting pin. sse-starlette moves to 3.x, so code that imports `sse_starlette` directly must also port to its 3.x API.
|
|
|
|
### `httpx` and `httpx-sse` replaced by `httpx2`
|
|
|
|
The SDK depends on [`httpx2`](https://pypi.org/project/httpx2/) (a fork of `httpx` with server-sent events built in) instead of `httpx` and `httpx-sse`, and installs neither. Parameter lists are unchanged; every `httpx` type in the client API is now its `httpx2` equivalent: `http_client=` on `streamable_http_client` is an `httpx2.AsyncClient` (as is whatever your `sse_client(httpx_client_factory=...)` returns), and `auth=` on `sse_client` is an `httpx2.Auth`, so a custom `httpx.Auth` subclass re-bases onto `httpx2.Auth`. If you built the client only to get default behavior, `Client("http://.../mcp")` needs no HTTP client at all; see [Client transports](client/transports.md).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
import httpx
|
|
|
|
http_client = httpx.AsyncClient(headers={"Authorization": "Bearer <token>"})
|
|
try:
|
|
...
|
|
except httpx.ConnectError:
|
|
...
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
import httpx2
|
|
|
|
http_client = httpx2.AsyncClient(headers={"Authorization": "Bearer <token>"})
|
|
try:
|
|
...
|
|
except httpx2.ConnectError:
|
|
...
|
|
```
|
|
|
|
`httpx2` keeps the `httpx` API, so usually only the import name changes; direct `httpx-sse` usage becomes `httpx2.EventSource` or `AsyncClient.sse()`. Code that imported `httpx` or `httpx_sse` only because v1 pulled them in now fails with `ModuleNotFoundError: No module named 'httpx'`: declare `httpx` in your own dependencies (the two packages install side by side) or port those calls.
|
|
|
|
The transports raise `httpx2` exceptions (`httpx2.ConnectError`, `httpx2.HTTPStatusError`, and so on), so an old `except httpx.ConnectError:` clause silently never matches if `httpx` is still installed; audit `except` clauses, `isinstance` checks, and test fixtures (`pytest.raises(httpx.ConnectError)`, `httpx.MockTransport`, test-only `httpx.Auth` subclasses) along with the imports.
|
|
|
|
The objects are not interchangeable either: an `httpx.AsyncClient` passed as `http_client=` still connects, but its server-to-client GET stream never opens, so server-initiated messages silently stop arriving.
|
|
|
|
The SDK's own auth providers made the same move: `OAuthClientProvider`, `ClientCredentialsOAuthProvider`, and `PrivateKeyJWTOAuthProvider` subclass `httpx2.Auth` (v1: `httpx.Auth`), so the client you attach one to must be an `httpx2.AsyncClient`; `httpx.AsyncClient(auth=provider)` fails at construction with `TypeError: Invalid "auth" argument`. See [OAuth clients](client/oauth-clients.md).
|
|
|
|
Log filters and instrumentation keyed to the `httpx`/`httpcore` names no longer see the SDK's traffic: the loggers are `httpx2` and `httpcore2.*` (target `logging.getLogger("httpx2")` and `logging.getLogger("httpcore2")`), the default User-Agent is `python-httpx2/<version>`, and OpenTelemetry's httpx instrumentation does not hook `httpx2`.
|
|
|
|
TLS verification changes too: `httpx` validated certificates against the bundled `certifi` CA list, while `httpx2` validates against the operating-system trust store via [`truststore`](https://pypi.org/project/truststore/). With no usable system CA store (some minimal containers), point `SSL_CERT_FILE` or `SSL_CERT_DIR` at a CA bundle (checked before the system store) or pass an explicit `verify=ssl_context` to your `httpx2.AsyncClient`.
|
|
|
|
## Types and wire format
|
|
|
|
### `mcp.types` moved to the `mcp-types` package
|
|
|
|
The protocol wire types now ship in a separate distribution, `mcp-types` (import package `mcp_types`, [API reference](api/mcp_types/index.md)); `mcp` depends on the identically-versioned `mcp-types`, so it installs with `mcp`. `mcp.types` is a permanent alias of `mcp_types` (every name is the same object), so `import mcp.types as types`, `from mcp.types import ...`, `from mcp import types`, and the top-level re-exports (`from mcp import Tool`) all still work. Keep importing through `mcp`; import `mcp_types` directly only in a project that depends on `mcp-types` without the SDK.
|
|
|
|
The one module that did move is `mcp.shared.version`: its constants live in `mcp.types.version` and changed meaning as well, see [`LATEST_PROTOCOL_VERSION` and `SUPPORTED_PROTOCOL_VERSIONS` changed](#latest_protocol_version-and-supported_protocol_versions-changed).
|
|
|
|
### Removed type aliases and classes
|
|
|
|
These `mcp.types` names are gone:
|
|
|
|
| Removed | Replacement |
|
|
|---------|-------------|
|
|
| `Content` | `ContentBlock` |
|
|
| `ResourceReference` | `ResourceTemplateReference` |
|
|
| `Cursor` | `str` |
|
|
| `AnyFunction` | `Callable[..., Any]` |
|
|
| `MethodT`, `RequestParamsT`, `NotificationParamsT` | internal `TypeVar`s; no longer exported |
|
|
| `ClientRequestType`, `ClientNotificationType`, `ClientResultType`, `ServerRequestType`, `ServerNotificationType`, `ServerResultType` | the union now has the bare name (`ClientRequest`, `ClientNotification`, `ClientResult`, `ServerRequest`, `ServerNotification`, `ServerResult`); see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation) |
|
|
| `TaskExecutionMode`, `TASK_FORBIDDEN`, `TASK_OPTIONAL`, `TASK_REQUIRED`, `TASK_STATUS_*` | string literals; `TaskStatus` remains as the literal-union type; see [Experimental Tasks support removed](#experimental-tasks-support-removed) |
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.types import Content, Cursor, ResourceReference
|
|
|
|
def render(block: Content, cursor: Cursor | None = None) -> str: ...
|
|
|
|
ref = ResourceReference(type="ref/resource", uri="file:///{name}")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import ContentBlock, ResourceTemplateReference
|
|
|
|
def render(block: ContentBlock, cursor: str | None = None) -> str: ...
|
|
|
|
ref = ResourceTemplateReference(type="ref/resource", uri="file:///{name}")
|
|
```
|
|
|
|
### Field names changed from camelCase to snake_case
|
|
|
|
Every field on the protocol type models (`mcp.types`) is now snake_case in Python. The JSON wire format is unchanged: the SDK still sends camelCase through Pydantic aliases.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
result = await session.call_tool("my_tool", {"x": 1})
|
|
if result.isError:
|
|
...
|
|
|
|
tools = await session.list_tools()
|
|
cursor = tools.nextCursor
|
|
schema = tools.tools[0].inputSchema
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
result = await session.call_tool("my_tool", {"x": 1})
|
|
if result.is_error:
|
|
...
|
|
|
|
tools = await session.list_tools()
|
|
cursor = tools.next_cursor
|
|
schema = tools.tools[0].input_schema
|
|
```
|
|
|
|
Common renames:
|
|
|
|
| v1 (camelCase) | v2 (snake_case) |
|
|
|-----------------|-----------------|
|
|
| `inputSchema` | `input_schema` |
|
|
| `outputSchema` | `output_schema` |
|
|
| `isError` | `is_error` |
|
|
| `nextCursor` | `next_cursor` |
|
|
| `mimeType` | `mime_type` |
|
|
| `structuredContent` | `structured_content` |
|
|
| `serverInfo` | `server_info` |
|
|
| `protocolVersion` | `protocol_version` |
|
|
| `uriTemplate` | `uri_template` |
|
|
| `listChanged` | `list_changed` |
|
|
| `progressToken` | `progress_token` |
|
|
|
|
Rename attribute access and constructor kwargs alike: `tool.input_schema`, `Tool(input_schema={...})`. A leftover camelCase kwarg such as `Tool(inputSchema={...})` still runs but fails type checking. Parsing is unaffected: `model_validate()` accepts camelCase wire JSON and snake_case dumps alike.
|
|
|
|
**If you serialize models yourself, pass `by_alias=True`.** `model_dump()` and `model_dump_json()` now silently emit snake_case keys instead of the camelCase wire format:
|
|
|
|
```python
|
|
tool.model_dump() # {"name": ..., "input_schema": ...}
|
|
tool.model_dump(by_alias=True, mode="json") # {"name": ..., "inputSchema": ...} (wire format)
|
|
```
|
|
|
|
### Extra fields on MCP types are no longer preserved
|
|
|
|
v1 protocol models set `extra="allow"`, so unknown fields passed to a constructor or received from a peer were stored and re-serialized. v2 models silently drop them during validation: the data no longer round-trips, and reading it back as an attribute (`result.customField`) raises `AttributeError`. Carry custom data in `_meta` instead.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.types import CallToolResult
|
|
|
|
result = CallToolResult.model_validate({"content": [], "customField": "x"})
|
|
result.model_dump()["customField"] # 'x'
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import CallToolResult
|
|
|
|
result = CallToolResult.model_validate({"content": [], "customField": "x"})
|
|
"customField" in result.model_dump() # False
|
|
|
|
result = CallToolResult(content=[], _meta={"myapp/custom": "x"})
|
|
result.meta # {'myapp/custom': 'x'}
|
|
```
|
|
|
|
### Resource URI type changed from `AnyUrl` to `str`
|
|
|
|
The `uri` field on resource types and the `uri` parameter of the client's `read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` are now plain `str`. Passing an `AnyUrl` object raises `ValidationError` ("Input should be a valid string"); wrap it in `str(...)`. URIs you read back are strings too, so attribute access like `uri.scheme` or `uri.host` raises `AttributeError`; parse with `urllib.parse`.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from pydantic import AnyUrl
|
|
from mcp.types import Resource
|
|
|
|
resource = Resource(name="test", uri=AnyUrl("resource://items/1"))
|
|
scheme = resource.uri.scheme
|
|
result = await session.read_resource(AnyUrl("resource://items/1"))
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from urllib.parse import urlparse
|
|
|
|
from mcp.types import Resource
|
|
|
|
resource = Resource(name="test", uri="resource://items/1")
|
|
scheme = urlparse(resource.uri).scheme
|
|
result = await session.read_resource("resource://items/1")
|
|
```
|
|
|
|
Affected `uri` fields: `Resource` (and `ResourceLink`), `ResourceContents` (`TextResourceContents`, `BlobResourceContents`), `ReadResourceRequestParams`, `SubscribeRequestParams`, `UnsubscribeRequestParams`, `ResourceUpdatedNotificationParams`, and the `MCPServer` resource classes (`TextResource`, `FileResource`, ...).
|
|
|
|
The string is no longer validated or normalized: relative URIs such as `users/me`, which v1 rejected, are accepted, and `https://example.com` is sent as given rather than as `https://example.com/`.
|
|
|
|
### Replace `RootModel` by union types with `TypeAdapter` validation
|
|
|
|
`ClientRequest`, `ServerRequest`, `ClientNotification`, `ServerNotification`, `ClientResult`, `ServerResult`, and `JSONRPCMessage` are now plain `X | Y` unions of the concrete pydantic classes, not `RootModel` subclasses, so `.root`, `model_validate()`, and the wrapper call are gone. Validate raw data with the matching `TypeAdapter`:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.types import ClientRequest, ServerNotification
|
|
|
|
request = ClientRequest.model_validate(data)
|
|
actual_request = request.root
|
|
|
|
notification = ServerNotification.model_validate(data)
|
|
actual_notification = notification.root
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import client_request_adapter, server_notification_adapter
|
|
|
|
request = client_request_adapter.validate_python(data) # already the concrete type
|
|
notification = server_notification_adapter.validate_python(data)
|
|
```
|
|
|
|
| Union | Adapter (exported from `mcp.types`) |
|
|
|-------|--------------------------------------|
|
|
| `ClientRequest` | `client_request_adapter` |
|
|
| `ServerRequest` | `server_request_adapter` |
|
|
| `ClientNotification` | `client_notification_adapter` |
|
|
| `ServerNotification` | `server_notification_adapter` |
|
|
| `ClientResult` | `client_result_adapter` |
|
|
| `ServerResult` | `server_result_adapter` |
|
|
| `JSONRPCMessage` | `jsonrpc_message_adapter` |
|
|
|
|
Constructing a value drops the wrapper only; pass the member (and its `params`) directly:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.types import ClientNotification, ClientRequest, InitializedNotification, ListToolsRequest, ListToolsResult
|
|
|
|
await session.send_notification(ClientNotification(InitializedNotification()))
|
|
result = await session.send_request(ClientRequest(ListToolsRequest()), ListToolsResult)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import (
|
|
CancelledNotification,
|
|
CancelledNotificationParams,
|
|
InitializedNotification,
|
|
ListToolsRequest,
|
|
ListToolsResult,
|
|
)
|
|
|
|
await session.send_notification(InitializedNotification())
|
|
result = await session.send_request(ListToolsRequest(), ListToolsResult)
|
|
await session.send_notification(
|
|
CancelledNotification(params=CancelledNotificationParams(request_id=request_id, reason="timeout"))
|
|
)
|
|
```
|
|
|
|
Values the SDK hands you are the member instances themselves. `isinstance(msg, ServerNotification)`, `isinstance(msg, LoggingMessageNotification)`, and `match`/`case` on the member classes keep working; `.root` accesses do not (`AttributeError: 'LoggingMessageNotification' object has no attribute 'root'`), so a `message_handler` becomes:
|
|
|
|
```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)`. A parsed `JSONRPCError` may now carry `id=None` (JSON-RPC 2.0 allows it; v1 rejected such messages), so `JSONRPCError.id` is typed `RequestId | None` and code that read it as a definite `str | int` needs a `None` check.
|
|
|
|
### `RequestParams.Meta` replaced with the `RequestParamsMeta` TypedDict
|
|
|
|
The nested `RequestParams.Meta` Pydantic model is now the `RequestParamsMeta` TypedDict (`from mcp.types import RequestParamsMeta`), and the nested `NotificationParams.Meta` class is gone: notification `_meta` is a plain `dict[str, Any]`. `ctx.meta` (lowlevel handlers, client callbacks) and `ctx.request_context.meta` (`MCPServer` handlers) are dicts, so attribute access becomes key access, `progressToken` becomes the optional `progress_token` key, and `_meta` you built with `types.RequestParams.Meta(...)` or `types.NotificationParams.Meta(...)` is passed as a plain dict. The wire format is unchanged.
|
|
|
|
If you only read `progressToken` to send progress, call `report_progress()` instead: it is a no-op when the caller did not request progress and also delivers on the default in-process `Client(server)`, where `ctx.meta` carries no `progress_token` even when the caller passed `progress_callback` (see [Progress](handlers/progress.md)).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
ctx = server.request_context
|
|
if ctx.meta and ctx.meta.progressToken:
|
|
await ctx.session.send_progress_notification(ctx.meta.progressToken, 0.5, 100)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
await ctx.session.report_progress(0.5, 100) # lowlevel handler, ctx: ServerRequestContext
|
|
# in an MCPServer handler with ctx: Context:
|
|
await ctx.report_progress(0.5, 100)
|
|
```
|
|
|
|
For other `_meta` keys use dictionary access; `meta` is still `None` when the request carries no `_meta`:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
trace = ctx.meta.traceparent # extra keys were attributes
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
trace = (ctx.meta or {}).get("traceparent")
|
|
```
|
|
|
|
### `LATEST_PROTOCOL_VERSION` and `SUPPORTED_PROTOCOL_VERSIONS` changed
|
|
|
|
Both constants moved from `mcp.shared.version` to `mcp.types.version` (see [`mcp.types` moved to the `mcp-types` package](#mcptypes-moved-to-the-mcp-types-package)) and changed under the same name:
|
|
|
|
- `LATEST_PROTOCOL_VERSION` was `"2025-11-25"`, the version the v1 client offered in `initialize`. In v2 it is `"2026-07-28"`, the newest revision the SDK speaks, which the `initialize` handshake never negotiates; `from mcp.types import LATEST_PROTOCOL_VERSION` still imports, so the value changes silently. Where your code used it as the handshake version (a hand-built `initialize`, or a comparison against the negotiated version), use `LATEST_HANDSHAKE_VERSION` (`"2025-11-25"`).
|
|
- `SUPPORTED_PROTOCOL_VERSIONS` is now a `tuple` (was a `list`) with `"2026-07-28"` appended: membership tests still pass, but `[-1]` is now `"2026-07-28"` and list operations such as `+ [...]` raise `TypeError`. The handshake-only set, with v1's four values, is `HANDSHAKE_PROTOCOL_VERSIONS`.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.shared.version import LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION
|
|
|
|
# LATEST_HANDSHAKE_VERSION == "2025-11-25", the value v1 called LATEST_PROTOCOL_VERSION
|
|
```
|
|
|
|
### `McpError` renamed to `MCPError`
|
|
|
|
Hard rename, no compatibility alias; import from `mcp` or `mcp.shared.exceptions` as before. `e.error` still holds the `ErrorData`, so `e.error.code`/`e.error.message` keep working; `e.code`, `e.message`, and `e.data` are new shortcuts.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.shared.exceptions import McpError
|
|
|
|
try:
|
|
result = await session.call_tool("my_tool")
|
|
except McpError as e:
|
|
print(f"Error: {e.error.message}")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp import MCPError
|
|
|
|
try:
|
|
result = await session.call_tool("my_tool")
|
|
except MCPError as e:
|
|
print(f"Error: {e.message}")
|
|
```
|
|
|
|
The constructor now takes `code`, `message`, and optional `data` instead of an `ErrorData` (the v1 form raises `TypeError`). If you already hold an `ErrorData`, use `MCPError.from_error_data(error)`.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.shared.exceptions import McpError
|
|
from mcp.types import INVALID_REQUEST, ErrorData
|
|
|
|
raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input"))
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp import MCPError
|
|
from mcp.types import INVALID_REQUEST
|
|
|
|
raise MCPError(INVALID_REQUEST, "bad input")
|
|
```
|
|
|
|
## MCPServer (formerly FastMCP)
|
|
|
|
### `FastMCP` renamed to `MCPServer`
|
|
|
|
`FastMCP` is now `MCPServer`, and the `mcp.server.fastmcp` subpackage is now `mcp.server.mcpserver` with the same internal layout (the old paths are removed, not deprecated).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.fastmcp import Context, FastMCP
|
|
|
|
mcp = FastMCP("Demo")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server.mcpserver import Context, MCPServer
|
|
|
|
mcp = MCPServer("Demo")
|
|
```
|
|
|
|
Other imports move the same way:
|
|
|
|
- `Image`, `Audio`, `Icon` — from `mcp.server.mcpserver` (`Icon`'s `mimeType` field is now `mime_type`; see [snake_case renames](#field-names-changed-from-camelcase-to-snake_case))
|
|
- `Message`, `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base`
|
|
- `ToolError`, `ResourceError`, `MCPServerError` (was `FastMCPError`) — from `mcp.server.mcpserver.exceptions`; `ValidationError` is removed (the SDK never raised it)
|
|
|
|
The `ctx.fastmcp` property is now `ctx.mcp_server`. Beyond the changes in this group, the `@mcp.tool()`/`@mcp.resource()`/`@mcp.prompt()` decorator surface, handler bodies and their return-value wrapping, the `lifespan=` hook, and `mcp.run()` port with only the import change.
|
|
|
|
### Default server name and version changed
|
|
|
|
A nameless `FastMCP()` reported `serverInfo.name == "FastMCP"`; a nameless `MCPServer()` reports `"mcp-server"`. A server without its own version (every v1 `FastMCP`, which had no `version` parameter, and a lowlevel `Server` given none) reported the installed `mcp` package version as `serverInfo.version`; v2 reports an empty string. Nothing raises, but clients keying off `serverInfo` and tests asserting on the initialize result see the new values, so set both explicitly:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
mcp = FastMCP() # serverInfo.name == "FastMCP", version == installed mcp version
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server import MCPServer
|
|
|
|
mcp = MCPServer("my-server", version="1.2.3")
|
|
```
|
|
|
|
The lowlevel `Server` takes the same fix: `Server("my-server", version="1.2.3")`.
|
|
|
|
### `MCPServer` constructor: positional parameter order changed
|
|
|
|
The positional order is now `name, title, description, instructions, website_url, icons, version` (v1: `name, instructions, website_url, icons`). A v1 call that passed `instructions` as the second positional argument still runs on v2, but the string lands in `title`: the server reports it as `serverInfo.title` and clients receive no `instructions`.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
# Second positional parameter is instructions
|
|
mcp = FastMCP("Demo", "You answer questions about the weather.")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server import MCPServer
|
|
|
|
mcp = MCPServer("Demo", instructions="You answer questions about the weather.")
|
|
```
|
|
|
|
Keep `name` positional and pass everything else by keyword.
|
|
|
|
### `mount_path` parameter removed from `MCPServer`
|
|
|
|
`mount_path` is gone from the constructor, `Settings`, `run()`, `run_sse_async()`, and `sse_app()`: passing it raises `TypeError`, and `mcp.settings.mount_path = ...` raises `ValueError`. The SSE transport already prefixes its message endpoint with the ASGI `root_path` that Starlette's `Mount` sets, so delete `mount_path` and mount as before.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from starlette.applications import Starlette
|
|
from starlette.routing import Mount
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
github_mcp = FastMCP("GitHub", mount_path="/github")
|
|
search_mcp = FastMCP("Search")
|
|
search_mcp.settings.mount_path = "/search"
|
|
curl_mcp = FastMCP("Curl")
|
|
|
|
app = Starlette(
|
|
routes=[
|
|
Mount("/github", app=github_mcp.sse_app()),
|
|
Mount("/search", app=search_mcp.sse_app()),
|
|
Mount("/curl", app=curl_mcp.sse_app("/curl")),
|
|
]
|
|
)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from starlette.applications import Starlette
|
|
from starlette.routing import Mount
|
|
|
|
from mcp.server import MCPServer
|
|
|
|
github_mcp = MCPServer("GitHub")
|
|
search_mcp = MCPServer("Search")
|
|
curl_mcp = MCPServer("Curl")
|
|
|
|
app = Starlette(
|
|
routes=[
|
|
Mount("/github", app=github_mcp.sse_app()),
|
|
Mount("/search", app=search_mcp.sse_app()),
|
|
Mount("/curl", app=curl_mcp.sse_app()),
|
|
]
|
|
)
|
|
```
|
|
|
|
`mcp.run(transport="sse", mount_path="/x")` becomes `mcp.run(transport="sse")`. See [Add to an existing app](run/asgi.md) for mounting details.
|
|
|
|
### Transport parameters moved off the `MCPServer` constructor
|
|
|
|
Transport options are no longer constructor arguments: `MCPServer(..., json_response=True)` raises `TypeError: MCPServer.__init__() got an unexpected keyword argument 'json_response'`. Pass them where the server starts instead: `run(transport, ...)`, or `streamable_http_app(...)` / `sse_app(...)` when you build the ASGI app yourself (same options minus `port`, which belongs to whatever serves the app).
|
|
|
|
Moved: `host`, `port`, `sse_path`, `message_path`, `streamable_http_path`, `json_response`, `stateless_http`, `max_request_body_size`, `event_store`, `retry_interval`, `transport_security`. `mount_path` is removed, not moved. `json_response=True` and `stateless_http=True` also change how server-to-client requests fail, see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
mcp = FastMCP("Demo", json_response=True, stateless_http=True)
|
|
mcp.run(transport="streamable-http")
|
|
|
|
# or, for SSE:
|
|
mcp = FastMCP("Server", host="0.0.0.0", port=9000, sse_path="/events")
|
|
mcp.run(transport="sse")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server import MCPServer
|
|
|
|
mcp = MCPServer("Demo")
|
|
mcp.run(transport="streamable-http", json_response=True, stateless_http=True)
|
|
|
|
# or, for SSE:
|
|
mcp = MCPServer("Server")
|
|
mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events")
|
|
```
|
|
|
|
For a mounted app the options go on the app method: `FastMCP("App", json_response=True).streamable_http_app()` becomes `MCPServer("App").streamable_http_app(json_response=True)` ([Add to an existing app](run/asgi.md)).
|
|
|
|
The moved fields are gone from `mcp.settings` too: `mcp.settings.port = 9000` now raises `ValueError: "Settings" object has no field "port"`. Nothing on the server object carries transport configuration any more, so if the module that builds the app is not the one that configures the server, carry the keywords yourself, e.g. `build_app = functools.partial(mcp.streamable_http_app, json_response=True)`.
|
|
|
|
DNS-rebinding protection is now armed from the `host=` these methods receive (default `127.0.0.1`) rather than from the constructor. v1 code that passed `host="0.0.0.0"` or `transport_security=` to `FastMCP(...)` so a mounted or externally-served app accepts a real hostname must pass it at the new call site; otherwise non-localhost `Host` headers are rejected with `421 Invalid Host header`. See [Deploy & scale](run/deploy.md).
|
|
|
|
### Streamable HTTP: lifespan now entered once at manager startup
|
|
|
|
Under streamable HTTP, v1 entered the server's `lifespan` for every session (stateful) or every request (`stateless_http=True`) and exited it when that session or request ended. In v2 `StreamableHTTPSessionManager.run()` enters it once at startup, and the object it yields is the same `ctx.request_context.lifespan_context` for every session and request (see [Lifespan](handlers/lifespan.md)). SSE and stdio are unchanged.
|
|
|
|
State you scoped to a session or request by yielding it from the lifespan (a per-client cache, a per-connection transaction) is now shared between all clients and torn down only at server shutdown. There is no per-session teardown hook: acquire and release such state per call inside the handler, and keep only server-wide resources (connection pool, HTTP client, loaded model) in the lifespan, which need no change.
|
|
|
|
### `MCPServer.get_context()` removed
|
|
|
|
`MCPServer.get_context()` is gone; there is no ambient context to fetch. Declare a `ctx: Context` parameter on the handler and the SDK injects it. Injection only happens for the registered function, so helpers that called `get_context()` now take `ctx` as an argument. See [The Context](handlers/context.md).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
@mcp.tool()
|
|
async def my_tool(x: int) -> str:
|
|
ctx = mcp.get_context()
|
|
await ctx.report_progress(1, 2)
|
|
return str(x)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server.mcpserver import Context
|
|
|
|
@mcp.tool()
|
|
async def my_tool(x: int, ctx: Context) -> str:
|
|
await ctx.report_progress(1, 2)
|
|
return str(x)
|
|
```
|
|
|
|
### Sync handler functions now run on a worker thread
|
|
|
|
v1 called a synchronous (`def`) tool, resource, or prompt function directly on the event-loop thread. v2 runs it on a worker thread via `anyio.to_thread.run_sync()`; `async def` handlers are unchanged. A synchronous handler that depended on the event-loop thread now behaves differently:
|
|
|
|
- `asyncio.get_running_loop()` in the body raises `RuntimeError: no running event loop`.
|
|
- Thread-affine state (a `sqlite3` connection created at import time, thread locals set at startup) is now used from another thread.
|
|
- Concurrent requests to synchronous handlers run in parallel, so unlocked shared state races.
|
|
|
|
Declare the handler `async def` to keep it on the event-loop thread.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
import asyncio
|
|
|
|
@mcp.tool()
|
|
def now_ms() -> int:
|
|
return int(asyncio.get_running_loop().time() * 1000)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
import asyncio
|
|
|
|
@mcp.tool()
|
|
async def now_ms() -> int:
|
|
return int(asyncio.get_running_loop().time() * 1000)
|
|
```
|
|
|
|
### Calling `MCPServer.call_tool()`, `get_prompt()`, and `read_resource()` directly
|
|
|
|
Direct calls to these methods (unit tests, wrapper servers, a handler that calls another) change in two ways.
|
|
|
|
**Return types.** `call_tool()` returns a `CallToolResult` instead of a bare list of content blocks or a `(content, structured_content)` tuple, and every declared return type is widened with `InputRequiredResult` (returned only when a [multi-round-trip](handlers/multi-round-trip.md) handler asks the client for input), so narrow with `isinstance`:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
# (content, structured_content) tuple for tools with an output schema,
|
|
# a bare list of content blocks otherwise
|
|
content, structured = await mcp.call_tool("greet", {"name": "Ada"})
|
|
text = content[0].text
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import CallToolResult, TextContent
|
|
|
|
result = await mcp.call_tool("greet", {"name": "Ada"})
|
|
assert isinstance(result, CallToolResult)
|
|
block = result.content[0]
|
|
assert isinstance(block, TextContent)
|
|
text = block.text
|
|
structured = result.structured_content # {"result": "Hello, Ada!"}
|
|
```
|
|
|
|
`get_prompt()` is likewise `GetPromptResult | InputRequiredResult` and `read_resource()` is `Iterable[ReadResourceContents] | InputRequiredResult`; runtime behavior is unchanged, but direct calls need the same narrowing (`Prompt.render()` and `ResourceTemplate.create_resource()` widen too). In tests, prefer the in-memory [`Client(mcp)`](get-started/testing.md), whose methods return plain `CallToolResult`/`GetPromptResult`/`ReadResourceResult`.
|
|
|
|
**No ambient request context.** In v1 these methods read the ambient request context, so calling `mcp.call_tool()` from inside a handler gave the inner handler a working `ctx`. v2 only ever injects a [`Context`](handlers/context.md): each method takes an optional `context: Context | None = None`, and omitting it builds a request-less `Context`, so an inner handler that touches `ctx.session`, `ctx.request_id`, etc. fails with "Context is not available outside of a request". Pass the caller's `ctx` through:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.fastmcp import Context, FastMCP
|
|
|
|
mcp = FastMCP("Demo")
|
|
|
|
|
|
@mcp.tool()
|
|
async def report(ctx: Context) -> str:
|
|
return f"report for request {ctx.request_id}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def digest(ctx: Context) -> str:
|
|
result = await mcp.call_tool("report", {})
|
|
return f"digest: {result}"
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server import MCPServer
|
|
from mcp.server.mcpserver import Context
|
|
|
|
mcp = MCPServer("Demo")
|
|
|
|
|
|
@mcp.tool()
|
|
async def report(ctx: Context) -> str:
|
|
return f"report for request {ctx.request_id}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def digest(ctx: Context) -> str:
|
|
result = await mcp.call_tool("report", {}, context=ctx)
|
|
return f"digest: {result}"
|
|
```
|
|
|
|
The lower layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, `ResourceManager.get_resource`) now require the `context` argument; call the `MCPServer` methods above instead.
|
|
|
|
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
|
|
|
|
Raising `MCPError` (or any subclass) inside an `@mcp.tool()` handler, or letting one propagate from a call the tool makes, now fails the `tools/call` request with a JSON-RPC error carrying the raised `code`, `message`, and `data`, so the client's `call_tool()` raises `MCPError` instead of returning a result. v1 caught it like any other exception and returned `CallToolResult(isError=True)` with the text `Error executing tool <name>: <message>`, dropping the code and `data` (`UrlElicitationRequiredError` was already re-raised and is unchanged).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.fastmcp import FastMCP
|
|
from mcp.shared.exceptions import McpError
|
|
from mcp.types import INVALID_PARAMS, ErrorData
|
|
|
|
mcp = FastMCP("demo")
|
|
|
|
|
|
@mcp.tool()
|
|
def strict(x: int) -> int:
|
|
if x < 0:
|
|
raise McpError(ErrorData(code=INVALID_PARAMS, message="x must be non-negative"))
|
|
return x
|
|
|
|
# session.call_tool("strict", {"x": -1}) returned CallToolResult(isError=True)
|
|
# with text "Error executing tool strict: x must be non-negative"
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp import Client, MCPError
|
|
from mcp.server import MCPServer
|
|
from mcp.types import INVALID_PARAMS
|
|
|
|
mcp = MCPServer("demo")
|
|
|
|
|
|
@mcp.tool()
|
|
def strict(x: int) -> int:
|
|
if x < 0:
|
|
raise MCPError(INVALID_PARAMS, "x must be non-negative", {"x": x})
|
|
return x
|
|
|
|
|
|
async def call() -> None:
|
|
async with Client(mcp) as client:
|
|
try:
|
|
await client.call_tool("strict", {"x": -1})
|
|
except MCPError as e: # was: `if result.isError:`
|
|
print(e.code, e.message, e.data) # -32602 x must be non-negative {'x': -1}
|
|
```
|
|
|
|
To keep the old model-visible `is_error=True` result, raise a plain exception (any non-`MCPError` still becomes one) or return `CallToolResult(is_error=True, ...)` from the tool. See [Handling errors](servers/handling-errors.md).
|
|
|
|
### Reading a missing resource raises `ResourceNotFoundError` and returns invalid-params
|
|
|
|
Reading a URI that matches no resource or template now fails with JSON-RPC code `-32602` (invalid params) and `error.data = {"uri": ...}`, per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164); v1 returned code `0` with no `data`. Other resource read failures now return `-32603` with the same `data` and a generic message instead of the exception text.
|
|
|
|
Server-side, `MCPServer.read_resource()` and `ctx.read_resource()` raise `ResourceNotFoundError` for an unknown URI and its parent `ResourceError` for a failing template, where v1 raised `ValueError` for both.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
try:
|
|
contents = await ctx.read_resource("config://missing")
|
|
except ValueError:
|
|
...
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
|
|
|
try:
|
|
contents = await ctx.read_resource("config://missing")
|
|
except ResourceNotFoundError:
|
|
...
|
|
```
|
|
|
|
A template handler that raised a plain exception for a missing instance should now raise `ResourceNotFoundError` so its message and the URI reach the client as `-32602`; see [Handling errors](servers/handling-errors.md).
|
|
|
|
### `Resource` classes reject unknown keyword arguments
|
|
|
|
The `Resource` base class now sets `extra="forbid"`, so `TextResource`, `BinaryResource`, `FunctionResource`, `FileResource`, `HttpResource`, `DirectoryResource`, and your own subclasses raise `pydantic.ValidationError` on an unrecognised keyword argument instead of silently dropping it. Remove the stray argument; to restore v1's silent-drop behavior on a subclass, set `model_config = ConfigDict(extra="ignore")`.
|
|
|
|
### `FileResource.is_binary` replaced by `encoding`
|
|
|
|
`FileResource` no longer takes `is_binary`; passing it raises `pydantic.ValidationError` at construction. Text versus blob is now chosen by `encoding: str | None` (`None` reads bytes and serves a base64 blob). When omitted, `encoding` is derived from `mime_type`: a declared `charset=` wins, otherwise `"utf-8-sig"` (UTF-8, BOM tolerated) for `text/*`, `application/json`, `application/xml` and any `+json`/`+xml` type, and `None` for everything else.
|
|
|
|
Two defaults change with no edit on your side: files whose mime type is textual but not `text/*` (`application/json`, `application/xml`, `image/svg+xml`, …), which v1 served as blobs, are now served as text; and text files are decoded as UTF-8 instead of with the platform locale encoding (v1 called `Path.read_text()` with no encoding). Where either default is wrong for a file, pass `encoding` explicitly.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from pathlib import Path
|
|
|
|
from mcp.server.fastmcp.resources import FileResource
|
|
|
|
FileResource(uri="file:///logo.png", path=Path("/srv/logo.png"), mime_type="image/png", is_binary=True)
|
|
FileResource(uri="file:///notes.txt", path=Path("/srv/notes.txt")) # text, locale encoding
|
|
FileResource(uri="file:///data.json", path=Path("/srv/data.json"), mime_type="application/json") # blob
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from pathlib import Path
|
|
|
|
from mcp.server.mcpserver.resources import FileResource
|
|
|
|
FileResource(uri="file:///logo.png", path=Path("/srv/logo.png"), mime_type="image/png") # blob, from mime_type
|
|
FileResource(uri="file:///notes.txt", path=Path("/srv/notes.txt")) # text, UTF-8
|
|
FileResource(uri="file:///data.json", path=Path("/srv/data.json"), mime_type="application/json") # now text
|
|
FileResource(uri="file:///legacy.txt", path=Path("/srv/legacy.txt"), encoding="cp1252") # non-UTF-8 text
|
|
# keep this JSON file as a blob
|
|
FileResource(uri="file:///raw.json", path=Path("/srv/raw.json"), mime_type="application/json", encoding=None)
|
|
```
|
|
|
|
### Resource templates: matching behavior changes
|
|
|
|
Template matching now follows [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) instead of a regex built from the URI string. Existing `{var}` templates behave differently in these ways:
|
|
|
|
**Extracted values are percent-decoded.** `docs://{name}` read as `docs://hello%20world` calls the handler with `name = "hello world"`; v1 passed `"hello%20world"`. Because `%2F` decodes to `/`, a simple `{var}` value can now contain slashes (`docs://a%2Fb` gives `name = "a/b"`), which v1's `[^/]+` capture ruled out. Remove any manual `unquote()` in the handler.
|
|
|
|
**Path-safety checks reject unsafe values by default.** A decoded value that escapes upward through `..` segments (a substring like `v1.0..v2.0` is fine), looks like an absolute path (`/etc/passwd`, `C:\Windows`, any single-letter `x:` prefix), or contains a null byte fails the read with the same `-32602` "Unknown resource" error as an unmatched URI, and the handler never runs. Exempt a parameter that legitimately carries such values, or pass `resource_security=` to `MCPServer` to change the policy server-wide:
|
|
|
|
```python
|
|
from mcp.server.mcpserver import ResourceSecurity
|
|
|
|
@mcp.resource("inspect://file/{target}", security=ResourceSecurity(exempt_params={"target"}))
|
|
def inspect_file(target: str) -> str: ...
|
|
```
|
|
|
|
**Literals and delimiters match exactly.** `data://v1.0/{id}` no longer matches `data://v1X0/42` (a template `.` was regex any-character), and `{var}` no longer captures `?`, `#`, `&`, or `,`: `api://{id}` no longer matches `api://foo?x=1`. To keep accepting the query string, write `api://{id}{?x}` and give the `x` parameter a default.
|
|
|
|
**`{var}` matches an empty value.** `tickets://{ticket_id}` now matches `tickets://` with `ticket_id=""`; v1 returned "Unknown resource" instead. Validate non-empty values in the handler if you relied on that.
|
|
|
|
**Templates v1 accepted can now fail at decoration time.** Duplicate variable names and other malformed shapes raise `InvalidUriTemplate` (a `ValueError`) when the decorator runs; v1 raised `re.error` only once a read reached the template. Two adjacent variables (`x://{name}{path}`), which v1's regex accepted, are rejected: separate them with a literal (`x://{name}/{path}`) or capture one variable and split it in the handler. A static URI whose handler takes only a `Context` parameter, silently unreachable in v1, now raises `ValueError` at decoration time: drop the parameter, or add a template variable so `Context` is injected; an optional query variable keeps a plain `notes://recent` read matching:
|
|
|
|
```python
|
|
@mcp.resource("notes://recent{?limit}")
|
|
async def recent_notes(ctx: Context, limit: int = 10) -> str: ...
|
|
```
|
|
|
|
See [URI templates and path safety](servers/uri-templates.md) for the operators added in v2 and the security settings.
|
|
|
|
### `MCPServer`'s `Context` logging: `message` renamed to `data`
|
|
|
|
On the `MCPServer` `Context`, `log()`, `debug()`, `info()`, `warning()`, and `error()` now take `data: Any` (any JSON-serializable value, so structured payloads go in `data`) instead of `message: str`.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
await ctx.log(level="info", message="hello")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
await ctx.log(level="info", data="hello")
|
|
await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432})
|
|
```
|
|
|
|
Positional calls (`await ctx.info("hello")`) and `logger_name=` are unaffected. These methods are also deprecated in v2 and emit `mcp.MCPDeprecationWarning`, so the rename only keeps them working; the durable replacement is standard `logging` (see [Logging](handlers/logging.md) and [Deprecated features](deprecated.md)).
|
|
|
|
### `Context.client_id` removed
|
|
|
|
`ctx.client_id` is removed; it only echoed a non-standard `client_id` key from the request's `_meta`. Read that key directly, or use the access token for the authenticated OAuth client (see [Authorization](run/authorization.md#the-callers-identity)).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
client_id = ctx.client_id
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
|
|
|
# the raw _meta key, if you set it yourself
|
|
meta = ctx.request_context.meta
|
|
client_id = meta.get("client_id") if meta else None
|
|
|
|
# the authenticated OAuth client (usually what you want)
|
|
token = get_access_token()
|
|
client_id = token.client_id if token else None
|
|
```
|
|
|
|
### `ProgressContext` and `progress()` context manager removed
|
|
|
|
The `mcp.shared.progress` module (`Progress`, `ProgressContext`, and the `progress()` context manager) is removed. Use `Context.report_progress()` in an `MCPServer` handler, or `ctx.session.report_progress()` in a lowlevel `Server` handler. Two behavior changes:
|
|
|
|
- `report_progress` takes the absolute current value, not a delta: `p.progress(25)` called twice reported `25` then `50`, while the same calls to `report_progress` report `25` twice. Keep the running total yourself.
|
|
- With no progress token on the request, `progress()` raised `ValueError`; `report_progress` is a silent no-op.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.shared.progress import progress
|
|
|
|
with progress(request_context, total=100) as p:
|
|
await p.progress(25, message="step 1") # reports 25
|
|
await p.progress(25) # reports 50
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server import MCPServer
|
|
from mcp.server.mcpserver import Context
|
|
|
|
mcp = MCPServer("Progress")
|
|
|
|
|
|
@mcp.tool()
|
|
async def my_tool(x: int, ctx: Context) -> str:
|
|
await ctx.report_progress(25, 100, message="step 1")
|
|
await ctx.report_progress(50, 100) # absolute value, not a delta
|
|
return "done"
|
|
```
|
|
|
|
In a lowlevel `Server` handler the equivalent is `await ctx.session.report_progress(50, 100, message="halfway")` on the `ServerRequestContext`. See [Progress](handlers/progress.md).
|
|
|
|
### `Context.elicit()` rejects non-spec schemas; mismatched answers raise `ValueError`
|
|
|
|
`ctx.elicit()` (and `elicit_with_validation()`) now reject two field shapes v1 accepted, raising `TypeError` before anything is sent: a `list[str]` field and a union of primitives such as `int | str`. Use `list[Literal[...]]` for a multi-select and a single primitive type per field.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class Preferences(BaseModel):
|
|
tags: list[str]
|
|
level: int | str
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class Preferences(BaseModel):
|
|
tags: list[Literal["news", "sports"]]
|
|
level: str
|
|
```
|
|
|
|
An accepted answer whose content doesn't match the schema now raises `ValueError` (the pydantic `ValidationError` is its `__cause__`) instead of letting `ValidationError` propagate; change `except ValidationError` around `ctx.elicit()` to `except ValueError`.
|
|
|
|
See [Elicitation](handlers/elicitation.md).
|
|
|
|
### `isinstance()` checks against `ElicitationResult` raise `TypeError`
|
|
|
|
`ElicitationResult` (the return type of `ctx.elicit()`) is now a subscriptable `TypeAliasType` rather than a plain union, so it is no longer a valid `isinstance()` target. Check the member classes instead (unmoved in `mcp.server.elicitation`), or branch on `result.action` (`"accept"` / `"decline"` / `"cancel"`), which is unaffected.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.elicitation import ElicitationResult
|
|
|
|
if isinstance(result, ElicitationResult):
|
|
...
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server.elicitation import AcceptedElicitation, CancelledElicitation, DeclinedElicitation
|
|
|
|
if isinstance(result, (AcceptedElicitation, DeclinedElicitation, CancelledElicitation)):
|
|
...
|
|
```
|
|
|
|
## Lowlevel Server
|
|
|
|
### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params
|
|
|
|
The lowlevel `Server` no longer has `@server.list_tools()`-style registration decorators. Pass each handler to the constructor as an `on_*` keyword argument; every handler has the signature `async (ctx: ServerRequestContext, params) -> Result`, taking the typed params model and returning the full result type.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.lowlevel.server import Server
|
|
import mcp.types as types
|
|
|
|
server = Server("my-server")
|
|
|
|
@server.list_tools()
|
|
async def handle_list_tools():
|
|
return [types.Tool(name="my_tool", description="A tool", inputSchema={})]
|
|
|
|
@server.call_tool()
|
|
async def handle_call_tool(name: str, arguments: dict):
|
|
return [types.TextContent(type="text", text=f"Called {name}")]
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server import Server, ServerRequestContext
|
|
from mcp.types import (
|
|
CallToolRequestParams,
|
|
CallToolResult,
|
|
ListToolsResult,
|
|
PaginatedRequestParams,
|
|
TextContent,
|
|
Tool,
|
|
)
|
|
|
|
|
|
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
return ListToolsResult(tools=[Tool(name="my_tool", description="A tool", input_schema={"type": "object"})])
|
|
|
|
|
|
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
return CallToolResult(content=[TextContent(type="text", text=f"Called {params.name}")])
|
|
|
|
|
|
server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
|
|
```
|
|
|
|
`ctx.session` is the `ServerSession`; `ctx.lifespan_context`, `ctx.request_id`, and `ctx.meta` are on the context too. The `jsonschema` input/output validation that `@server.call_tool()` performed is gone: `input_schema` is advertised, never applied, so validate `params.arguments` yourself.
|
|
|
|
| v1 decorator | v2 constructor kwarg | `params` type | return type |
|
|
|---|---|---|---|
|
|
| `@server.list_tools()` | `on_list_tools` | `PaginatedRequestParams \| None` | `ListToolsResult` |
|
|
| `@server.call_tool()` | `on_call_tool` | `CallToolRequestParams` | `CallToolResult` |
|
|
| `@server.list_resources()` | `on_list_resources` | `PaginatedRequestParams \| None` | `ListResourcesResult` |
|
|
| `@server.list_resource_templates()` | `on_list_resource_templates` | `PaginatedRequestParams \| None` | `ListResourceTemplatesResult` |
|
|
| `@server.read_resource()` | `on_read_resource` | `ReadResourceRequestParams` | `ReadResourceResult` |
|
|
| `@server.subscribe_resource()` | `on_subscribe_resource` | `SubscribeRequestParams` | `EmptyResult` |
|
|
| `@server.unsubscribe_resource()` | `on_unsubscribe_resource` | `UnsubscribeRequestParams` | `EmptyResult` |
|
|
| `@server.list_prompts()` | `on_list_prompts` | `PaginatedRequestParams \| None` | `ListPromptsResult` |
|
|
| `@server.get_prompt()` | `on_get_prompt` | `GetPromptRequestParams` | `GetPromptResult` |
|
|
| `@server.completion()` | `on_completion` | `CompleteRequestParams` | `CompleteResult` |
|
|
| `@server.set_logging_level()` | `on_set_logging_level` | `SetLevelRequestParams` | `EmptyResult` |
|
|
| `@server.progress_notification()` | `on_progress` | `ProgressNotificationParams` | `None` |
|
|
| (none) | `on_ping` | `RequestParams \| None` | `EmptyResult` |
|
|
| (none) | `on_roots_list_changed` | `NotificationParams \| None` | `None` |
|
|
|
|
All `params` and result types come from `mcp.types`. The full handler model is in [The low-level Server](advanced/low-level-server.md). Around the handlers, the transports and how you drive `server.run(...)`, `create_initialization_options()`, `NotificationOptions`, and the streamable-HTTP session manager with its `EventStore` are unchanged.
|
|
|
|
### Lowlevel `Server`: automatic return value wrapping removed
|
|
|
|
The v1 handler decorators converted several return shapes into the wire result type; v2 `on_*` handlers return the fully constructed result (see [The low-level Server](advanced/low-level-server.md)). If you want the conveniences back, use `MCPServer`, whose `@mcp.tool()` and `@mcp.resource()` still wrap return values.
|
|
|
|
`call_tool` wrapped `list[ContentBlock]`, `dict`, or `(list, dict)` into a `CallToolResult`; a `dict` became `structured_content` plus a JSON `TextContent`. Build it yourself:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
@server.call_tool()
|
|
async def handle(name: str, arguments: dict) -> dict:
|
|
return {"temperature": 22.5, "city": "London"}
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
import json
|
|
|
|
from mcp.server import ServerRequestContext
|
|
from mcp.types import CallToolRequestParams, CallToolResult, TextContent
|
|
|
|
|
|
async def handle(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
data = {"temperature": 22.5, "city": "London"}
|
|
return CallToolResult(
|
|
content=[TextContent(type="text", text=json.dumps(data, indent=2))],
|
|
structured_content=data,
|
|
)
|
|
```
|
|
|
|
`params.arguments` is `None` when the client sends no arguments (the v1 decorator passed `{}`); use `params.arguments or {}`.
|
|
|
|
`read_resource` converted `Iterable[ReadResourceContents]` (and the deprecated `str`/`bytes` shorthand) into `TextResourceContents`/`BlobResourceContents`, base64-encoding bytes and defaulting the mime type. Build the contents yourself:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from collections.abc import Iterable
|
|
|
|
from pydantic import AnyUrl
|
|
|
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
|
|
|
|
@server.read_resource()
|
|
async def handle(uri: AnyUrl) -> Iterable[ReadResourceContents]:
|
|
return [ReadResourceContents(content="file contents", mime_type="text/plain")]
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
import base64
|
|
|
|
from mcp.server import ServerRequestContext
|
|
from mcp.types import (
|
|
BlobResourceContents,
|
|
ReadResourceRequestParams,
|
|
ReadResourceResult,
|
|
TextResourceContents,
|
|
)
|
|
|
|
|
|
async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
|
|
if params.uri.endswith(".png"):
|
|
blob = base64.b64encode(b"\x89PNG...").decode() # base64-encode binary yourself
|
|
return ReadResourceResult(contents=[BlobResourceContents(uri=params.uri, blob=blob, mime_type="image/png")])
|
|
return ReadResourceResult(
|
|
contents=[TextResourceContents(uri=params.uri, text="file contents", mime_type="text/plain")]
|
|
)
|
|
```
|
|
|
|
The other decorators wrapped bare returns too: `list_*` handlers now return `ListToolsResult(tools=[...])`, `ListResourcesResult(resources=[...])`, `ListResourceTemplatesResult(resource_templates=[...])`, and `ListPromptsResult(prompts=[...])`, and `completion` returns `CompleteResult(completion=Completion(values=[...]))` instead of `Completion | None`.
|
|
|
|
### Lowlevel `Server`: tool handler exceptions no longer become `CallToolResult(is_error=True)`
|
|
|
|
The v1 `@server.call_tool()` decorator caught any exception the handler raised and returned it as `CallToolResult(isError=True)`, so the model saw the error text and could retry. The v2 `on_call_tool` handler has no such wrapper: the exception fails the whole `tools/call` request with a JSON-RPC error instead of a result, the client raises `MCPError`, and the model gets no tool result.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
@server.call_tool()
|
|
async def call_tool(name: str, arguments: dict):
|
|
raise ValueError("kaboom") # client received CallToolResult(isError=True)
|
|
```
|
|
|
|
**After (v2):** catch the exception in the handler and build the error result yourself:
|
|
|
|
```python
|
|
from mcp.server import ServerRequestContext
|
|
from mcp.types import CallToolRequestParams, CallToolResult, TextContent
|
|
|
|
|
|
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
try:
|
|
args = params.arguments or {}
|
|
text = str(args["a"] + args["b"]) # tool logic that may raise
|
|
except Exception as e: # what the v1 decorator did for you
|
|
return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True)
|
|
return CallToolResult(content=[TextContent(type="text", text=text)])
|
|
```
|
|
|
|
Raise `MCPError` only when the request itself should fail as a protocol error. If you want the automatic conversion back, `MCPServer`'s `@mcp.tool()` still turns handler exceptions into `is_error=True` results; see [Handling errors](servers/handling-errors.md).
|
|
|
|
### Lowlevel `Server`: constructor parameters are now keyword-only
|
|
|
|
All parameters after `name` are now keyword-only, so passing `version` (or anything else) positionally raises `TypeError` at construction:
|
|
|
|
```python
|
|
from mcp.server import Server
|
|
|
|
# Before (v1)
|
|
server = Server("my-server", "1.0")
|
|
|
|
# After (v2)
|
|
server = Server("my-server", version="1.0")
|
|
```
|
|
|
|
### Lowlevel `Server`: type parameter reduced from 2 to 1
|
|
|
|
`Server[LifespanResultT, RequestT]` is now `Server[LifespanResultT]`. Drop the second type argument; a two-argument subscript fails type checking and raises `TypeError` wherever the subscript is evaluated at runtime (a module-level annotation on Python 3.13 or earlier, a subclass base, `get_type_hints`).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from typing import Any
|
|
|
|
from mcp.server import Server
|
|
|
|
server: Server[dict[str, Any], Any] = Server("my-server")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from typing import Any
|
|
|
|
from mcp.server import Server
|
|
|
|
server: Server[dict[str, Any]] = Server("my-server")
|
|
```
|
|
|
|
The transport request object that `RequestT` typed (`server.request_context.request` in v1) is now typed on the handler's `ctx: ServerRequestContext[LifespanContextT, RequestT]`; see [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified).
|
|
|
|
### Lowlevel `Server`: `request_handlers` and `notification_handlers` attributes removed
|
|
|
|
The public `server.request_handlers` and `server.notification_handlers` dicts are gone. Register handlers after construction with `add_request_handler` / `add_notification_handler`, and look them up with `get_request_handler` / `get_notification_handler`, which return the registered entry (with `.handler` and `.params_type`) or `None`. Keys are method strings, not request types.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.types import ListToolsRequest
|
|
|
|
server.request_handlers[ListToolsRequest] = handle_list_tools
|
|
if ListToolsRequest in server.request_handlers:
|
|
...
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import PaginatedRequestParams
|
|
|
|
server.add_request_handler("tools/list", PaginatedRequestParams, handle_list_tools)
|
|
if server.get_request_handler("tools/list") is not None:
|
|
...
|
|
```
|
|
|
|
`handle_list_tools` uses the v2 `(ctx, params)` handler signature; see [The low-level Server](advanced/low-level-server.md) for `add_request_handler` and custom methods.
|
|
|
|
### Lowlevel `Server.run(raise_exceptions=True)`: transport errors no longer re-raised
|
|
|
|
`raise_exceptions=True` now only affects handler exceptions: an unexpected exception from a handler still propagates out of `run()`, but the JSON-RPC error response is written to the client first (v1 re-raised without responding).
|
|
|
|
Exceptions the transport places on the read stream (e.g. a stdio JSON parse error) are now debug-logged and dropped regardless of the flag: `run()` keeps serving instead of raising, and the client no longer receives the `Internal Server Error` `notifications/message` log entry v1 sent.
|
|
|
|
### Cancelled requests are no longer answered
|
|
|
|
v1 answered a request the peer cancelled with a JSON-RPC error, `{"code": 0, "message": "Request cancelled"}`; v2 sends no response at all (no result, no error) on both seats (the server for a cancelled client request, the client for a cancelled server-initiated request such as sampling or elicitation). The one exception is the 2025-era streamable HTTP server transport, whose wire can end a request's stream only with a response: it terminates a cancelled request with `-32800` (`mcp.server.streamable_http.REQUEST_CANCELLED`) instead of the old `0`.
|
|
|
|
Callers that abandon a call through the SDK (cancelling the awaiting task or scope, or a per-request timeout) are unaffected. Code that hand-sends `notifications/cancelled` while another task still awaits the call breaks: that call no longer raises `McpError("Request cancelled")` and instead waits indefinitely (over 2025-era streamable HTTP it fails with `-32800`). Cancel the scope or task awaiting the call instead; v2 sends `notifications/cancelled` for you and the server interrupts the handler:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
await session.send_notification(
|
|
types.ClientNotification(
|
|
types.CancelledNotification(
|
|
params=types.CancelledNotificationParams(requestId=request_id, reason="user aborted"),
|
|
)
|
|
)
|
|
) # the task awaiting session.call_tool(...) then raised McpError("Request cancelled")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
with anyio.CancelScope() as scope: # abort with scope.cancel(); v2 sends notifications/cancelled for the call
|
|
result = await session.call_tool("slow", {})
|
|
if scope.cancelled_caught:
|
|
... # the call was abandoned (replaces v1's `except McpError`)
|
|
```
|
|
|
|
### `Server.run()` no longer takes a `stateless` flag
|
|
|
|
Delete the argument: passing `stateless=` to the lowlevel `Server.run()` raises `TypeError`. Statelessness is configured on the HTTP transport, not per connection:
|
|
|
|
```python
|
|
# Before (v1)
|
|
await server.run(read_stream, write_stream, init_options, stateless=True)
|
|
```
|
|
|
|
```python
|
|
# After (v2)
|
|
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
|
|
app = server.streamable_http_app(stateless_http=True) # the lowlevel Server now builds the ASGI app
|
|
# or, driving the session manager yourself (unchanged from v1):
|
|
manager = StreamableHTTPSessionManager(app=server, stateless=True)
|
|
```
|
|
|
|
On a `stateless_http=True` server, server-initiated requests (sampling, elicitation, roots) no longer stall waiting for a reply that can never arrive, as they did in v1; they raise `NoBackChannelError` — see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror) and [Serving legacy clients](run/legacy-clients.md).
|
|
|
|
### Lowlevel `Server`: `request_context` property removed
|
|
|
|
`server.request_context` and the module-level `request_ctx` contextvar are gone. The request context is now the handler's first argument, `ctx: ServerRequestContext`, with the same `session`, `lifespan_context`, `request_id`, `meta`, and `request` fields. There is no ambient accessor in v2, so pass `ctx` down to any helper that called `request_ctx.get()`.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
import mcp.types as types
|
|
from mcp.server.lowlevel import Server
|
|
from mcp.server.lowlevel.server import request_ctx
|
|
|
|
server = Server("my-server")
|
|
|
|
|
|
@server.call_tool()
|
|
async def query_db(name: str, arguments: dict) -> list[types.TextContent]:
|
|
ctx = server.request_context # or, from any nested helper, request_ctx.get()
|
|
db = ctx.lifespan_context["db"]
|
|
return [types.TextContent(type="text", text=f"queried {db}")]
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server import Server, ServerRequestContext
|
|
from mcp.types import CallToolRequestParams, CallToolResult, TextContent
|
|
|
|
|
|
async def query_db(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
db = ctx.lifespan_context["db"]
|
|
return CallToolResult(content=[TextContent(type="text", text=f"queried {db}")])
|
|
|
|
|
|
server = Server("my-server", on_call_tool=query_db)
|
|
```
|
|
|
|
See [Low-level server](advanced/low-level-server.md) for the handler shape and `ctx` fields.
|
|
|
|
### `RequestContext` type parameters simplified
|
|
|
|
`mcp.shared.context.RequestContext[SessionT, LifespanContextT, RequestT]` is gone (`from mcp.shared.context import RequestContext` raises `ImportError`). It is split by side, and the leading session type parameter is dropped:
|
|
|
|
- Client callbacks (`sampling`, `elicitation`, `list_roots`) receive the non-generic `ClientRequestContext` from `mcp.client` (`session`, `request_id`, `meta`); the always-`None` `lifespan_context` and `request` are gone.
|
|
- Server-side code uses `ServerRequestContext[LifespanContextT, RequestT]` from `mcp.server`.
|
|
- The injected high-level `Context[ServerSessionT, LifespanContextT, RequestT]` becomes `Context[LifespanContextT, RequestT]`: `Context[ServerSession, None]` becomes bare `Context`, and `Context[ServerSession, AppContext]` becomes `Context[AppContext]` (a [tools-only](handlers/lifespan.md) spelling, as in v1).
|
|
|
|
A leftover two-argument `Context[ServerSession, AppContext]` still runs but types `lifespan_context` as `ServerSession`; three type arguments raise `TypeError`.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from mcp import ClientSession, types
|
|
from mcp.server.fastmcp import Context
|
|
from mcp.server.session import ServerSession
|
|
from mcp.shared.context import RequestContext
|
|
|
|
|
|
@dataclass
|
|
class AppContext:
|
|
db_url: str
|
|
|
|
|
|
async def handle_sampling(
|
|
context: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams
|
|
) -> types.CreateMessageResult: ...
|
|
|
|
|
|
def db_url_from(ctx: RequestContext[ServerSession, AppContext, Any]) -> str:
|
|
return ctx.lifespan_context.db_url
|
|
|
|
|
|
async def typed_tool(ctx: Context[ServerSession, AppContext]) -> str:
|
|
return ctx.request_context.lifespan_context.db_url
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from dataclasses import dataclass
|
|
|
|
from mcp.client import ClientRequestContext
|
|
from mcp.server import ServerRequestContext
|
|
from mcp.server.mcpserver import Context
|
|
from mcp.types import CreateMessageRequestParams, CreateMessageResult
|
|
|
|
|
|
@dataclass
|
|
class AppContext:
|
|
db_url: str
|
|
|
|
|
|
async def handle_sampling(
|
|
context: ClientRequestContext, params: CreateMessageRequestParams
|
|
) -> CreateMessageResult: ...
|
|
|
|
|
|
def db_url_from(ctx: ServerRequestContext[AppContext]) -> str:
|
|
return ctx.lifespan_context.db_url
|
|
|
|
|
|
async def typed_tool(ctx: Context[AppContext]) -> str:
|
|
return ctx.request_context.lifespan_context.db_url
|
|
```
|
|
|
|
`isinstance(ctx, RequestContext)` checks must name the concrete class, and the `LifespanContextT`/`RequestT` TypeVars now live in `mcp.server.context`. `ServerRequestContext.request_id` is `RequestId | None` (`None` when the same context reaches a notification handler), so code passing `ctx.request_id` on as a definite `RequestId` needs a `None` check.
|
|
|
|
### `ServerSession` is now a thin proxy (no longer a `BaseSession`)
|
|
|
|
`ServerSession` no longer subclasses `BaseSession` and is not an async context manager; handlers still reach it as `ctx.session`. The typed helpers (`send_*`, `create_message`, `elicit_*`, `list_roots`, `client_params`, `check_client_capability`) keep their v1 names, but `send_notification` and `send_request` now take the message model itself: the `types.ServerNotification(...)` / `types.ServerRequest(...)` wrappers are gone (see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation)).
|
|
|
|
Two behavior changes reach code that only used `ctx.session`:
|
|
|
|
- **`ctx.session` is a new object on every request.** v1 kept one `ServerSession` per connection, so servers keyed per-client state on its identity (`WeakKeyDictionary[ServerSession, ...]`, `id(ctx.session)`, a set of captured sessions to notify later). Those idioms now silently misbehave: a session-keyed dict never finds an earlier entry, and a broadcast set gains a proxy per request and sends duplicates. Key on something connection-stable instead: on stateful streamable HTTP the `mcp-session-id` header (`ctx.headers` on `MCPServer`, `ctx.request.headers` in a lowlevel handler); on stdio the process serves a single client.
|
|
- **Sends on a closed connection are dropped instead of raising.** In v1 the notification helpers (`send_resource_updated()`, `send_tool_list_changed()`, ...) raised `anyio.ClosedResourceError` / `anyio.BrokenResourceError` once the connection was gone, and broadcast loops used that to prune dead sessions. In v2 the send returns normally (the drop is debug-logged); probe with a request instead: `await session.send_ping()` raises `MCPError` (`CONNECTION_CLOSED`) once the connection has closed. On a 2026-07-28 connection `send_ping()` raises `NoBackChannelError` regardless (see [Behavior changes on v2's default connection](#behavior-changes-on-v2s-default-connection)), so it is a liveness probe only for 2025-11-25 and earlier peers.
|
|
|
|
If you constructed or subclassed `ServerSession` directly, hand the streams to a `Server` instead:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
import anyio
|
|
|
|
from mcp.server.models import InitializationOptions
|
|
from mcp.server.session import ServerSession
|
|
from mcp.server.stdio import stdio_server
|
|
from mcp.types import ServerCapabilities
|
|
|
|
init_options = InitializationOptions(server_name="mcp", server_version="0.1.0", capabilities=ServerCapabilities())
|
|
|
|
|
|
async def main() -> None:
|
|
async with stdio_server() as (read_stream, write_stream):
|
|
async with ServerSession(read_stream, write_stream, init_options) as session:
|
|
async for message in session.incoming_messages:
|
|
...
|
|
|
|
|
|
anyio.run(main)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
import anyio
|
|
|
|
from mcp.server import Server
|
|
from mcp.server.stdio import stdio_server
|
|
|
|
server = Server("mcp")
|
|
|
|
|
|
async def main() -> None:
|
|
async with stdio_server() as (read_stream, write_stream):
|
|
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
|
|
|
|
anyio.run(main)
|
|
```
|
|
|
|
Register handlers via the `Server` constructor's `on_*` arguments (or `add_request_handler`); to observe every inbound request and notification (what `incoming_messages` or an overridden `_receive_loop` / `_received_request` gave you), add a [middleware](advanced/middleware.md). Also removed from `mcp.server.session`: `InitializationState`, `ServerRequestResponder`, and the `ServerSessionT` TypeVar (see [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified)); the tasks-only `experimental`, `send_message()`, and `add_response_router()` members went with the [experimental tasks API](#experimental-tasks-support-removed).
|
|
|
|
### `ServerSession.elicit()` and `elicit_form()` take `requested_schema`, not `requestedSchema`
|
|
|
|
The schema parameter of `ServerSession.elicit()` and `ServerSession.elicit_form()` was renamed from `requestedSchema` to `requested_schema`. Unlike the model-field renames, a method parameter has no alias fallback: keyword callers get `TypeError: ServerSession.elicit_form() got an unexpected keyword argument 'requestedSchema'` at call time.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
result = await ctx.session.elicit_form(
|
|
message="Your name?",
|
|
requestedSchema={
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
"required": ["name"],
|
|
},
|
|
)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
result = await ctx.session.elicit_form(
|
|
message="Your name?",
|
|
requested_schema={
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
"required": ["name"],
|
|
},
|
|
)
|
|
```
|
|
|
|
Positional callers (`session.elicit_form(message, schema)`) are unaffected. `elicit_url()` already used snake_case parameters in v1.
|
|
|
|
## Clients
|
|
|
|
### `ClientSession.get_server_capabilities()` replaced by properties
|
|
|
|
`get_server_capabilities()` is removed; read the `server_capabilities` property, which is likewise `None` until the session is initialized. `server_info`, `instructions`, and `protocol_version` are exposed the same way, so code that captured the `initialize()` return value only to keep those fields no longer needs to (`initialize()` still returns the `InitializeResult`).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
init = await session.initialize() # captured for serverInfo / instructions / protocolVersion
|
|
capabilities = session.get_server_capabilities()
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
await session.initialize()
|
|
capabilities = session.server_capabilities
|
|
server_info = session.server_info
|
|
instructions = session.instructions
|
|
version = session.protocol_version
|
|
```
|
|
|
|
The high-level [`Client`](client/index.md) exposes the same four properties, populated on entering its `async with` block.
|
|
|
|
### `cursor` parameter removed from `ClientSession` list methods
|
|
|
|
`ClientSession.list_resources()`, `list_resource_templates()`, `list_prompts()`, and `list_tools()` no longer accept the deprecated `cursor` argument; passing it raises `TypeError`. Wrap the cursor in `PaginatedRequestParams`, or use the high-level `Client`, whose `list_*` methods take `cursor=` directly (and do not accept `params=`). See [Pagination](advanced/pagination.md).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
result = await session.list_resources(cursor="next_page_token")
|
|
result = await session.list_tools(cursor="next_page_token")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import PaginatedRequestParams
|
|
|
|
result = await session.list_resources(params=PaginatedRequestParams(cursor="next_page_token"))
|
|
result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token"))
|
|
|
|
# or, on the high-level Client:
|
|
result = await client.list_resources(cursor="next_page_token")
|
|
result = await client.list_tools(cursor="next_page_token")
|
|
```
|
|
|
|
### `args` parameter removed from `ClientSessionGroup.call_tool()`
|
|
|
|
Calls using the old `args=` keyword now raise `TypeError`. Use `arguments=`, or pass the dict positionally (works on both v1 and v2).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
result = await session_group.call_tool("my_tool", args={"key": "value"})
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
result = await session_group.call_tool("my_tool", arguments={"key": "value"})
|
|
```
|
|
|
|
### Timeouts take `float` seconds instead of `timedelta`
|
|
|
|
Every timeout parameter that took a `datetime.timedelta` in v1 now takes plain seconds as a `float`:
|
|
|
|
| Surface | v1 type | v2 type |
|
|
|---|---|---|
|
|
| `ClientSession(read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` |
|
|
| `ClientSession.call_tool(read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` |
|
|
| `ClientSession.send_request(request_read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` |
|
|
| `ClientSessionGroup.call_tool(read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` |
|
|
| `ClientSessionParameters.read_timeout_seconds` | `timedelta \| None` | `float \| None` |
|
|
| `StreamableHttpParameters.timeout` / `.sse_read_timeout` | `timedelta` | `float` |
|
|
| `ServerSession.send_request(request_read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` |
|
|
|
|
`SseServerParameters` already used `float` in v1 and is unaffected.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from datetime import timedelta
|
|
|
|
session = ClientSession(read_stream, write_stream, read_timeout_seconds=timedelta(seconds=30))
|
|
result = await session.call_tool("slow_tool", {}, read_timeout_seconds=timedelta(minutes=2))
|
|
|
|
params = StreamableHttpParameters(
|
|
url="https://example.com/mcp",
|
|
timeout=timedelta(seconds=30),
|
|
sse_read_timeout=timedelta(seconds=300),
|
|
)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
session = ClientSession(read_stream, write_stream, read_timeout_seconds=30)
|
|
result = await session.call_tool("slow_tool", {}, read_timeout_seconds=120)
|
|
|
|
params = StreamableHttpParameters(
|
|
url="https://example.com/mcp",
|
|
timeout=30,
|
|
sse_read_timeout=300,
|
|
)
|
|
```
|
|
|
|
Replace `timedelta(...)` with plain seconds, or append `.total_seconds()`. Only `StreamableHttpParameters` fails loudly on a leftover `timedelta` (pydantic `ValidationError: Input should be a valid number`); the session and per-call parameters accept it silently, and the first request that arms the timeout raises `TypeError: unsupported operand type(s) for +: 'float' and 'datetime.timedelta'` from anyio. Bare numbers already passed to v1's `StreamableHttpParameters` (which coerced them to `timedelta`) need no change.
|
|
|
|
### Client request timeouts raise `REQUEST_TIMEOUT` instead of code `408`
|
|
|
|
A request that exceeds `read_timeout_seconds` still raises `MCPError` (v1's `McpError`), but its error code changed from the HTTP status `408` (`httpx.codes.REQUEST_TIMEOUT`) to the JSON-RPC code `-32001`, exported as `REQUEST_TIMEOUT`. The message wording changed too and differs between the in-process and wire transports, so match on the code, not the text. A leftover `e.error.code == 408` check still evaluates without error and silently never matches.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.shared.exceptions import McpError
|
|
|
|
try:
|
|
result = await session.call_tool("slow_tool", {})
|
|
except McpError as e:
|
|
if e.error.code == 408:
|
|
... # retry / back off
|
|
else:
|
|
raise
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.shared.exceptions import MCPError
|
|
from mcp.types import REQUEST_TIMEOUT # -32001
|
|
|
|
try:
|
|
result = await session.call_tool("slow_tool", {})
|
|
except MCPError as e:
|
|
if e.code == REQUEST_TIMEOUT: # e.error.code still works too
|
|
... # retry / back off
|
|
else:
|
|
raise
|
|
```
|
|
|
|
### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed
|
|
|
|
`ClientSession` no longer subclasses `BaseSession`: the `mcp.shared.session` module (`BaseSession`, `RequestResponder`) is gone with no shim, and the receive loop is now `JSONRPCDispatcher` (`mcp.shared.jsonrpc_dispatcher`). The constructor callbacks and lifecycle are unchanged (this group's other sections cover the changed parameters and removed methods); code that reached into `BaseSession` to intercept the wire passes a dispatcher through the new keyword-only `dispatcher=` argument instead (the `Dispatcher` protocol in `mcp.shared.dispatcher` is provisional). `ProgressFnT` moved to `mcp.shared.dispatcher`, `RequestId` comes from `mcp.types`, and the session TypeVars (`SendRequestT` and friends) went with the module: the sessions are no longer generic.
|
|
|
|
`message_handler` no longer receives requests (the typed callbacks answer them): its parameter is `IncomingMessage = ServerNotification | Exception`, exported from `mcp.client` (see [Callbacks](client/callbacks.md)). Notifications arrive as the concrete model rather than the v1 `RootModel` wrapper, so drop `.root` (`message.params`, not `message.root.params`; see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation)).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
import mcp.types as types
|
|
from mcp.shared.session import RequestResponder
|
|
|
|
|
|
async def message_handler(
|
|
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
|
) -> None:
|
|
if isinstance(message, Exception):
|
|
raise message
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.client import IncomingMessage
|
|
|
|
|
|
async def message_handler(message: IncomingMessage) -> None:
|
|
if isinstance(message, Exception):
|
|
raise message
|
|
```
|
|
|
|
Behavior changes on unchanged session code:
|
|
|
|
- **Callbacks and notifications run concurrently.** v1 handled one inbound message at a time, so callbacks ran inline and in order; v2 runs each server-initiated request callback and each notification delivery in its own task. They may interleave, a callback may itself send requests (that deadlocked in v1), an in-flight callback is interrupted when the server sends `notifications/cancelled`, and a `progress_callback` may run after its request has returned. A raising `message_handler` is now logged instead of ending the receive loop, so the `raise message` idiom above no longer tears the session down. Callbacks that need strict ordering must coordinate themselves.
|
|
- **A raising request callback** is answered with `code=0` and the exception text; v1 flattened every callback exception to `INVALID_PARAMS` (`"Invalid request parameters"`, still the answer for a pydantic `ValidationError`). Return `ErrorData` or raise `MCPError` to choose the error code.
|
|
- **`send_request` before entering the context manager raises `RuntimeError`** (v1 wrote the request and hung until the read timeout, if any); after the connection has closed it raises `MCPError` (`CONNECTION_CLOSED`) instead of `anyio.ClosedResourceError`.
|
|
- **`send_notification` after the connection has closed is dropped with a debug log** (`send_roots_list_changed()` included) instead of raising `anyio.ClosedResourceError`; if that exception was your disconnect signal, probe with a request instead (`send_request` still raises `MCPError` after close).
|
|
- **`send_notification` no longer takes `related_request_id`.**
|
|
- **Abandoning a request now sends `notifications/cancelled`.** Cancelling the awaiting task or scope, or a request hitting its read timeout, tells the server to interrupt the handler; v1 sent nothing. A cancelled peer no longer answers at all: see [Cancelled requests are no longer answered](#cancelled-requests-are-no-longer-answered).
|
|
- **Request callbacks receive `mcp.client.ClientRequestContext`** in place of `RequestContext[ClientSession, Any]`; see [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified).
|
|
|
|
### Experimental Tasks support removed
|
|
|
|
The deprecated experimental Tasks API is removed (the 2026-07-28 spec moves Tasks into an extension, [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), that this SDK does not implement). The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules are gone, along with the `experimental` accessors on `Server`, `ServerSession`, `ClientSession`, and `RequestContext`, and the `ClientSession(experimental_task_handlers=...)` argument. The `Task*` models stay in `mcp.types` as types-only definitions; the `TASK_*` constants and the `TaskExecutionMode` alias are gone (see [Removed type aliases and classes](#removed-type-aliases-and-classes)).
|
|
|
|
There is no drop-in replacement for the tasks runtime (`server.experimental.enable_tasks()` and its `@server.experimental.*` handler decorators, `ctx.experimental.run_task()`, `ServerTaskContext` / `TaskStore`, the client's `session.experimental.call_tool_as_task()` / `poll_task()` / `get_task_result()`). Port by what the code used tasks for:
|
|
|
|
**Status updates on a long-running tool.** Run the work inline in the handler and replace `task.update_status()` with `ctx.report_progress()` (`ctx.session.report_progress()` in a lowlevel handler); the client passes `progress_callback=` to `call_tool()` instead of creating a task and polling — see [Progress](handlers/progress.md).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
async def work(task: ServerTaskContext) -> CallToolResult:
|
|
await task.update_status("Processing step 1...")
|
|
...
|
|
|
|
result = await ctx.experimental.run_task(work)
|
|
|
|
# client: create the task, poll its status, then fetch the result
|
|
result = await session.experimental.call_tool_as_task("long_running_task", arguments={}, ttl=60000)
|
|
async for status in session.experimental.poll_task(result.task.taskId):
|
|
print(status.statusMessage)
|
|
task_result = await session.experimental.get_task_result(result.task.taskId, CallToolResult)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
@mcp.tool()
|
|
async def long_running_task(ctx: Context) -> str:
|
|
await ctx.report_progress(1, total=3, message="Processing step 1...")
|
|
...
|
|
return "Task completed!"
|
|
|
|
# client
|
|
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
|
print(message)
|
|
|
|
result = await client.call_tool("long_running_task", {}, progress_callback=on_progress)
|
|
```
|
|
|
|
**User input mid-work** (`task.elicit()`, `task.create_message()`). Don't port to inline `ctx.elicit()` / `ctx.session.create_message()`: those raise `NoBackChannelError` on 2026-07-28 connections, the default for an in-process `Client(server)`. Use the resolver dependencies (`Elicit`, `Sample`) or return an `InputRequiredResult` — see [Multi-round-trip requests](handlers/multi-round-trip.md) and [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror).
|
|
|
|
**Detached work** (fetch the result on a later connection or after a client restart) has no v2 equivalent.
|
|
|
|
Also drop `execution=ToolExecution(taskSupport=types.TASK_REQUIRED)` from lowlevel `types.Tool` definitions: the constant is gone and nothing in v2 reads the field.
|
|
|
|
## Transports
|
|
|
|
Server-side transport entry points (`stdio_server()`, `SseServerTransport`, `StreamableHTTPSessionManager`) keep their v1 import paths and signatures, so apart from [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors) the sections below are client-side; the streamable-HTTP lifespan change sits under MCPServer ([lifespan entered once at manager startup](#streamable-http-lifespan-now-entered-once-at-manager-startup)).
|
|
|
|
### `streamablehttp_client` removed
|
|
|
|
The deprecated `streamablehttp_client` function is gone. Use `streamable_http_client(url, *, http_client=None, terminate_on_close=True)`: headers, timeouts, and auth move onto an `httpx2.AsyncClient` you pass as `http_client` (see [Client transports](client/transports.md)), and the yielded tuple drops the `get_session_id` callback (next section).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.client.streamable_http import streamablehttp_client
|
|
|
|
async with streamablehttp_client(
|
|
url="http://localhost:8000/mcp",
|
|
headers={"Authorization": "Bearer token"},
|
|
timeout=30,
|
|
sse_read_timeout=300,
|
|
auth=my_auth,
|
|
) as (read_stream, write_stream, get_session_id):
|
|
...
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
import httpx2
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
|
|
async with httpx2.AsyncClient(
|
|
headers={"Authorization": "Bearer token"},
|
|
timeout=httpx2.Timeout(30, read=300),
|
|
auth=my_auth,
|
|
follow_redirects=True,
|
|
) as http_client:
|
|
async with streamable_http_client(
|
|
url="http://localhost:8000/mcp",
|
|
http_client=http_client,
|
|
) as (read_stream, write_stream):
|
|
...
|
|
```
|
|
|
|
Port `timeout=`/`sse_read_timeout=` as `httpx2.Timeout(timeout, read=sse_read_timeout)` and keep `follow_redirects=True`: a bare `httpx2.AsyncClient()` defaults to a flat 5-second timeout (too short for the long-lived GET stream) and no redirect following, while omitting `http_client` gives you a default client with the v1 values (`Timeout(30, read=300)`, redirects followed). `httpx_client_factory` is gone: call your factory yourself, enter the client it returns (the SDK no longer closes a client it did not create), and pass it as `http_client`.
|
|
|
|
### `get_session_id` callback removed from `streamable_http_client`
|
|
|
|
`streamable_http_client` now yields `(read_stream, write_stream)` instead of a 3-tuple: the `get_session_id` callback, the `StreamableHTTPTransport.get_session_id()` method behind it, and the `GetSessionIdCallback` alias are gone (`from mcp.client.streamable_http import GetSessionIdCallback` raises `ImportError`; inline `Callable[[], str | None]` if you still need the type).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp import ClientSession
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
|
|
async with streamable_http_client(url) as (read_stream, write_stream, get_session_id):
|
|
async with ClientSession(read_stream, write_stream) as session:
|
|
await session.initialize()
|
|
session_id = get_session_id()
|
|
```
|
|
|
|
**After (v2):** unpack two values. If you still need the session ID, read the `mcp-session-id` response header with an `httpx2` event hook (the hook fires on every response; the last entry is the current session's ID):
|
|
|
|
```python
|
|
import httpx2
|
|
|
|
from mcp import ClientSession
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
|
|
captured_session_ids: list[str] = []
|
|
|
|
|
|
async def capture_session_id(response: httpx2.Response) -> None:
|
|
session_id = response.headers.get("mcp-session-id")
|
|
if session_id:
|
|
captured_session_ids.append(session_id)
|
|
|
|
|
|
http_client = httpx2.AsyncClient(event_hooks={"response": [capture_session_id]}, follow_redirects=True)
|
|
|
|
async with http_client:
|
|
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
|
|
async with ClientSession(read_stream, write_stream) as session:
|
|
await session.initialize()
|
|
session_id = captured_session_ids[-1] if captured_session_ids else None
|
|
```
|
|
|
|
### `StreamableHTTPTransport` parameters removed
|
|
|
|
`StreamableHTTPTransport` now takes only `url`; passing `headers`, `timeout`, `sse_read_timeout`, or `auth` raises `TypeError`. Configure them on the `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)` (`headers=`, `auth=`, `timeout=httpx2.Timeout(timeout, read=sse_read_timeout)`), as shown in [`streamablehttp_client` removed](#streamablehttp_client-removed) and [Client transports](client/transports.md). `sse_client` keeps all four parameters; there only `auth` is retyped, to `httpx2.Auth` (see [the `httpx2` swap](#httpx-and-httpx-sse-replaced-by-httpx2)).
|
|
|
|
### `StreamableHTTPTransport.protocol_version` and `MCP_PROTOCOL_VERSION` removed
|
|
|
|
`StreamableHTTPTransport` no longer exposes a `protocol_version` attribute; read the negotiated version from [`session.protocol_version` or `client.protocol_version`](#clientsessionget_server_capabilities-replaced-by-properties). The header-name constant moved from `mcp.client.streamable_http` to `mcp.shared.inbound` under a new name (same value, `"mcp-protocol-version"`):
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
|
|
|
|
headers = {MCP_PROTOCOL_VERSION: "2025-11-25"}
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
|
|
|
headers = {MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}
|
|
```
|
|
|
|
### Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors
|
|
|
|
In v1 a 404 to a request POST injected `McpError` with the positive literal code `32600`; any other non-2xx response raised `httpx.HTTPStatusError`, which escaped `streamable_http_client` as an `ExceptionGroup` and cancelled every pending request. In v2 the transport resolves the failing request with a JSON-RPC error, raised as `MCPError` from that one call, and the connection stays usable.
|
|
|
|
| Server response | v1 | v2 |
|
|
| --- | --- | --- |
|
|
| 404, session established | `McpError` with positive code `32600` | `MCPError(-32600, 'Session terminated')` |
|
|
| 404, no session yet | `McpError` with positive code `32600` | `MCPError(-32601, 'Not Found')` |
|
|
| Any other 4xx/5xx | `httpx.HTTPStatusError` escapes the context as `ExceptionGroup` | `MCPError(-32603, 'Server returned an error response')` |
|
|
| Any of the above with a JSON-RPC error body | body ignored | body's error surfaced verbatim; an SDK server's expired-session 404 arrives as `MCPError(-32600, 'Session not found')` |
|
|
|
|
Two v1 patterns silently stop working: an `except* httpx.HTTPStatusError` around the transport context is dead code, and a session-expiry check on `error.code == 32600` never matches the now-negative `-32600`.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
import httpx
|
|
from mcp import ClientSession
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
from mcp.shared.exceptions import McpError
|
|
|
|
try:
|
|
async with streamable_http_client(url) as (read, write, _):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
try:
|
|
await session.list_tools()
|
|
except McpError as exc:
|
|
if exc.error.code == 32600: # 404: "Session terminated"
|
|
... # rebuild the connection
|
|
else:
|
|
raise
|
|
except* httpx.HTTPStatusError:
|
|
... # any other 4xx/5xx: rebuild the connection
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp import ClientSession, MCPError
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
from mcp.types import INVALID_REQUEST # -32600
|
|
|
|
async with streamable_http_client(url) as (read, write):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
try:
|
|
await session.list_tools()
|
|
except MCPError as exc:
|
|
if exc.code == INVALID_REQUEST and exc.message in ("Session not found", "Session terminated"):
|
|
... # session expired: rebuild the connection
|
|
else:
|
|
raise # only this call failed; the session is still usable
|
|
```
|
|
|
|
Catch `MCPError` per call ([`McpError` renamed to `MCPError`](#mcperror-renamed-to-mcperror)); connect-level failures such as `httpx2.ConnectError` still escape the transport context as an `ExceptionGroup`, so keep context-level handling for those only. Troubleshooting covers [`Server returned an error response`](troubleshooting.md#mcperror-server-returned-an-error-response) and [`Session not found`](troubleshooting.md#mcperror-session-not-found).
|
|
|
|
### `mcp.os.win32.utilities` process helpers changed
|
|
|
|
`mcp.os.win32.utilities` is internal plumbing for `stdio_client`, but code that reused its helpers (e.g. a copy of the stdio client's cleanup) needs two edits: the deprecated `terminate_windows_process` is gone with no replacement (`stdio_client` handles termination itself), and `terminate_windows_process_tree` no longer accepts `timeout_seconds` (the argument was already ignored, so drop it).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.os.win32.utilities import terminate_windows_process_tree
|
|
|
|
await terminate_windows_process_tree(process, timeout_seconds)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.os.win32.utilities import terminate_windows_process_tree
|
|
|
|
await terminate_windows_process_tree(process)
|
|
```
|
|
|
|
### `stdio_server` keeps the protocol streams on private descriptors
|
|
|
|
A POSIX watchdog thread that polls fd 0 to detect a vanished client stops working: while `stdio_server()` (and so any stdio `MCPServer`) is serving, the protocol runs on private duplicates of the standard descriptors, with fd 0 pointing at the null device and fd 1 at stderr (both restored on exit), so the null device never reports `POLLHUP` or `POLLERR` and always polls readable, a hangup wait blocks forever, and an any-event wait fires at startup. Poll a duplicate of the descriptor taken before the server starts:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
import os
|
|
import select
|
|
import threading
|
|
|
|
|
|
def watch_stdin() -> None:
|
|
poller = select.poll()
|
|
poller.register(0, select.POLLHUP | select.POLLERR)
|
|
poller.poll() # blocked until the client closes the pipe
|
|
os._exit(0)
|
|
|
|
|
|
threading.Thread(target=watch_stdin, daemon=True).start()
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
import os
|
|
import select
|
|
import threading
|
|
|
|
wire = os.dup(0) # capture the client pipe before stdio_server() diverts fd 0
|
|
|
|
|
|
def watch_stdin() -> None:
|
|
poller = select.poll()
|
|
poller.register(wire, select.POLLHUP | select.POLLERR)
|
|
poller.poll() # blocked until the client closes the pipe
|
|
os._exit(0)
|
|
|
|
|
|
threading.Thread(target=watch_stdin, daemon=True).start()
|
|
```
|
|
|
|
This also works on v1. If the descriptor cannot be captured before serving begins, watch `os.getppid()` for a change instead: an orphaned server is reparented when its launching parent dies. The redirection also means output flushed to fd 1 while serving (a stray `print()`, an inherited child process) lands on stderr instead of the wire ([Running your server: stdio](run/index.md#stdio)); explicit `stdin=`/`stdout=` streams passed to `stdio_server()` are used as given and not diverted.
|
|
|
|
### WebSocket transport removed
|
|
|
|
`mcp.client.websocket.websocket_client`, `mcp.server.websocket.websocket_server`, and the `mcp[ws]` extra are gone (WebSocket was never part of the MCP spec). Use the streamable HTTP transport instead.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.client.websocket import websocket_client
|
|
|
|
async with websocket_client("ws://localhost:8000/ws") as (read, write):
|
|
...
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
|
|
async with streamable_http_client("http://localhost:8000/mcp") as (read, write):
|
|
...
|
|
```
|
|
|
|
Or connect with `Client("http://localhost:8000/mcp")` directly. On the server, drop the `websocket_server` ASGI route and mount `streamable_http_app()` (available on `MCPServer` and the lowlevel `Server`), or call `mcp.run(transport="streamable-http")`. See [Client transports](client/transports.md) and [Add to an existing app](run/asgi.md).
|
|
|
|
## OAuth and server auth
|
|
|
|
### `RFC7523OAuthClientProvider` and `JWTParameters` removed
|
|
|
|
`RFC7523OAuthClientProvider` and its `JWTParameters` model are removed from `mcp.client.auth.extensions.client_credentials`. Use `ClientCredentialsOAuthProvider` for a client secret, `PrivateKeyJWTOAuthProvider` for a JWT.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.client.auth.extensions.client_credentials import JWTParameters, RFC7523OAuthClientProvider
|
|
|
|
provider = RFC7523OAuthClientProvider(
|
|
server_url=server_url,
|
|
client_metadata=client_metadata,
|
|
storage=storage,
|
|
jwt_parameters=JWTParameters(issuer="my-client-id", subject="my-client-id", jwt_signing_key=key_pem),
|
|
)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.client.auth.extensions.client_credentials import PrivateKeyJWTOAuthProvider, SignedJWTParameters
|
|
|
|
provider = PrivateKeyJWTOAuthProvider(
|
|
server_url=server_url,
|
|
storage=storage,
|
|
client_id="my-client-id",
|
|
assertion_provider=SignedJWTParameters(
|
|
issuer="my-client-id", subject="my-client-id", signing_key=key_pem
|
|
).create_assertion_provider(),
|
|
)
|
|
```
|
|
|
|
The provider sends a `client_credentials` grant with `private_key_jwt` client authentication rather than the `jwt-bearer` grant `RFC7523OAuthClientProvider` sent. Relative to `JWTParameters`, `SignedJWTParameters` drops the `jwt_` prefix (`jwt_signing_key` → `signing_key`, `jwt_signing_algorithm` → `signing_algorithm`, `jwt_lifetime_seconds` → `lifetime_seconds`), renames `claims` to `additional_claims`, and has no `audience` (the provider supplies the authorization server's issuer); a prebuilt `JWTParameters(assertion=...)` becomes `assertion_provider=static_assertion_provider(token)`. See [OAuth clients](client/oauth-clients.md#machine-to-machine). For an enterprise ID-JAG `jwt-bearer` grant, use `IdentityAssertionOAuthProvider` ([Identity assertion](client/identity-assertion.md)). The `authorization_code` flow with `private_key_jwt` token-endpoint authentication has no v2 equivalent. The module also stops re-exporting `OAuthTokenError`; import it from `mcp.client.auth`, its home in both versions.
|
|
|
|
### OAuth metadata URLs no longer gain a trailing slash
|
|
|
|
`OAuthMetadata`, `ProtectedResourceMetadata`, `OAuthClientMetadata`, and `AuthSettings` now set `url_preserve_empty_path=True`: a path-less URL parsed from a string (constructor argument or wire JSON) keeps its empty path instead of gaining a trailing slash.
|
|
|
|
```python
|
|
from mcp.shared.auth import OAuthClientMetadata
|
|
|
|
meta = OAuthClientMetadata.model_validate({"redirect_uris": ["http://localhost:8080"]})
|
|
print(meta.model_dump(mode="json")["redirect_uris"])
|
|
# v1: ['http://localhost:8080/']
|
|
# v2: ['http://localhost:8080']
|
|
```
|
|
|
|
The client sends `redirect_uris[0]` verbatim in the `/authorize` and token requests, and authorization servers match redirect URIs by exact string comparison ([RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §3.1.2.3). If a v1 run registered such a URI via Dynamic Client Registration (as `http://localhost:8080/`) and your `TokenStorage` still returns that client information, clear it so the client re-registers, or write the URI with an explicit trailing slash. Redirect URIs with a path (`http://localhost:3000/callback`) and values passed as `AnyUrl(...)`/`AnyHttpUrl(...)` objects are unchanged.
|
|
|
|
Likewise, a path-less string `issuer_url` or `resource_server_url` in `AuthSettings` is now advertised as `https://as.example.com` rather than `https://as.example.com/`; update anything that pinned the old string form.
|
|
|
|
### OAuth `callback_handler` returns `AuthorizationCodeResult`
|
|
|
|
The `callback_handler` passed to `OAuthClientProvider` must return an `AuthorizationCodeResult` instead of a `(code, state)` tuple; an unchanged tuple-returning handler crashes the authorization flow (`AttributeError`). Forward the redirect's `iss` parameter as well: the provider now validates it against the authorization server's issuer ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207), [SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)) and raises `OAuthFlowError` on a mismatch, or on a missing `iss` when the server advertises `authorization_response_iss_parameter_supported`.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
async def callback_handler() -> tuple[str, str | None]:
|
|
params = parse_qs(urlparse(await wait_for_redirect()).query)
|
|
return params["code"][0], params.get("state", [None])[0]
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.client.auth import AuthorizationCodeResult
|
|
|
|
|
|
async def callback_handler() -> AuthorizationCodeResult:
|
|
params = parse_qs(urlparse(await wait_for_redirect()).query)
|
|
return AuthorizationCodeResult(
|
|
code=params["code"][0],
|
|
state=params.get("state", [None])[0],
|
|
iss=params.get("iss", [None])[0],
|
|
)
|
|
```
|
|
|
|
See [OAuth clients](client/oauth-clients.md) for the full handler contract.
|
|
|
|
### `scopes=` renamed to `scope=` on the client-credentials providers
|
|
|
|
`ClientCredentialsOAuthProvider` and `PrivateKeyJWTOAuthProvider` renamed the `scopes` keyword to `scope`; the value is unchanged (a single space-separated string).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
provider = ClientCredentialsOAuthProvider(server_url, storage, client_id, client_secret, scopes="read write")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
provider = ClientCredentialsOAuthProvider(server_url, storage, client_id, client_secret, scope="read write")
|
|
```
|
|
|
|
The `client_secret_post` token request body also now carries `client_id` next to `client_secret` ([RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §2.3.1), so update any mock authorization server or test that pins the exact form body.
|
|
|
|
### `timeout` parameter removed from `OAuthClientProvider`
|
|
|
|
`OAuthClientProvider` no longer accepts a `timeout` argument, and `OAuthContext.timeout` is gone. The value was stored but never read, so dropping it changes no behavior.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
provider = OAuthClientProvider(server_url, client_metadata, storage, timeout=120.0)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
provider = OAuthClientProvider(server_url, client_metadata, storage)
|
|
```
|
|
|
|
If you passed `timeout` intending to bound the wait for the user to authorize, apply that bound where you actually wait: in your `redirect_handler`/`callback_handler`, e.g. `with anyio.fail_after(120): ...`.
|
|
|
|
### Client rejects authorization server metadata with a mismatched `issuer`
|
|
|
|
`OAuthClientProvider` now requires the authorization server metadata `issuer` to be string-equal to the URL taken from the protected resource metadata's `authorization_servers` list ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) section 3.3, [SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)). v1 never compared the two, so a pairing that disagrees (even by a trailing slash) authenticated under v1 and now fails discovery. For example, protected resource metadata advertising `"authorization_servers": ["https://as.example.com"]` against authorization server metadata with `"issuer": "https://as.example.com/"` raises:
|
|
|
|
```text
|
|
OAuthFlowError: Authorization server metadata issuer mismatch: https://as.example.com/ != https://as.example.com
|
|
```
|
|
|
|
There is no client-side override; make the two strings identical. If the MCP server also uses this SDK, `AuthSettings(issuer_url=...)` is what it advertises in `authorization_servers`, so set it to exactly the authorization server's `issuer`. See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash) for how v2 preserves the exact string form of these URLs.
|
|
|
|
### OAuth client requests `offline_access` and sends `prompt=consent`
|
|
|
|
Per [SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207), when the authorization server's metadata lists `offline_access` in `scopes_supported` and the client's `grant_types` include `refresh_token` (the default), the client appends `offline_access` to the requested scope; whenever `offline_access` is in the scope, the authorization request also carries `prompt=consent`. Unchanged v1 code sends a different authorization URL:
|
|
|
|
**Before (v1):**
|
|
|
|
```text
|
|
https://as.example.com/authorize?...&scope=read
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```text
|
|
https://as.example.com/authorize?...&scope=read+offline_access&prompt=consent
|
|
```
|
|
|
|
End users see the provider's consent screen on every authorization instead of being re-authorized silently, the requested scope is broader by `offline_access` (an authorization server that ties refresh tokens to that scope now issues them), and an authorization server that does not allow `offline_access` for the client can fail the flow with `invalid_scope`.
|
|
|
|
To send the v1 authorization request again, remove the `refresh_token` grant:
|
|
|
|
```python
|
|
from pydantic import AnyUrl
|
|
|
|
from mcp.shared.auth import OAuthClientMetadata
|
|
|
|
client_metadata = OAuthClientMetadata(
|
|
client_name="my-client",
|
|
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
|
|
grant_types=["authorization_code"],
|
|
)
|
|
```
|
|
|
|
Unlike v1, this also drops `refresh_token` from the Dynamic Client Registration payload, so authorization servers that gate refresh tokens on the registered grants stop issuing them. If `offline_access` is already in the selected scope (from a `WWW-Authenticate` challenge or a `scopes_supported` list), `prompt=consent` is still sent regardless of `grant_types`; there is no separate switch for it.
|
|
|
|
### Dynamic Client Registration now sends `application_type`
|
|
|
|
The registration body now always includes `application_type` ([SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)), defaulting to `"native"` (loopback/`localhost` redirect URIs). A browser-based client served from a non-local host must set `"web"`, or an OIDC authorization server may reject its non-loopback redirect URIs:
|
|
|
|
```python
|
|
from pydantic import AnyUrl
|
|
|
|
from mcp.shared.auth import OAuthClientMetadata
|
|
|
|
client_metadata = OAuthClientMetadata(
|
|
redirect_uris=[AnyUrl("https://app.example.com/callback")],
|
|
application_type="web",
|
|
)
|
|
```
|
|
|
|
Non-OIDC authorization servers ignore the parameter.
|
|
|
|
### `OAuthClientInformationFull` no longer subclasses `OAuthClientMetadata`
|
|
|
|
`OAuthClientMetadata` (the registration request) and `OAuthClientInformationFull` (the registered-client record) are now siblings over a shared `OAuthClientMetadataBase` instead of the record subclassing the request. So `isinstance(client_info, OAuthClientMetadata)` is `False`, a record is not assignable where an `OAuthClientMetadata` is annotated, and `validate_scope()`/`validate_redirect_uri()` exist only on the record. On the record, `client_id` is now required, `token_endpoint_auth_method` widens to `str | None`, and `redirect_uris` is optional (it holds whatever an authorization server echoes). Code that treated a record as a request must name `OAuthClientInformationFull`, or `OAuthClientMetadataBase` where either model is acceptable:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from pydantic import AnyUrl
|
|
|
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
|
|
|
|
|
|
def log_registration(metadata: OAuthClientMetadata) -> None:
|
|
print(metadata.client_name, metadata.scope)
|
|
|
|
|
|
client_info = OAuthClientInformationFull( # client_id defaulted to None
|
|
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
|
|
)
|
|
log_registration(client_info) # a record IS-A request
|
|
assert isinstance(client_info, OAuthClientMetadata)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from pydantic import AnyUrl
|
|
|
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadataBase
|
|
|
|
|
|
def log_registration(metadata: OAuthClientMetadataBase) -> None: # request or record
|
|
print(metadata.client_name, metadata.scope)
|
|
|
|
|
|
client_info = OAuthClientInformationFull(
|
|
client_id="my-client", # now required
|
|
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
|
|
)
|
|
log_registration(client_info)
|
|
```
|
|
|
|
On the server side, a custom `OAuthAuthorizationServerProvider.register_client` behind the SDK's registration endpoint now receives `client_secret_expires_at=0` (RFC 7591's "never expires") rather than `None` when a secret is issued and `ClientRegistrationOptions.client_secret_expiry_seconds` is unset.
|
|
|
|
### Stricter client authentication at `/token` and `/revoke`
|
|
|
|
Two behavior changes affect authorization servers embedded via `auth_server_provider=` / `create_auth_routes`.
|
|
|
|
**Failed client authentication at `/token` returns `invalid_client`.** v1 answered every client-authentication failure at `/token` (unknown `client_id`, missing, wrong, or expired secret) with 401 `unauthorized_client`; v2 sends 401 `invalid_client` ([RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §5.2). Update tests, dashboards, or non-SDK clients that match on the error code.
|
|
|
|
**Before (v1):**
|
|
|
|
```text
|
|
401 {"error":"unauthorized_client","error_description":"Invalid client_id"}
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```text
|
|
401 {"error":"invalid_client","error_description":"Invalid client_id"}
|
|
```
|
|
|
|
**Secret-method client records with no stored secret are rejected.** In v1, a client record whose `token_endpoint_auth_method` was `client_secret_post` or `client_secret_basic` but whose `client_secret` was `None` authenticated without a valid secret. v2 rejects it before grant processing: `/token` returns 401 `invalid_client` and `/revoke` returns 401 `unauthorized_client`, both with "Client is registered for secret-based authentication but has no stored secret". Clients registered through `/register` are unaffected (they always receive a secret); hand-provisioned records need a stored secret or a public auth method.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.shared.auth import OAuthClientInformationFull
|
|
|
|
LEGACY_CLIENT = OAuthClientInformationFull(
|
|
client_id="legacy-client",
|
|
client_secret=None, # no secret stored
|
|
token_endpoint_auth_method="client_secret_post", # but a secret-based method
|
|
redirect_uris=["http://localhost:1234/cb"],
|
|
)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.shared.auth import OAuthClientInformationFull
|
|
|
|
LEGACY_CLIENT = OAuthClientInformationFull(
|
|
client_id="legacy-client",
|
|
token_endpoint_auth_method="none", # public client, no secret expected
|
|
redirect_uris=["http://localhost:1234/cb"],
|
|
)
|
|
```
|
|
|
|
## Stricter protocol validation and wire behavior
|
|
|
|
### Server handler results are validated against the protocol schema
|
|
|
|
Server handler results are now validated against the negotiated protocol version's schema before they are sent. A non-conforming result no longer reaches the client: the server logs `handler for '<method>' returned an invalid result` with the pydantic error, and the client gets `-32603` (`INTERNAL_ERROR`, "Handler returned an invalid result"). Validation runs at serialization, not construction, so a bad model still builds and fails only when a handler returns it.
|
|
|
|
The case most existing code hits is a hand-built `Tool` whose input schema lacks the required `"type": "object"` (schemas generated by `@mcp.tool()` already conform):
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.types import Tool
|
|
|
|
Tool(name="ping", inputSchema={})
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import Tool
|
|
|
|
Tool(name="ping", input_schema={"type": "object"})
|
|
```
|
|
|
|
### Client validates inbound traffic against the protocol schema
|
|
|
|
The client (`Client`, and `ClientSession` beneath it) validates server results against the negotiated protocol version's schema before parsing them. Spec-invalid output that v1's lenient parse accepted now raises `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and the other request methods; for example a tool whose `inputSchema` (or `outputSchema`) is `{}`, since the spec requires `"type": "object"`. There is no opt-out: catch `pydantic.ValidationError` around calls to servers you don't control, or fix the server.
|
|
|
|
### Every outbound request now carries a `_meta` envelope; OpenTelemetry is on by default
|
|
|
|
Every request the SDK sends (client-to-server and server-to-client, at every negotiated protocol version) now includes `params._meta`, even where v1 sent no `params` at all. No setting restores the v1 wire shape, so update fixtures, mock servers, and snapshot tests that assert on raw request bytes.
|
|
|
|
**Before (v1)**, `ClientSession` against a 2025-11-25 peer:
|
|
|
|
```text
|
|
{"method":"ping","jsonrpc":"2.0","id":1}
|
|
{"method":"tools/list","jsonrpc":"2.0","id":2}
|
|
```
|
|
|
|
**After (v2)**, the same `ClientSession` code (or `Client(..., mode='legacy')`) against the same peer:
|
|
|
|
```text
|
|
{"jsonrpc":"2.0","id":2,"method":"ping","params":{"_meta":{}}}
|
|
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"_meta":{}}}
|
|
```
|
|
|
|
The envelope is the carrier for OpenTelemetry trace propagation ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)), which v2 enables by default: every server wraps each inbound message in a span and the client opens a span per outbound request, both under the `mcp-python-sdk` tracer. With no OpenTelemetry SDK configured these are no-ops. If your application already installs a global tracer provider, upgrading starts recording MCP client and server spans and injects a W3C `traceparent` into every outbound `_meta`, propagating your trace ids to the servers you call. To opt out, filter the `mcp-python-sdk` instrumentation scope in your span pipeline, or remove the server middleware as shown in [OpenTelemetry](run/opentelemetry.md); the client-side span and `traceparent` injection have no switch.
|
|
|
|
## Testing utilities
|
|
|
|
### `create_connected_server_and_client_session` removed
|
|
|
|
`mcp.shared.memory.create_connected_server_and_client_session` is gone. `Client` takes the `MCPServer` or lowlevel `Server` instance directly and connects in-process (see [Testing](get-started/testing.md)).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.shared.memory import create_connected_server_and_client_session
|
|
|
|
async with create_connected_server_and_client_session(server) as session:
|
|
result = await session.call_tool("my_tool", {"x": 1})
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp import Client
|
|
|
|
async with Client(server) as client:
|
|
result = await client.call_tool("my_tool", {"x": 1})
|
|
```
|
|
|
|
`Client` accepts the helper's keyword arguments (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`, `raise_exceptions`, and `read_timeout_seconds`, now a `float` — see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)). Request methods live on `client`, and `client.session` is the underlying `ClientSession`; `Client`'s `list_*()` methods paginate with `cursor=` rather than `params=PaginatedRequestParams(...)` (see [`cursor` parameter removed from `ClientSession` list methods](#cursor-parameter-removed-from-clientsession-list-methods)).
|
|
|
|
Unlike the helper, which always ran the pre-2026 `initialize` handshake, `Client(server)` negotiates 2026-07-28 by default, so unchanged sampling, elicitation, logging, and change-notification code behaves differently: see [Behavior changes on v2's default connection](#behavior-changes-on-v2s-default-connection). On that connection `session.send_ping()`, `set_logging_level()`, and `subscribe_resource()`/`unsubscribe_resource()` also raise `MCPError` `-32601` (Method not found), and client-to-server progress notifications are not delivered. `Client(server, mode="legacy")` restores the pre-2026 handshake and all of the above.
|
|
|
|
`create_client_server_memory_streams()` remains, but the streams it yields no longer expose anyio's `send_nowait`, `receive_nowait`, or `statistics()`.
|
|
|
|
### Deprecated v1 calls now warn (breaks warnings-as-errors test runs)
|
|
|
|
The APIs the 2026-07-28 spec deprecates — the `Context` logging helpers (`await ctx.info(...)` and friends), the sampling and roots calls, the ported `on_set_logging_level=`-style handlers, and the rest listed in [Deprecated features](deprecated.md) — keep working but now emit `MCPDeprecationWarning`, a `UserWarning` subclass exported from `mcp`. A test suite configured with `filterwarnings = ["error"]` therefore fails on unchanged code, and strict type checkers flag the calls (`reportDeprecated`). Update the calls, or add a `default` filter for the category after the `error` entry:
|
|
|
|
```toml
|
|
[tool.pytest.ini_options]
|
|
filterwarnings = [
|
|
"error",
|
|
"default::mcp.MCPDeprecationWarning",
|
|
]
|
|
```
|
|
|
|
## Behavior changes on v2's default connection
|
|
|
|
v2's `Client` (including the in-process `Client(server)` that replaces v1's test helper) negotiates the 2026-07-28 protocol by default, so the v1 patterns below change behavior even though the server code is untouched. `Client(..., mode="legacy")` reproduces v1's `initialize` handshake and restores each of them; connections negotiated at 2025-11-25 or earlier (a `ClientSession` you `initialize()` yourself, any 2025-era peer) are unaffected.
|
|
|
|
### Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`
|
|
|
|
A handler that reaches back to the client mid-request — `ctx.elicit()`, `ctx.elicit_url()`, `ctx.session.create_message()`, `ctx.session.list_roots()`, or any other `ServerSession` request helper — raises `NoBackChannelError` where the connection has no server-to-client channel: every 2026-07-28 connection (the protocol has no server-initiated requests), a legacy session against a `stateless_http=True` server, and the request-scoped channel of a legacy session against a `json_response=True` server. In the last two cases the v1 call hung until the client's read timeout; v2 fails fast. So an unchanged v1 sampling or elicitation tool fails on its first `Client(server)` test, and `sampling_callback=` / `elicitation_callback=` change nothing — no request ever reaches the client.
|
|
|
|
`NoBackChannelError` (`mcp.shared.exceptions`) subclasses `MCPError` with code `-32600` and message `Cannot send '<method>': this transport context has no back-channel for server-initiated requests.`. Raised inside a handler it reaches the client as a 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)). Notification helpers never raise it: undeliverable notifications are dropped instead (see the next two sections). `UrlElicitationRequiredError` is unaffected; it is an error response, not a request.
|
|
|
|
Two ways to migrate:
|
|
|
|
- **Keep the push behavior for now** by connecting at a pre-2026 version: `Client(server, mode="legacy", sampling_callback=..., elicitation_callback=..., list_roots_callback=...)`; a lowlevel `ClientSession` you `initialize()` yourself already negotiates a 2025-era version. Sampling and roots stay deprecated on this path ([Deprecated features](deprecated.md)).
|
|
- **Port to the era-portable form**: a `Resolve(...)`-backed parameter whose resolver returns `Elicit`, `Sample`, or `ListRoots` makes the server return the question instead of pushing it — the SDK elicits directly on a legacy connection and drives the `InputRequiredResult` multi-round trip at 2026-07-28, with one handler 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 keeps the same `elicitation_callback`; it now answers the returned question.
|
|
|
|
### Log messages are delivered only to requests that opt in
|
|
|
|
A migrated test whose `logging_callback` received every `notifications/message` in v1 now receives none: on a 2026-07-28 connection protocol logging is a per-request opt-in, so `ctx.info(...)` / `ctx.log(...)` on `MCPServer`'s `Context` and `ctx.session.send_log_message(...)` are dropped (debug-logged) unless the request's `_meta` carries `io.modelcontextprotocol/logLevel` at or above the message level. `logging/setLevel` no longer exists either: `client.set_logging_level(...)` raises `MCPError` (`-32601`, method not found) even when the server registered a handler. Opt in with `log_level=` on `Client` (a per-call `meta=` entry overrides it), or pin `Client(server, mode="legacy", ...)` to keep v1's push delivery. See [Client callbacks](client/callbacks.md#the-notification-callbacks).
|
|
|
|
```python
|
|
from mcp import Client
|
|
from mcp.types import LOG_LEVEL_META_KEY
|
|
|
|
async with Client(server, logging_callback=on_log, log_level="info") as client:
|
|
await client.call_tool("chatty", {}) # on_log receives info and above
|
|
await client.call_tool("chatty", {}, meta={LOG_LEVEL_META_KEY: "debug"}) # this call: debug and above
|
|
```
|
|
|
|
### Change notifications travel only on `subscriptions/listen` streams
|
|
|
|
The v1 helpers `ctx.session.send_tool_list_changed()`, `send_prompt_list_changed()`, `send_resource_list_changed()`, and `send_resource_updated(uri)` are dropped with a debug log on a 2026-07-28 connection: there the four change notifications reach a client only through a `subscriptions/listen` stream it opened. Their 2026 counterparts are `await ctx.notify_tools_changed()` / `notify_prompts_changed()` / `notify_resources_changed()` / `notify_resource_updated(uri)` on `MCPServer`'s `Context`, which reach `subscriptions/listen` streams only ([Subscriptions](handlers/subscriptions.md)); neither call reaches the other era's clients, so to notify everyone call both ([Serving legacy clients](run/legacy-clients.md#where-your-code-actually-forks)).
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.fastmcp import Context, FastMCP
|
|
|
|
mcp = FastMCP("Dynamic Tools")
|
|
|
|
|
|
@mcp.tool()
|
|
async def register_plugin(name: str, ctx: Context) -> str:
|
|
# ... register the plugin's tools ...
|
|
await ctx.session.send_tool_list_changed()
|
|
return f"Plugin '{name}' registered"
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server import MCPServer
|
|
from mcp.server.mcpserver import Context
|
|
|
|
mcp = MCPServer("Dynamic Tools")
|
|
|
|
|
|
@mcp.tool()
|
|
async def register_plugin(name: str, ctx: Context) -> str:
|
|
# ... register the plugin's tools ...
|
|
await ctx.notify_tools_changed() # 2026-07-28 clients (subscriptions/listen)
|
|
await ctx.session.send_tool_list_changed() # legacy (<= 2025-11-25) clients
|
|
return f"Plugin '{name}' registered"
|
|
```
|
|
|
|
On a low-level `Server`, publish to your own `SubscriptionBus` (`await bus.publish(ToolsListChanged())` from `mcp.server.subscriptions`) served by `ListenHandler(bus)` on `on_subscriptions_listen=`; see [the low-level composition](handlers/subscriptions.md#the-low-level-composition).
|
|
|
|
## Need Help?
|
|
|
|
If you encounter issues during migration:
|
|
|
|
1. Check the [API Reference](api/mcp/index.md) for updated method signatures
|
|
2. Review the [examples](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples) for updated usage patterns
|
|
3. Open an issue on [GitHub](https://github.com/modelcontextprotocol/python-sdk/issues) if you find a bug or need further assistance
|