1277 lines
55 KiB
Markdown
1277 lines
55 KiB
Markdown
# Migration Guide: v1 to v2
|
|
|
|
This guide covers the breaking changes introduced in v2 of the MCP Python SDK and how to update your code.
|
|
|
|
## Overview
|
|
|
|
Version 2 of the MCP Python SDK introduces several breaking changes to improve the API, align with the MCP specification, and provide better type safety.
|
|
|
|
## Breaking Changes
|
|
|
|
### `streamablehttp_client` removed
|
|
|
|
The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead.
|
|
|
|
**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 httpx
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
|
|
# Configure headers, timeout, and auth on the httpx.AsyncClient
|
|
http_client = httpx.AsyncClient(
|
|
headers={"Authorization": "Bearer token"},
|
|
timeout=httpx.Timeout(30, read=300),
|
|
auth=my_auth,
|
|
follow_redirects=True,
|
|
)
|
|
|
|
async with http_client:
|
|
async with streamable_http_client(
|
|
url="http://localhost:8000/mcp",
|
|
http_client=http_client,
|
|
) as (read_stream, write_stream):
|
|
...
|
|
```
|
|
|
|
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior.
|
|
|
|
### `get_session_id` callback removed from `streamable_http_client`
|
|
|
|
The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple.
|
|
|
|
If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
|
|
async with streamable_http_client(url) as (read_stream, write_stream, get_session_id):
|
|
async with ClientSession(read_stream, write_stream) as session:
|
|
await session.initialize()
|
|
session_id = get_session_id() # Get session ID via callback
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
import httpx
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
|
|
# Option 1: Simply ignore if you don't need the session ID
|
|
async with streamable_http_client(url) as (read_stream, write_stream):
|
|
async with ClientSession(read_stream, write_stream) as session:
|
|
await session.initialize()
|
|
|
|
# Option 2: Capture session ID via httpx event hooks if needed
|
|
captured_session_ids: list[str] = []
|
|
|
|
async def capture_session_id(response: httpx.Response) -> None:
|
|
session_id = response.headers.get("mcp-session-id")
|
|
if session_id:
|
|
captured_session_ids.append(session_id)
|
|
|
|
http_client = httpx.AsyncClient(
|
|
event_hooks={"response": [capture_session_id]},
|
|
follow_redirects=True,
|
|
)
|
|
|
|
async with http_client:
|
|
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
|
|
async with ClientSession(read_stream, write_stream) as session:
|
|
await session.initialize()
|
|
session_id = captured_session_ids[0] if captured_session_ids else None
|
|
```
|
|
|
|
### `StreamableHTTPTransport` parameters removed
|
|
|
|
The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above).
|
|
|
|
Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed.
|
|
|
|
### `terminate_windows_process` removed
|
|
|
|
The deprecated `mcp.os.win32.utilities.terminate_windows_process` function has been
|
|
removed. Process termination is handled internally by the `stdio_client` context
|
|
manager; there is no replacement API. The Windows tree-termination helper
|
|
`terminate_windows_process_tree` no longer accepts a `timeout_seconds` argument —
|
|
the value was never used (Job Object termination is immediate).
|
|
|
|
### `stdio_client` no longer kills children of a gracefully-exited server on POSIX
|
|
|
|
When a server exits on its own after `stdio_client` closes its stdin, background
|
|
child processes the server leaves behind are no longer killed on POSIX — their
|
|
lifetime is the server's business. The old behavior was a side effect of a shutdown
|
|
wait gated on the stdio pipes closing rather than on process exit: a child holding
|
|
an inherited pipe made a well-behaved server look hung, so its whole process tree
|
|
was killed. (That gating is an asyncio behavior specific to Python 3.11+ — on
|
|
Python 3.10 and the trio backend the old wait already resolved on process exit, so
|
|
the spurious kill never fired there.) A server that does not exit within the grace
|
|
period is still terminated
|
|
along with its entire process group. On Windows, children stay in the server's Job
|
|
Object and are still killed at shutdown — now deterministically when the job handle
|
|
is closed, rather than whenever the handle happened to be garbage-collected.
|
|
|
|
If you relied on `stdio_client` killing everything the server spawned, make the
|
|
server terminate its own children on shutdown (its stdin reaching EOF is the
|
|
shutdown signal), or clean up the process tree from the host application after
|
|
`stdio_client` exits.
|
|
|
|
Two related shutdown refinements: `stdio_client` now closes its end of the pipes
|
|
deterministically at shutdown, so a surviving child that keeps writing to an
|
|
inherited stdout receives `EPIPE`/`SIGPIPE` once the client is gone (previously the
|
|
pipe lingered until garbage collection); and a failed write to a server that is
|
|
still running now surfaces as a closed connection (`CONNECTION_CLOSED`) on the read
|
|
side instead of leaving requests waiting indefinitely.
|
|
|
|
`terminate_posix_process_tree` now requires the process to lead its own process
|
|
group (spawned with `start_new_session=True`); the `getpgid()` lookup and the
|
|
per-process terminate/kill fallback are gone. The win32 utilities logger is now
|
|
named `mcp.os.win32.utilities` (was `client.stdio.win32`).
|
|
|
|
### WebSocket transport removed
|
|
|
|
The WebSocket transport has been removed: `mcp.client.websocket.websocket_client`, `mcp.server.websocket.websocket_server`, and the `ws` optional dependency extra (`mcp[ws]`) no longer exist. WebSocket was never part of the MCP specification. Use the streamable HTTP transport instead (`mcp.client.streamable_http.streamable_http_client` on the client, `streamable_http_app()` on the server), which supports bidirectional communication with server-to-client streaming over standard HTTP.
|
|
|
|
### Removed type aliases and classes
|
|
|
|
The following deprecated type aliases and classes have been removed from `mcp.types`:
|
|
|
|
| Removed | Replacement |
|
|
|---------|-------------|
|
|
| `Content` | `ContentBlock` |
|
|
| `ResourceReference` | `ResourceTemplateReference` |
|
|
| `Cursor` | Use `str` directly |
|
|
| `MethodT` | Internal TypeVar, not intended for public use |
|
|
| `RequestParamsT` | Internal TypeVar, not intended for public use |
|
|
| `NotificationParamsT` | Internal TypeVar, not intended for public use |
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.types import Content, ResourceReference, Cursor
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import ContentBlock, ResourceTemplateReference
|
|
# Use `str` instead of `Cursor` for pagination cursors
|
|
```
|
|
|
|
### Field names changed from camelCase to snake_case
|
|
|
|
All Pydantic model fields in `mcp.types` now use snake_case names for Python attribute access. The JSON wire format is unchanged — serialization still uses camelCase via Pydantic aliases.
|
|
|
|
**Before (v1):**
|
|
|
|
```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` |
|
|
|
|
Because `populate_by_name=True` is set, the old camelCase names still work as constructor kwargs (e.g., `Tool(inputSchema={...})` is accepted), but attribute access must use snake_case (`tool.input_schema`).
|
|
|
|
### Server handler results are validated against the protocol schema
|
|
|
|
Results returned from server handlers are now validated against the negotiated protocol version's schema before being sent. A result that does not conform raises on the server side and the client receives an `INTERNAL_ERROR` response. The case most existing code will hit is `Tool.inputSchema`: the spec requires it to contain `"type": "object"`, so an empty `{}` is now rejected.
|
|
|
|
### Client validates inbound traffic against the protocol schema
|
|
|
|
`ClientSession` now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into `mcp.types` models. Spec-invalid server output that the previous monolith parse tolerated may now raise `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and similar calls. `_meta` remains the sanctioned place for result extras (and `experimental` for capability extras).
|
|
|
|
### `args` parameter removed from `ClientSessionGroup.call_tool()`
|
|
|
|
The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead.
|
|
|
|
**Before (v1):**
|
|
|
|
```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"})
|
|
```
|
|
|
|
### `cursor` parameter removed from `ClientSession` list methods
|
|
|
|
The deprecated `cursor` parameter has been removed from the following `ClientSession` methods:
|
|
|
|
- `list_resources()`
|
|
- `list_resource_templates()`
|
|
- `list_prompts()`
|
|
- `list_tools()`
|
|
|
|
Use `params=PaginatedRequestParams(cursor=...)` instead.
|
|
|
|
**Before (v1):**
|
|
|
|
```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"))
|
|
```
|
|
|
|
### `ClientSession.get_server_capabilities()` replaced by `initialize_result` property
|
|
|
|
`ClientSession` now stores the full `InitializeResult` via an `initialize_result` property. This provides access to `server_info`, `capabilities`, `instructions`, and the negotiated `protocol_version` through a single property. The `get_server_capabilities()` method has been removed.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
capabilities = session.get_server_capabilities()
|
|
# server_info, instructions, protocol_version were not stored — had to capture initialize() return value
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
result = session.initialize_result
|
|
if result is not None:
|
|
capabilities = result.capabilities
|
|
server_info = result.server_info
|
|
instructions = result.instructions
|
|
version = result.protocol_version
|
|
```
|
|
|
|
The high-level `Client.initialize_result` returns the same `InitializeResult` but is non-nullable — initialization is guaranteed inside the context manager, so no `None` check is needed. This replaces v1's `Client.server_capabilities`; use `client.initialize_result.capabilities` instead.
|
|
|
|
### `McpError` renamed to `MCPError`
|
|
|
|
The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.
|
|
|
|
**Before (v1):**
|
|
|
|
```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.shared.exceptions import MCPError
|
|
|
|
try:
|
|
result = await session.call_tool("my_tool")
|
|
except MCPError as e:
|
|
print(f"Error: {e.message}")
|
|
```
|
|
|
|
`MCPError` is also exported from the top-level `mcp` package:
|
|
|
|
```python
|
|
from mcp import MCPError
|
|
```
|
|
|
|
The constructor signature also changed — it now takes `code`, `message`, and optional `data` directly instead of wrapping an `ErrorData`:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.shared.exceptions import McpError
|
|
from mcp.types import ErrorData, INVALID_REQUEST
|
|
|
|
raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input"))
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.shared.exceptions import MCPError
|
|
from mcp.types import INVALID_REQUEST
|
|
|
|
raise MCPError(INVALID_REQUEST, "bad input")
|
|
# or, if you already have an ErrorData:
|
|
raise MCPError.from_error_data(error_data)
|
|
```
|
|
|
|
### `FastMCP` renamed to `MCPServer`
|
|
|
|
The `FastMCP` class has been renamed to `MCPServer` to better reflect its role as the main server class in the SDK. This is a simple rename with no functional changes to the class itself.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
mcp = FastMCP("Demo")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server.mcpserver import MCPServer, Context
|
|
|
|
mcp = MCPServer("Demo")
|
|
```
|
|
|
|
`Context` is the type annotation for the `ctx` parameter injected into tools, resources, and prompts (see [`get_context()` removed](#mcpserverget_context-removed) below).
|
|
|
|
All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver.*` with the same structure. Common imports:
|
|
|
|
- `Image`, `Audio` — from `mcp.server.mcpserver` (or `.utilities.types`)
|
|
- `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base`
|
|
- `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions`
|
|
|
|
### `mount_path` parameter removed from MCPServer
|
|
|
|
The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class.
|
|
|
|
This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard `root_path` mechanism. When using Starlette's `Mount("/path", app=mcp.sse_app())`, Starlette automatically sets `root_path` in the ASGI scope, and the `SseServerTransport` uses this to construct the correct message endpoint path.
|
|
|
|
### Transport-specific parameters moved from MCPServer constructor to run()/app methods
|
|
|
|
Transport-specific parameters have been moved from the `MCPServer` constructor to the `run()`, `sse_app()`, and `streamable_http_app()` methods. This provides better separation of concerns - the constructor now only handles server identity and authentication, while transport configuration is passed when starting the server.
|
|
|
|
**Parameters moved:**
|
|
|
|
- `host`, `port` - HTTP server binding
|
|
- `sse_path`, `message_path` - SSE transport paths
|
|
- `streamable_http_path` - StreamableHTTP endpoint path
|
|
- `json_response`, `stateless_http` - StreamableHTTP behavior
|
|
- `event_store`, `retry_interval` - StreamableHTTP event handling
|
|
- `transport_security` - DNS rebinding protection
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
# Transport params in constructor
|
|
mcp = FastMCP("Demo", json_response=True, stateless_http=True)
|
|
mcp.run(transport="streamable-http")
|
|
|
|
# Or for SSE
|
|
mcp = FastMCP("Server", host="0.0.0.0", port=9000, sse_path="/events")
|
|
mcp.run(transport="sse")
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server.mcpserver import MCPServer
|
|
|
|
# Transport params passed to run()
|
|
mcp = MCPServer("Demo")
|
|
mcp.run(transport="streamable-http", json_response=True, stateless_http=True)
|
|
|
|
# Or for SSE
|
|
mcp = MCPServer("Server")
|
|
mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events")
|
|
```
|
|
|
|
**For mounted apps:**
|
|
|
|
When mounting in a Starlette app, pass transport params to the app methods:
|
|
|
|
```python
|
|
# Before (v1)
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
mcp = FastMCP("App", json_response=True)
|
|
app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())])
|
|
|
|
# After (v2)
|
|
from mcp.server.mcpserver import MCPServer
|
|
|
|
mcp = MCPServer("App")
|
|
app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))])
|
|
```
|
|
|
|
**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor.
|
|
|
|
If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor.
|
|
|
|
### `MCPServer.get_context()` removed
|
|
|
|
`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.
|
|
|
|
**If you were calling `get_context()` from inside a tool/resource/prompt:** use the `ctx: Context` parameter injection instead.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
@mcp.tool()
|
|
async def my_tool(x: int) -> str:
|
|
ctx = mcp.get_context()
|
|
await ctx.info("Processing...")
|
|
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.info("Processing...")
|
|
return str(x)
|
|
```
|
|
|
|
### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter
|
|
|
|
`MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise.
|
|
|
|
The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument.
|
|
|
|
### Registering lowlevel handlers from `MCPServer`
|
|
|
|
`MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage]
|
|
async def handle_set_logging_level(level: str) -> None:
|
|
...
|
|
|
|
mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage]
|
|
```
|
|
|
|
In v2, the lowlevel `Server` supports arbitrary request handlers directly via `add_request_handler` (the decorator methods are gone; handlers are otherwise constructor-only). From `MCPServer`, access it via `_lowlevel_server`:
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server import ServerRequestContext
|
|
from mcp.types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams
|
|
|
|
|
|
async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult:
|
|
...
|
|
return EmptyResult()
|
|
|
|
|
|
async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult:
|
|
...
|
|
return EmptyResult()
|
|
|
|
|
|
mcp._lowlevel_server.add_request_handler("logging/setLevel", SetLevelRequestParams, handle_set_logging_level) # pyright: ignore[reportPrivateUsage]
|
|
mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequestParams, handle_subscribe) # pyright: ignore[reportPrivateUsage]
|
|
```
|
|
|
|
`_lowlevel_server` is private and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly.
|
|
|
|
### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed
|
|
|
|
On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `.debug()`, `.info()`, `.warning()`, and `.error()` now take `data: Any` instead of `message: str`, matching the MCP spec's `LoggingMessageNotificationParams.data` field which allows any JSON-serializable value. The `extra` parameter has been removed — pass structured data directly as `data`.
|
|
|
|
The lowlevel `ServerSession.send_log_message(data: Any)` already accepted arbitrary data and is unchanged.
|
|
|
|
`Context.log()` also now accepts all eight RFC-5424 log levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`) via the `LoggingLevel` type, not just the four it previously allowed.
|
|
|
|
```python
|
|
# Before
|
|
await ctx.info("Connection failed", extra={"host": "localhost", "port": 5432})
|
|
await ctx.log(level="info", message="hello")
|
|
|
|
# After
|
|
await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432})
|
|
await ctx.log(level="info", data="hello")
|
|
```
|
|
|
|
Positional calls (`await ctx.info("hello")`) are unaffected.
|
|
|
|
### `Context.elicit()` schema gate validates the rendered schema
|
|
|
|
`Context.elicit()` (and `elicit_with_validation()`) now render the schema first and validate each property against the spec's `PrimitiveSchemaDefinition`, raising `TypeError` at the call site for anything outside it. `Optional[T]` fields render as `{"type": ...}` with the field omitted from `required` (previously the non-spec `anyOf` shape). A bare `list[str]` field is rejected because it renders without the required enum items; use `list[Literal[...]]` or `list[str]` with `json_schema_extra` supplying the items. Unions of multiple primitives (e.g. `int | str`) and nested models are rejected.
|
|
|
|
### Replace `RootModel` by union types with `TypeAdapter` validation
|
|
|
|
The following union types are no longer `RootModel` subclasses:
|
|
|
|
- `ClientRequest`
|
|
- `ServerRequest`
|
|
- `ClientNotification`
|
|
- `ServerNotification`
|
|
- `ClientResult`
|
|
- `ServerResult`
|
|
- `JSONRPCMessage`
|
|
|
|
This means you can no longer access `.root` on these types or use `model_validate()` directly on them. Instead, use the provided `TypeAdapter` instances for validation.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.types import ClientRequest, ServerNotification
|
|
|
|
# Using RootModel.model_validate()
|
|
request = ClientRequest.model_validate(data)
|
|
actual_request = request.root # Accessing the wrapped value
|
|
|
|
notification = ServerNotification.model_validate(data)
|
|
actual_notification = notification.root
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import client_request_adapter, server_notification_adapter
|
|
|
|
# Using TypeAdapter.validate_python()
|
|
request = client_request_adapter.validate_python(data)
|
|
# No .root access needed - request is the actual type
|
|
|
|
notification = server_notification_adapter.validate_python(data)
|
|
# No .root access needed - notification is the actual type
|
|
```
|
|
|
|
The same applies when constructing values — the wrapper call is no longer needed:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
await session.send_notification(ClientNotification(InitializedNotification()))
|
|
await session.send_request(ClientRequest(PingRequest()), EmptyResult)
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
await session.send_notification(InitializedNotification())
|
|
await session.send_request(PingRequest(), EmptyResult)
|
|
```
|
|
|
|
**Available adapters:**
|
|
|
|
| Union Type | Adapter |
|
|
|------------|---------|
|
|
| `ClientRequest` | `client_request_adapter` |
|
|
| `ServerRequest` | `server_request_adapter` |
|
|
| `ClientNotification` | `client_notification_adapter` |
|
|
| `ServerNotification` | `server_notification_adapter` |
|
|
| `ClientResult` | `client_result_adapter` |
|
|
| `ServerResult` | `server_result_adapter` |
|
|
| `JSONRPCMessage` | `jsonrpc_message_adapter` |
|
|
|
|
All adapters are exported from `mcp.types`.
|
|
|
|
### `RequestParams.Meta` replaced with `RequestParamsMeta` TypedDict
|
|
|
|
The nested `RequestParams.Meta` Pydantic model class has been replaced with a top-level `RequestParamsMeta` TypedDict. This affects the `ctx.meta` field in request handlers and any code that imports or references this type.
|
|
|
|
**Key changes:**
|
|
|
|
- `RequestParams.Meta` (Pydantic model) → `RequestParamsMeta` (TypedDict)
|
|
- Attribute access (`meta.progress_token`) → Dictionary access (`meta.get("progress_token")`)
|
|
- `progress_token` field changed from `ProgressToken | None = None` to `NotRequired[ProgressToken]`
|
|
|
|
**In request context handlers:**
|
|
|
|
```python
|
|
# Before (v1)
|
|
@server.call_tool()
|
|
async def handle_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
ctx = server.request_context
|
|
if ctx.meta and ctx.meta.progress_token:
|
|
await ctx.session.send_progress_notification(ctx.meta.progress_token, 0.5, 100)
|
|
|
|
# After (v2)
|
|
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
if ctx.meta and "progress_token" in ctx.meta:
|
|
await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100)
|
|
...
|
|
|
|
server = Server("my-server", on_call_tool=handle_call_tool)
|
|
```
|
|
|
|
### `RequestContext` type parameters simplified
|
|
|
|
The `mcp.shared.context` module has been removed. `RequestContext` is now split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`).
|
|
|
|
**`RequestContext` changes:**
|
|
|
|
- The `RequestContext[SessionT, LifespanContextT, RequestT]` generic no longer exists; use `ClientRequestContext` or `ServerRequestContext[LifespanContextT, RequestT]`
|
|
- Server-specific fields (`lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) moved to new `ServerRequestContext` class in `mcp.server.context`
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.client.session import ClientSession
|
|
from mcp.shared.context import RequestContext, LifespanContextT, RequestT
|
|
|
|
# RequestContext with 3 type parameters
|
|
ctx: RequestContext[ClientSession, LifespanContextT, RequestT]
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.client.context import ClientRequestContext
|
|
from mcp.server.context import ServerRequestContext, LifespanContextT, RequestT
|
|
|
|
# For client-side context (sampling, elicitation, list_roots callbacks)
|
|
ctx: ClientRequestContext
|
|
|
|
# For server-specific context with lifespan and request types
|
|
server_ctx: ServerRequestContext[LifespanContextT, RequestT]
|
|
```
|
|
|
|
`ServerRequestContext` is now a standalone dataclass — it no longer subclasses `RequestContext[ServerSession]`. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) plus a new `protocol_version: str` field, so handler code is unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`.
|
|
|
|
The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient:
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
async def my_tool(ctx: Context[ServerSession, None]) -> str: ...
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
async def my_tool(ctx: Context) -> str: ...
|
|
# or, with an explicit lifespan type:
|
|
async def my_tool(ctx: Context[MyLifespanState]) -> str: ...
|
|
```
|
|
|
|
### `ProgressContext` and `progress()` context manager removed
|
|
|
|
The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` or `session.send_progress_notification()` directly.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.shared.progress import progress
|
|
|
|
with progress(ctx, total=100) as p:
|
|
await p.progress(25)
|
|
```
|
|
|
|
**After — use `Context.report_progress()` (recommended):**
|
|
|
|
```python
|
|
@server.tool()
|
|
async def my_tool(x: int, ctx: Context) -> str:
|
|
await ctx.report_progress(25, 100)
|
|
return "done"
|
|
```
|
|
|
|
**After — use `session.send_progress_notification()` (low-level):**
|
|
|
|
```python
|
|
await session.send_progress_notification(
|
|
progress_token=progress_token,
|
|
progress=25,
|
|
total=100,
|
|
)
|
|
```
|
|
|
|
### `create_connected_server_and_client_session` removed
|
|
|
|
The `create_connected_server_and_client_session` helper in `mcp.shared.memory` has been removed. Use `mcp.client.Client` instead — it accepts a `Server` or `MCPServer` instance directly and handles the in-memory transport and session setup for you.
|
|
|
|
**Before (v1):**
|
|
|
|
```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.client import Client
|
|
|
|
async with Client(server) as client:
|
|
result = await client.call_tool("my_tool", {"x": 1})
|
|
```
|
|
|
|
`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`) plus `raise_exceptions` to surface server-side errors.
|
|
|
|
If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`:
|
|
|
|
```python
|
|
import anyio
|
|
from mcp.client.session import ClientSession
|
|
from mcp.shared.memory import create_client_server_memory_streams
|
|
|
|
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(lambda: server.run(*server_streams, server.create_initialization_options()))
|
|
async with ClientSession(*client_streams) as session:
|
|
await session.initialize()
|
|
...
|
|
tg.cancel_scope.cancel()
|
|
```
|
|
|
|
### Resource URI type changed from `AnyUrl` to `str`
|
|
|
|
The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from pydantic import AnyUrl
|
|
from mcp.types import Resource
|
|
|
|
# Required wrapping in AnyUrl
|
|
resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validation
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.types import Resource
|
|
|
|
# Plain strings accepted
|
|
resource = Resource(name="test", uri="users/me") # Works
|
|
resource = Resource(name="test", uri="custom://scheme") # Works
|
|
resource = Resource(name="test", uri="https://example.com") # Works
|
|
```
|
|
|
|
If your code passes `AnyUrl` objects to URI fields, convert them to strings:
|
|
|
|
```python
|
|
# If you have an AnyUrl from elsewhere
|
|
uri = str(my_any_url) # Convert to string
|
|
```
|
|
|
|
Affected types:
|
|
|
|
- `Resource.uri`
|
|
- `ReadResourceRequestParams.uri`
|
|
- `ResourceContents.uri` (and subclasses `TextResourceContents`, `BlobResourceContents`)
|
|
- `SubscribeRequestParams.uri`
|
|
- `UnsubscribeRequestParams.uri`
|
|
- `ResourceUpdatedNotificationParams.uri`
|
|
|
|
The `Client` and `ClientSession` methods `read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` now only accept `str` for the `uri` parameter. If you were passing `AnyUrl` objects, convert them to strings:
|
|
|
|
```python
|
|
# Before (v1)
|
|
from pydantic import AnyUrl
|
|
|
|
await client.read_resource(AnyUrl("test://resource"))
|
|
|
|
# After (v2)
|
|
await client.read_resource("test://resource")
|
|
# Or if you have an AnyUrl from elsewhere:
|
|
await client.read_resource(str(my_any_url))
|
|
```
|
|
|
|
### Lowlevel `Server`: constructor parameters are now keyword-only
|
|
|
|
All parameters after `name` are now keyword-only. If you were passing `version` or other parameters positionally, use keyword arguments instead:
|
|
|
|
```python
|
|
# Before (v1)
|
|
server = Server("my-server", "1.0")
|
|
|
|
# After (v2)
|
|
server = Server("my-server", version="1.0")
|
|
```
|
|
|
|
### Lowlevel `Server`: type parameter reduced from 2 to 1
|
|
|
|
The `Server` class previously had two type parameters: `Server[LifespanResultT, RequestT]`. The `RequestT` parameter has been removed — handlers now receive typed params directly rather than a generic request type.
|
|
|
|
```python
|
|
# Before (v1)
|
|
from typing import Any
|
|
|
|
from mcp.server.lowlevel.server import Server
|
|
|
|
server: Server[dict[str, Any], Any] = Server(...)
|
|
|
|
# After (v2)
|
|
from typing import Any
|
|
|
|
from mcp.server import Server
|
|
|
|
server: Server[dict[str, Any]] = Server(...)
|
|
```
|
|
|
|
### Lowlevel `Server`: `request_handlers` and `notification_handlers` attributes removed
|
|
|
|
The public `server.request_handlers` and `server.notification_handlers` dictionaries have been removed. Handler registration is now done exclusively through constructor `on_*` keyword arguments. There is no public API to register handlers after construction.
|
|
|
|
```python
|
|
# Before (v1) — direct dict access
|
|
from mcp.types import ListToolsRequest
|
|
|
|
if ListToolsRequest in server.request_handlers:
|
|
...
|
|
|
|
# After (v2) — no public access to handler dicts
|
|
# Use the on_* constructor params to register handlers
|
|
server = Server("my-server", on_list_tools=handle_list_tools)
|
|
```
|
|
|
|
If you need to check whether a handler is registered, track this yourself — there is currently no public introspection API.
|
|
|
|
### Lowlevel `Server`: `add_request_handler` is now public and takes `params_type`
|
|
|
|
The private `_add_request_handler(method, handler)` escape hatch is now the public `add_request_handler(method, params_type, handler)`, alongside a matching `add_notification_handler`. Each takes a `params_type` model that incoming params are validated against before the handler runs. A message with no `params` member validates `{}` against the model, so handlers never receive `None`: all-optional models arrive with their defaults, and models with required fields reject the message as `INVALID_PARAMS` before the handler runs (matching the Go SDK).
|
|
|
|
```python
|
|
# Before (v1 / earlier v2 prereleases)
|
|
server._add_request_handler("custom/method", my_handler)
|
|
|
|
# After (v2)
|
|
server.add_request_handler("custom/method", MyParams, my_handler)
|
|
server.add_notification_handler("notifications/custom", MyNotifyParams, my_notify_handler)
|
|
```
|
|
|
|
### Lowlevel `Server`: private `_handle_*` dispatch methods removed
|
|
|
|
`Server._handle_message`, `_handle_request`, and `_handle_notification` have been removed. The receive loop and per-message dispatch now live in `JSONRPCDispatcher` and `ServerRunner`, which `Server.run()` drives internally.
|
|
|
|
These were private, but some users subclassed `Server` and overrode them to intercept requests. Use middleware instead:
|
|
|
|
```python
|
|
from collections.abc import Mapping
|
|
from typing import Any
|
|
|
|
from mcp.server import Server, ServerRequestContext
|
|
from mcp.server.context import CallNext, HandlerResult
|
|
|
|
|
|
async def logging_middleware(
|
|
ctx: ServerRequestContext[Any, Any], method: str, params: Mapping[str, Any] | None, call_next: CallNext
|
|
) -> HandlerResult:
|
|
print(f"handling {method}")
|
|
result = await call_next()
|
|
print(f"done {method}")
|
|
return result
|
|
|
|
|
|
server = Server("my-server", on_call_tool=...)
|
|
server.middleware.append(logging_middleware)
|
|
```
|
|
|
|
Middleware runs before params validation, so `params` is the raw inbound mapping (or `None`), and it also wraps unknown methods.
|
|
|
|
### Lowlevel `Server.run(raise_exceptions=True)`: transport errors no longer re-raised
|
|
|
|
`raise_exceptions=True` now only governs handler exceptions: an exception raised by an `on_*` handler propagates out of `run()`. The JSON-RPC error response is still written to the client first, regardless of the flag.
|
|
|
|
Previously it also re-raised exceptions yielded by the transport onto the read stream (e.g. JSON parse errors). Those are now debug-logged and dropped regardless of `raise_exceptions`. If you relied on `run()` exiting on a transport-level parse error, that no longer happens.
|
|
|
|
### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params
|
|
|
|
The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.lowlevel.server import Server
|
|
|
|
server = Server("my-server")
|
|
|
|
@server.list_tools()
|
|
async def handle_list_tools():
|
|
return [types.Tool(name="my_tool", description="A tool", inputSchema={})]
|
|
|
|
@server.call_tool()
|
|
async def handle_call_tool(name: str, arguments: dict):
|
|
return [types.TextContent(type="text", text=f"Called {name}")]
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```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}")],
|
|
is_error=False,
|
|
)
|
|
|
|
server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
|
|
```
|
|
|
|
**Key differences:**
|
|
|
|
- Handlers receive `(ctx, params)` instead of the full request object or unpacked arguments. `ctx` is a `ServerRequestContext` with `session` and `lifespan_context` fields (plus `request_id`, `meta`, etc. for request handlers). `params` is the typed request params object.
|
|
- Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`).
|
|
- The automatic `jsonschema` input/output validation that the old `call_tool()` decorator performed has been removed. There is no built-in replacement — if you relied on schema validation in the lowlevel server, you will need to validate inputs yourself in your handler.
|
|
|
|
**Complete handler reference:**
|
|
|
|
All handlers receive `ctx: ServerRequestContext` as the first argument. The second argument and return type are:
|
|
|
|
| v1 decorator | v2 constructor kwarg | `params` type | return type |
|
|
|---|---|---|---|
|
|
| `@server.list_tools()` | `on_list_tools` | `PaginatedRequestParams \| None` | `ListToolsResult` |
|
|
| `@server.call_tool()` | `on_call_tool` | `CallToolRequestParams` | `CallToolResult` |
|
|
| `@server.list_resources()` | `on_list_resources` | `PaginatedRequestParams \| None` | `ListResourcesResult` |
|
|
| `@server.list_resource_templates()` | `on_list_resource_templates` | `PaginatedRequestParams \| None` | `ListResourceTemplatesResult` |
|
|
| `@server.read_resource()` | `on_read_resource` | `ReadResourceRequestParams` | `ReadResourceResult` |
|
|
| `@server.subscribe_resource()` | `on_subscribe_resource` | `SubscribeRequestParams` | `EmptyResult` |
|
|
| `@server.unsubscribe_resource()` | `on_unsubscribe_resource` | `UnsubscribeRequestParams` | `EmptyResult` |
|
|
| `@server.list_prompts()` | `on_list_prompts` | `PaginatedRequestParams \| None` | `ListPromptsResult` |
|
|
| `@server.get_prompt()` | `on_get_prompt` | `GetPromptRequestParams` | `GetPromptResult` |
|
|
| `@server.completion()` | `on_completion` | `CompleteRequestParams` | `CompleteResult` |
|
|
| `@server.set_logging_level()` | `on_set_logging_level` | `SetLevelRequestParams` | `EmptyResult` |
|
|
| — | `on_ping` | `RequestParams \| None` | `EmptyResult` |
|
|
| `@server.progress_notification()` | `on_progress` | `ProgressNotificationParams` | `None` |
|
|
| — | `on_roots_list_changed` | `NotificationParams \| None` | `None` |
|
|
|
|
All `params` and return types are importable from `mcp.types`.
|
|
|
|
**Notification handlers:**
|
|
|
|
```python
|
|
from mcp.server import Server, ServerRequestContext
|
|
from mcp.types import ProgressNotificationParams
|
|
|
|
|
|
async def handle_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None:
|
|
print(f"Progress: {params.progress}/{params.total}")
|
|
|
|
server = Server("my-server", on_progress=handle_progress)
|
|
```
|
|
|
|
### Lowlevel `Server`: automatic return value wrapping removed
|
|
|
|
The old decorator-based handlers performed significant automatic wrapping of return values. This magic has been removed — handlers now return fully constructed result types. If you want these conveniences, use `MCPServer` (previously `FastMCP`) instead of the lowlevel `Server`.
|
|
|
|
**`call_tool()` — structured output wrapping removed:**
|
|
|
|
The old decorator accepted several return types and auto-wrapped them into `CallToolResult`:
|
|
|
|
```python
|
|
# Before (v1) — returning a dict auto-wrapped into structured_content + JSON TextContent
|
|
@server.call_tool()
|
|
async def handle(name: str, arguments: dict) -> dict:
|
|
return {"temperature": 22.5, "city": "London"}
|
|
|
|
# Before (v1) — returning a list auto-wrapped into CallToolResult.content
|
|
@server.call_tool()
|
|
async def handle(name: str, arguments: dict) -> list[TextContent]:
|
|
return [TextContent(type="text", text="Done")]
|
|
```
|
|
|
|
```python
|
|
# After (v2) — construct the full result yourself
|
|
import json
|
|
|
|
async def handle(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
data = {"temperature": 22.5, "city": "London"}
|
|
return CallToolResult(
|
|
content=[TextContent(type="text", text=json.dumps(data, indent=2))],
|
|
structured_content=data,
|
|
)
|
|
```
|
|
|
|
Note: `params.arguments` can be `None` (the old decorator defaulted it to `{}`). Use `params.arguments or {}` to preserve the old behavior.
|
|
|
|
**`read_resource()` — content type wrapping removed:**
|
|
|
|
The old decorator auto-wrapped `Iterable[ReadResourceContents]` (and the deprecated `str`/`bytes` shorthand) into `TextResourceContents`/`BlobResourceContents`, handling base64 encoding and mime-type defaulting:
|
|
|
|
```python
|
|
# Before (v1) — Iterable[ReadResourceContents] auto-wrapped
|
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
|
|
@server.read_resource()
|
|
async def handle(uri: AnyUrl) -> Iterable[ReadResourceContents]:
|
|
return [ReadResourceContents(content="file contents", mime_type="text/plain")]
|
|
|
|
# Before (v1) — str/bytes shorthand (already deprecated in v1)
|
|
@server.read_resource()
|
|
async def handle(uri: str) -> str:
|
|
return "file contents"
|
|
|
|
@server.read_resource()
|
|
async def handle(uri: str) -> bytes:
|
|
return b"\x89PNG..."
|
|
```
|
|
|
|
```python
|
|
# After (v2) — construct TextResourceContents or BlobResourceContents yourself
|
|
import base64
|
|
|
|
async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
|
|
# Text content
|
|
return ReadResourceResult(
|
|
contents=[TextResourceContents(uri=str(params.uri), text="file contents", mime_type="text/plain")]
|
|
)
|
|
|
|
async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
|
|
# Binary content — you must base64-encode it yourself
|
|
return ReadResourceResult(
|
|
contents=[BlobResourceContents(
|
|
uri=str(params.uri),
|
|
blob=base64.b64encode(b"\x89PNG...").decode("utf-8"),
|
|
mime_type="image/png",
|
|
)]
|
|
)
|
|
```
|
|
|
|
**`list_tools()`, `list_resources()`, `list_prompts()` — list wrapping removed:**
|
|
|
|
The old decorators accepted bare lists and wrapped them into the result type:
|
|
|
|
```python
|
|
# Before (v1)
|
|
@server.list_tools()
|
|
async def handle() -> list[Tool]:
|
|
return [Tool(name="my_tool", ...)]
|
|
|
|
# After (v2)
|
|
async def handle(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
return ListToolsResult(tools=[Tool(name="my_tool", ...)])
|
|
```
|
|
|
|
**Using `MCPServer` instead:**
|
|
|
|
If you prefer the convenience of automatic wrapping, use `MCPServer` which still provides these features through its `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` decorators. The lowlevel `Server` is intentionally minimal — it provides no magic and gives you full control over the MCP protocol types.
|
|
|
|
### Lowlevel `Server`: `request_context` property removed
|
|
|
|
The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar has been removed entirely.
|
|
|
|
**Before (v1):**
|
|
|
|
```python
|
|
from mcp.server.lowlevel.server import request_ctx
|
|
|
|
@server.call_tool()
|
|
async def handle_call_tool(name: str, arguments: dict):
|
|
ctx = server.request_context # or request_ctx.get()
|
|
await ctx.session.send_log_message(level="info", data="Processing...")
|
|
return [types.TextContent(type="text", text="Done")]
|
|
```
|
|
|
|
**After (v2):**
|
|
|
|
```python
|
|
from mcp.server import ServerRequestContext
|
|
from mcp.types import CallToolRequestParams, CallToolResult, TextContent
|
|
|
|
|
|
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
await ctx.session.send_log_message(level="info", data="Processing...")
|
|
return CallToolResult(
|
|
content=[TextContent(type="text", text="Done")],
|
|
is_error=False,
|
|
)
|
|
```
|
|
|
|
### `ServerRequestContext`: request-specific fields are now optional
|
|
|
|
`ServerRequestContext` now uses optional fields for request-specific data (`request_id`, `meta`, etc.) so it can be used for both request and notification handlers. In notification handlers, these fields are `None`.
|
|
|
|
```python
|
|
from mcp.server import ServerRequestContext
|
|
|
|
# request_id, meta, etc. are available in request handlers
|
|
# but None in notification handlers
|
|
```
|
|
|
|
### `ServerSession` is now a thin proxy (no longer a `BaseSession`)
|
|
|
|
`ServerSession` no longer subclasses `BaseSession`. It is now a small connection-scoped proxy that exposes `send_request`, `send_notification`, the typed convenience helpers (`create_message`, `elicit_form`, `send_log_message`, `send_tool_list_changed`, ...), `client_params`, `protocol_version`, and `check_client_capability`. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`.
|
|
|
|
`ServerSession` is normally constructed for you by `Server.run()` and reached via `ctx.session` in handlers, so most servers are unaffected. If you were constructing or subclassing it directly:
|
|
|
|
**Constructor change:**
|
|
|
|
```python
|
|
# Before (v1)
|
|
session = ServerSession(read_stream, write_stream, init_options, stateless=False)
|
|
|
|
# After (v2)
|
|
session = ServerSession(dispatcher, connection, stateless=False)
|
|
# where `dispatcher` is a JSONRPCDispatcher and `connection` is a Connection
|
|
```
|
|
|
|
In practice, replace direct `ServerSession` use with `Server.run(read_stream, write_stream, init_options)` and let the framework wire it up.
|
|
|
|
**Removed from `mcp.server.session`:**
|
|
|
|
- `InitializationState` enum and `ServerSession._initialization_state` — initialization tracking is now on `Connection` (`connection.initialized` is an `anyio.Event`, `connection.client_params` holds the init params).
|
|
- `ServerRequestResponder` type alias.
|
|
- `ServerSession.incoming_messages` stream — there is no longer a public stream of inbound messages to iterate. Register handlers via the `on_*` constructor params (or `add_request_handler`) and use `Server.middleware` to observe every inbound request and notification (`initialize`, unknown methods, validation failures, and `notifications/initialized` included).
|
|
- `ServerSession.__aenter__` / `__aexit__` — `ServerSession` is no longer an async context manager.
|
|
- The private `_receive_loop`, `_received_request`, `_received_notification`, and `_handle_incoming` overrides — there is nothing to override on `ServerSession` anymore. To intercept inbound messages, use `Server.middleware` (see the `_handle_*` removal section above).
|
|
|
|
### `BaseSession` / `RequestResponder`: server-side cancellation tracking removed
|
|
|
|
`BaseSession._in_flight` and the `RequestResponder` members that supported it (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument, and the internal `CancelScope`) have been removed. These existed to let `ServerSession` cancel a handler when a `CancelledNotification` arrived; `ServerSession` no longer drives a receive loop, so they were dead code. Inbound-cancellation handling for the server now lives in `JSONRPCDispatcher`.
|
|
|
|
`BaseSession` itself has since been removed entirely; see the next section.
|
|
|
|
### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed
|
|
|
|
`ClientSession`'s public surface is unchanged — same constructor, typed methods, manual `initialize()`, and async context-manager lifecycle — but `BaseSession`, the v1 receive loop underneath it, is removed with no shim. The engine now lives in `JSONRPCDispatcher` (`mcp.shared.jsonrpc_dispatcher`). To customize client behavior, use the `ClientSession` constructor callbacks, or pass a pre-built dispatcher via the new keyword-only `dispatcher=` constructor argument (e.g. a `DirectDispatcher` for in-process embedding).
|
|
|
|
Behavior changes:
|
|
|
|
- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (the request is then answered with an error). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves.
|
|
- **Timeouts**: a timed-out or abandoned request is now followed by `notifications/cancelled`, so the server stops the handler instead of leaving it running.
|
|
- **A raising request callback** is answered with `code=0` and the exception text; v1 flattened every callback exception to `INVALID_PARAMS`. For a specific error response, return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: pydantic's `ValidationError` is still answered with `INVALID_PARAMS`, as in v1.
|
|
- **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. After the connection has closed it raises `MCPError` (`CONNECTION_CLOSED`) instead. `send_notification` before entry still works.
|
|
- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected.
|
|
- **Client callbacks now receive `mcp.client.ClientRequestContext`** (its `request_id` is always populated); the private `mcp.shared._context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession]` become `ClientRequestContext`.
|
|
|
|
`mcp.shared.session` is now a compatibility module: `ProgressFnT` is re-exported (its home is `mcp.shared.dispatcher`), and `RequestResponder` remains as a typing-only stub so `MessageHandlerFnT` annotations keep importing. `RequestResponder.respond()` no longer exists.
|
|
|
|
### Experimental Tasks support removed
|
|
|
|
Tasks (SEP-1686) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp.types` as types-only definitions.
|
|
|
|
Tasks are expected to return as a separate MCP extension in a future release.
|
|
|
|
## Deprecations
|
|
|
|
<!-- Add deprecations below -->
|
|
|
|
## Bug Fixes
|
|
|
|
### Lowlevel `Server`: `subscribe` capability now correctly reported
|
|
|
|
Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior.
|
|
|
|
### Unknown request methods now return `-32601` (Method not found)
|
|
|
|
In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with `-32602` (`"Invalid request parameters"`, empty `data`). Any method the receiver doesn't serve — unrecognized, or a spec method with no registered handler — is now answered with the JSON-RPC-specified `-32601` (`"Method not found"`), with the method name in `data`, on both the server and the client side, in every initialization state. Update anything that matched on the old code for this case.
|
|
|
|
### Extra fields on MCP types are no longer preserved
|
|
|
|
In v1, MCP protocol types were configured with `extra="allow"`: unknown fields passed to a constructor or received from a peer were kept on the model and re-serialized on output.
|
|
|
|
In v2, MCP types silently ignore extra fields. Unknown constructor keyword arguments and unknown keys in wire data are dropped during validation — no error is raised, and the values do not round-trip:
|
|
|
|
```python
|
|
from mcp.types import CallToolRequestParams
|
|
|
|
params = CallToolRequestParams(
|
|
name="my_tool",
|
|
arguments={},
|
|
unknown_field="value", # silently ignored, not stored
|
|
)
|
|
"unknown_field" in params.model_dump() # False
|
|
|
|
# _meta remains the supported place for custom data, per the MCP spec
|
|
params = CallToolRequestParams(
|
|
name="my_tool",
|
|
arguments={},
|
|
_meta={"my_custom_key": "value", "another": 123}, # OK, preserved
|
|
)
|
|
```
|
|
|
|
If you relied on extra fields round-tripping through MCP types, move that data into `_meta`.
|
|
|
|
## New Features
|
|
|
|
### 2025-11-25 and 2026-07-28 protocol fields modeled
|
|
|
|
`mcp.types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `None`; `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.
|
|
|
|
### `streamable_http_app()` available on lowlevel Server
|
|
|
|
The `streamable_http_app()` method is now available directly on the lowlevel `Server` class, not just `MCPServer`. This allows using the streamable HTTP transport without the MCPServer wrapper.
|
|
|
|
```python
|
|
from mcp.server import Server, ServerRequestContext
|
|
from mcp.types import ListToolsResult, PaginatedRequestParams
|
|
|
|
|
|
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
return ListToolsResult(tools=[...])
|
|
|
|
|
|
server = Server("my-server", on_list_tools=handle_list_tools)
|
|
|
|
app = server.streamable_http_app(
|
|
streamable_http_path="/mcp",
|
|
json_response=False,
|
|
stateless_http=False,
|
|
)
|
|
```
|
|
|
|
The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`.
|
|
|
|
## Need Help?
|
|
|
|
If you encounter issues during migration:
|
|
|
|
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
|