Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
cfe3ae4
feat: add Dispatcher Protocol and DirectDispatcher
maxisbey Apr 16, 2026
f53b056
fix: address coverage gaps and stale RequestSender docstring
maxisbey Apr 16, 2026
2e2b2d7
refactor: rename Dispatcher.call to send_request, replace RequestSend…
maxisbey Apr 16, 2026
b5cf756
refactor: rename Outbound.send_request to send_raw_request
maxisbey Apr 16, 2026
163d38f
feat: JSONRPCDispatcher outbound side + parametrized contract tests
maxisbey Apr 16, 2026
4739744
feat: JSONRPCDispatcher receive loop and dispatch (chunk b)
maxisbey Apr 16, 2026
62a555b
feat: JSONRPCDispatcher exception boundary (chunk c)
maxisbey Apr 16, 2026
72913ad
test: JSON-RPC-specific dispatcher tests + coverage to 100%
maxisbey Apr 16, 2026
e0ca9bc
ci: run full matrix on PRs targeting any branch
maxisbey Apr 16, 2026
afc1789
test: address 3.11/3.14 coverage instrumentation quirks
maxisbey Apr 16, 2026
f8f350e
refactor: rename send_request to send_raw_request in JSONRPCDispatcher
maxisbey Apr 16, 2026
689b784
feat: PeerMixin and Peer wrapper
maxisbey Apr 16, 2026
1096712
feat: BaseContext
maxisbey Apr 16, 2026
efd7df7
refactor: follow Outbound.send_raw_request rename in PeerMixin/BaseCo…
maxisbey Apr 16, 2026
7d18a7d
feat: Connection, server Context, typed send_request, meta kwarg
maxisbey Apr 16, 2026
551cacb
test: close PR3 coverage gaps to 100%
maxisbey Apr 16, 2026
445f99a
test: move asserts inside async-with for 3.11 coverage instrumentation
maxisbey Apr 20, 2026
63be3e4
docs: drop development-journal language from docstrings/comments
maxisbey Apr 20, 2026
786bc55
refactor: make BaseContext/Context covariant in their type params
maxisbey Apr 20, 2026
958bdd7
feat: ServerRunner skeleton — _on_request, initialize, init-gate
maxisbey Apr 20, 2026
fb81056
feat: ServerRunner middleware (two-tier) + _on_notify
maxisbey Apr 20, 2026
954874b
feat: ServerRunner.run() and otel_middleware
maxisbey Apr 22, 2026
87579da
test: ServerRunner coverage to 100% — otel span assertions + connecte…
maxisbey Apr 25, 2026
a19735b
test: converge span capture on capfire to fix xdist order-dependence
maxisbey Apr 25, 2026
7123dd9
feat: Server registry stores HandlerEntry; ServerRunner consumes Serv…
maxisbey Apr 30, 2026
c46b529
feat: Connection.state + exit_stack; ctx.session_id/headers; Transpor…
maxisbey May 7, 2026
47989e7
fix: JSONRPCDispatcher coerces string response/progress IDs to int fo…
maxisbey May 8, 2026
7234f0e
feat: DispatchContext.message_metadata passes SessionMessage.metadata…
maxisbey Jun 2, 2026
b87060d
feat: DispatchContext.request_id; drop RequestId arg from transport_b…
maxisbey Jun 2, 2026
caa7ca9
style: ASCII-only docstrings/comments in v2 dispatcher files
maxisbey Jun 2, 2026
013d406
fix: JSONRPCDispatcher.run enters both streams; ClosedResourceError i…
maxisbey Jun 2, 2026
44574ad
fix: align JSONRPCDispatcher error shapes with existing server
maxisbey Jun 2, 2026
e9ee4b4
feat: JSONRPCDispatcher.send_raw_request emits CLIENT span and inject…
maxisbey Jun 2, 2026
130e160
fix: ServerRunner error shapes match existing server (METHOD_NOT_FOUN…
maxisbey Jun 2, 2026
9bdd153
fix: ServerRunner validates spec methods against ClientRequest before…
maxisbey Jun 2, 2026
87e0dbc
feat: ServerRunner builds InitializeResult from InitializationOptions
maxisbey Jun 2, 2026
9d121cf
fix: ServerRunner passes None to notification handlers when params ab…
maxisbey Jun 2, 2026
ca0c67b
fix: JSONRPCDispatcher no-builder __init__ overload accepts all kwargs
maxisbey Jun 2, 2026
16fbef6
fix: JSONRPCDispatcher.run cancels in-flight handlers on read-stream EOF
maxisbey Jun 2, 2026
94b3ce9
chore: drop unused Server.capabilities() helper
maxisbey Jun 2, 2026
ac5ab57
feat: Server.run drives JSONRPCDispatcher + ServerRunner; ServerSessi…
maxisbey Jun 2, 2026
8cca623
Merge origin/main (Tasks removal #2714) into v2-dispatcher-swap
maxisbey Jun 2, 2026
c1c851f
test: rebaseline legacy unit tests for the dispatcher swap; rewrite _…
maxisbey Jun 2, 2026
fa20352
fix: notification handlers and progress callbacks cannot crash the co…
maxisbey Jun 2, 2026
07d94ee
fix: late peer-cancel cannot double-respond; mcpserver completion get…
maxisbey Jun 2, 2026
535d621
feat: JSONRPCDispatcher.inline_methods; otel_middleware sets jsonrpc.…
maxisbey Jun 2, 2026
9f63603
fix: InMemoryTransport EOFs the server instead of cancelling; coverag…
maxisbey Jun 3, 2026
50134cb
test: close remaining server-side coverage gaps; ServerSession.check_…
maxisbey Jun 3, 2026
513ccc3
chore: drop write-only _InFlight.cancelled_by_peer; add assertion to …
maxisbey Jun 3, 2026
5b126bf
docs: migration.md entries for the dispatcher swap
maxisbey Jun 3, 2026
67d61ab
fix: address 11 bughunter findings (parity, latent wiring, lifecycle)
maxisbey Jun 3, 2026
6c51892
fix: BaseSession.__aexit__ healing checkpoint for gh-106749 (CI 3.11/…
maxisbey Jun 3, 2026
48e4c23
fix: gh-106749 healing checkpoints at remaining throw sites; anyio>=4…
maxisbey Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ on:
branches: ["main", "v1.x"]
tags: ["v*.*.*"]
pull_request:
branches: ["main", "v1.x"]

permissions:
contents: read
Expand Down
92 changes: 88 additions & 4 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ async def handle_set_logging_level(level: str) -> None:
mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage]
```

In v2, the lowlevel `Server` no longer has decorator methods (handlers are constructor-only), so the equivalent workaround is `_add_request_handler`:
In v2, the lowlevel `Server` no longer has decorator methods (handlers are constructor-only), so the equivalent workaround is `add_request_handler`:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably wouldn't say workaround here. Instead would say "this is now supported by ___" or something


**After (v2):**

Expand All @@ -461,11 +461,11 @@ async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestPa
return EmptyResult()


mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage]
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]
```

This is a private API 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.
`_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

Expand Down Expand Up @@ -620,6 +620,8 @@ ctx: ClientRequestContext
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`), 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):**
Expand Down Expand Up @@ -813,6 +815,55 @@ 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.

```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 typing import Any

from pydantic import BaseModel

from mcp.server import Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult


async def logging_middleware(
ctx: ServerRequestContext[Any, Any], method: str, params: BaseModel, 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)
```

For lower-level interception (raw method/params before validation, including unknown methods), use `DispatchMiddleware` from `mcp.shared.dispatcher`.

### 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()` instead of being converted to a JSON-RPC error response.

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.
Expand Down Expand Up @@ -1039,6 +1090,39 @@ from mcp.server import ServerRequestContext
# 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`, 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 request.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to make sure middleware works even if a method doesn't exist I think

- `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` or `DispatchMiddleware` (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` is still used by `ClientSession`, which never relied on these members. `RequestResponder.respond()` is unchanged.

### 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 all `Task*` types, the `tasks` capability fields, `Tool.execution`, and the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,9 +417,15 @@ async def handle_unsubscribe(ctx: ServerRequestContext, params: UnsubscribeReque
return EmptyResult()


mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server._add_request_handler("resources/unsubscribe", handle_unsubscribe) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage]
"logging/setLevel", SetLevelRequestParams, handle_set_logging_level
)
mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage]
"resources/subscribe", SubscribeRequestParams, handle_subscribe
)
mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage]
"resources/unsubscribe", UnsubscribeRequestParams, handle_unsubscribe
)


@mcp.completion()
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"anyio>=4.9",
# anyio < 4.10 triggers a compile-time SyntaxWarning on Python 3.14 (PEP 765,
# "'return' in a 'finally' block"); for stdio servers it lands on the child's
# stderr (agronholm/anyio#816, fixed in 4.10).
"anyio>=4.10; python_version >= '3.14'",
"anyio>=4.9; python_version < '3.14'",
"httpx>=0.27.1,<1.0.0",
"httpx-sse>=0.4",
"pydantic>=2.12.0",
Expand Down Expand Up @@ -91,6 +95,7 @@ dev = [
"pillow>=12.0",
"strict-no-cover",
"logfire>=3.0.0",
"opentelemetry-sdk>=1.39.1",
]
docs = [
"mkdocs>=1.6.1",
Expand Down
43 changes: 37 additions & 6 deletions src/mcp/client/_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
from mcp.server.mcpserver import MCPServer
from mcp.shared.memory import create_client_server_memory_streams

SERVER_SHUTDOWN_GRACE = 2.0
"""Seconds to wait for the in-process server to exit on EOF before cancelling."""


class InMemoryTransport:
"""In-memory transport for testing MCP servers without network overhead.
Expand Down Expand Up @@ -48,21 +51,49 @@ async def _connect(self) -> AsyncIterator[TransportStreams]:
client_read, client_write = client_streams
server_read, server_write = server_streams

async with anyio.create_task_group() as tg:
# Start server in background
tg.start_soon(
lambda: actual_server.run(
server_done = anyio.Event()

async def _run_server() -> None:
try:
await actual_server.run(
server_read,
server_write,
actual_server.create_initialization_options(),
raise_exceptions=self._raise_exceptions,
)
)
finally:
server_done.set()

async with anyio.create_task_group() as tg:
tg.start_soon(_run_server)

try:
yield client_read, client_write
finally:
tg.cancel_scope.cancel()
# EOF the server (and our own read side) instead of
# cancelling outright. The dispatcher's run() cancels its
# own in-flight handlers on read-stream EOF, so for a
# well-behaved server the task exits naturally and the
# task-group join below is immediate. Cancelling here
# unconditionally would `coro.throw()` into this task,
# which on CPython 3.11 (gh-106749) drops `'call'` trace
# events for the outer await chain and desyncs coverage's
# CTracer past the test frame.
await client_write.aclose()
await server_write.aclose()
# Backstop: the dispatcher exits on EOF, but the server's
# own teardown (lifespan __aexit__, connection.exit_stack
# callbacks) runs after that and is user code. If it never
# completes the join would hang forever, so bound the wait
# and fall back to cancelling. The healthy path returns
# from wait() without the timeout firing, so the cancel is
# never reached and gh-106749 stays avoided. If the cancel
# does fire, the checkpoint at the end of
# `create_client_server_memory_streams` resyncs the tracer.
with anyio.move_on_after(SERVER_SHUTDOWN_GRACE):
await server_done.wait()
if not server_done.is_set():
tg.cancel_scope.cancel()

async def __aenter__(self) -> TransportStreams:
"""Connect to the server and return streams for communication."""
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/client/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from urllib.parse import parse_qs, urljoin, urlparse

import anyio
import anyio.lowlevel
import httpx
from anyio.abc import TaskStatus
from httpx_sse import SSEError, aconnect_sse
Expand Down Expand Up @@ -157,3 +158,10 @@ async def _send_message(session_message: SessionMessage) -> None:

yield read_stream, write_stream
tg.cancel_scope.cancel()
# The cancel above is delivered via `coro.throw()` into this task at
# the task-group join; on CPython 3.11 (gh-106749) that drops `'call'`
# trace events for the outer await chain and desyncs coverage's CTracer
# past the caller's frame. Yielding once here resumes via `.send()`,
# which re-stamps the missing `'call'` events and resyncs the tracer.
# Shielded so a pending outer cancel is not re-delivered at this point.
await anyio.lowlevel.cancel_shielded_checkpoint()
8 changes: 8 additions & 0 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from dataclasses import dataclass

import anyio
import anyio.lowlevel
import httpx
from anyio.abc import TaskGroup
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
Expand Down Expand Up @@ -586,3 +587,10 @@ def start_get_stream() -> None:
if transport.session_id and terminate_on_close:
await transport.terminate_session(client)
tg.cancel_scope.cancel()
# The cancel above is delivered via `coro.throw()` into this task at
# the task-group join; on CPython 3.11 (gh-106749) that drops `'call'`
# trace events for the outer await chain and desyncs coverage's CTracer
# past the caller's frame. Yielding once here resumes via `.send()`,
# which re-stamps the missing `'call'` events and resyncs the tracer.
# Shielded so a pending outer cancel is not re-delivered at this point.
await anyio.lowlevel.cancel_shielded_checkpoint()
8 changes: 8 additions & 0 deletions src/mcp/client/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from contextlib import asynccontextmanager

import anyio
import anyio.lowlevel
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from pydantic import ValidationError
from websockets.asyncio.client import connect as ws_connect
Expand Down Expand Up @@ -83,3 +84,10 @@ async def ws_writer():

# Once the caller's 'async with' block exits, we shut down
tg.cancel_scope.cancel()
# The cancel above is delivered via `coro.throw()` into this task at
# the task-group join; on CPython 3.11 (gh-106749) that drops `'call'`
# trace events for the outer await chain and desyncs coverage's CTracer
# past the caller's frame. Yielding once here resumes via `.send()`,
# which re-stamps the missing `'call'` events and resyncs the tracer.
# Shielded so a pending outer cancel is not re-delivered at this point.
await anyio.lowlevel.cancel_shielded_checkpoint()
33 changes: 4 additions & 29 deletions src/mcp/server/__main__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import importlib.metadata
import logging
import sys
import warnings

import anyio

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

if not sys.warnoptions:
warnings.simplefilter("ignore")
Expand All @@ -17,32 +14,10 @@
logger = logging.getLogger("server")


async def receive_loop(session: ServerSession):
logger.info("Starting receive loop")
async for message in session.incoming_messages:
if isinstance(message, Exception):
logger.error("Error: %s", message)
continue

logger.info("Received message from client: %s", message)


async def main():
version = importlib.metadata.version("mcp")
async def main() -> None:
server: Server[dict[str, object]] = Server("mcp")
async with stdio_server() as (read_stream, write_stream):
async with (
ServerSession(
read_stream,
write_stream,
InitializationOptions(
server_name="mcp",
server_version=version,
capabilities=ServerCapabilities(),
),
) as session,
write_stream,
):
await receive_loop(session)
await server.run(read_stream, write_stream, server.create_initialization_options())


if __name__ == "__main__":
Expand Down
Loading
Loading