Skip to content
Merged
22 changes: 15 additions & 7 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1135,7 +1135,7 @@ from mcp.server import ServerRequestContext

### `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` 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:

Expand Down Expand Up @@ -1182,28 +1182,36 @@ Tasks are expected to return as a separate MCP extension in a future release.

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.

### Extra fields no longer allowed on top-level MCP types
### Unknown request methods now return `-32601` (Method not found)

MCP protocol types no longer accept arbitrary extra fields at the top level. This matches the MCP specification which only allows extra fields within `_meta` objects, not on the types themselves.
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
# This will now raise a validation error
from mcp.types import CallToolRequestParams

params = CallToolRequestParams(
name="my_tool",
arguments={},
unknown_field="value", # ValidationError: extra fields not permitted
unknown_field="value", # silently ignored, not stored
)
"unknown_field" in params.model_dump() # False

# Extra fields are still allowed in _meta
# _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
_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

### `streamable_http_app()` available on lowlevel Server
Expand Down
5 changes: 2 additions & 3 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
check_resource_allowed,
resource_url_from_server_url,
)
from mcp.shared.version import is_version_at_least

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -172,9 +173,7 @@ def should_include_resource_param(self, protocol_version: str | None = None) ->
if not protocol_version:
return False

# Check if protocol version is 2025-06-18 or later
# Version format is YYYY-MM-DD, so string comparison works
return protocol_version >= "2025-06-18"
return is_version_at_least(protocol_version, "2025-06-18")

def prepare_token_auth(
self, data: dict[str, str], headers: dict[str, str] | None = None
Expand Down
16 changes: 14 additions & 2 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from __future__ import annotations

import logging
from typing import Any, Protocol
from typing import Any, Protocol, cast, get_args

import anyio.lowlevel
from pydantic import TypeAdapter
from pydantic import BaseModel, TypeAdapter

from mcp import types
from mcp.client._transport import ReadStream, WriteStream
Expand Down Expand Up @@ -95,6 +95,14 @@ async def _default_logging_callback(

ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData)

_SERVER_REQUEST_METHODS: frozenset[str] = frozenset(
cast(type[BaseModel], arm).model_fields["method"].default for arm in get_args(types.ServerRequest)
)
"""Method names in the SDK's `ServerRequest` union, derived from the
discriminator literal on each arm. Requests for any other method — including
spec methods this SDK deliberately doesn't model, like `tasks/*` — are
answered with METHOD_NOT_FOUND instead of failing union validation."""


class ClientSession(
BaseSession[
Expand Down Expand Up @@ -134,6 +142,10 @@ def __init__(
def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]:
return types.server_request_adapter

@property
def _receive_request_methods(self) -> frozenset[str]:
return _SERVER_REQUEST_METHODS

@property
def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]:
return types.server_notification_adapter
Expand Down
4 changes: 4 additions & 0 deletions src/mcp/server/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ class Connection:
"""The full `initialize` request params; `None` before initialization."""

protocol_version: str | None
"""The protocol version negotiated during `initialize`; `None` before
initialization. Stateless connections don't require the handshake, so this
normally stays `None` there (a client that sends `initialize` anyway still
commits it). Handlers read this as `ServerSession.protocol_version`."""

initialized: anyio.Event
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);
Expand Down
11 changes: 8 additions & 3 deletions src/mcp/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,12 +243,17 @@ async def _inner() -> HandlerResult:
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
if method == "initialize":
return self._handle_initialize(params)
# Methods without a handler are METHOD_NOT_FOUND regardless of
# initialization state: JSON-RPC 2.0 reserves -32601 for "not
# available on this server", and clients probing a server before
# the handshake key off that code. The init gate below therefore
# only ever applies to methods the server actually serves.
entry = self.server.get_request_handler(method)
if entry is None:
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method)
if not self.connection.initialize_accepted and method not in _INIT_EXEMPT:
# Pinned compat: the same error shape the union validation produced.
raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="")
entry = self.server.get_request_handler(method)
if entry is None:
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found")
# Absent params validate as {} (required fields still reject), so
# the handler receives the model with its defaults, never None.
typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False)
Expand Down
11 changes: 11 additions & 0 deletions src/mcp/server/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ def client_params(self) -> types.InitializeRequestParams | None:
"""The client's `initialize` request params; `None` before initialization."""
return self._connection.client_params

@property
def protocol_version(self) -> str | None:
"""The protocol version negotiated during `initialize`.

`None` before initialization completes. Stateless connections don't
require the handshake, so this is normally `None` there (on streamable
HTTP the per-request version is the `MCP-Protocol-Version` header,
available via `ctx.request.headers`).
"""
return self._connection.protocol_version

async def send_request(
self,
request: types.ServerRequest,
Expand Down
6 changes: 3 additions & 3 deletions src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least
from mcp.types import (
DEFAULT_NEGOTIATED_VERSION,
INTERNAL_ERROR,
Expand Down Expand Up @@ -238,7 +238,7 @@ def _create_session_message(
the stream is closed early because they didn't receive a priming event.
"""
# Only provide close callbacks when client supports resumability
if self._event_store and protocol_version >= "2025-11-25":
if self._event_store and is_version_at_least(protocol_version, "2025-11-25"):

async def close_stream_callback() -> None:
self.close_sse_stream(request_id)
Expand Down Expand Up @@ -271,7 +271,7 @@ async def _maybe_send_priming_event(
if not self._event_store:
return
# Priming events have empty data which older clients cannot handle.
if protocol_version < "2025-11-25":
if not is_version_at_least(protocol_version, "2025-11-25"):
return
priming_event_id = await self._event_store.store_event(
str(request_id), # Convert RequestId to StreamId (str)
Expand Down
19 changes: 19 additions & 0 deletions src/mcp/shared/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from mcp.types import (
CONNECTION_CLOSED,
INVALID_PARAMS,
METHOD_NOT_FOUND,
REQUEST_TIMEOUT,
CancelledNotification,
ClientNotification,
Expand Down Expand Up @@ -286,6 +287,12 @@ def _receive_request_adapter(self) -> TypeAdapter[ReceiveRequestT]:
"""Each subclass must provide its own request adapter."""
raise NotImplementedError

@property
def _receive_request_methods(self) -> frozenset[str]:
"""Method names in the receive-request union; anything else is
answered with METHOD_NOT_FOUND before validation is attempted."""
raise NotImplementedError

@property
def _receive_notification_adapter(self) -> TypeAdapter[ReceiveNotificationT]:
raise NotImplementedError
Expand All @@ -297,6 +304,18 @@ async def _receive_loop(self) -> None:
async def _handle_session_message(message: SessionMessage) -> None:
sender_context: contextvars.Context | None = getattr(self._read_stream, "last_context", None)
if isinstance(message.message, JSONRPCRequest):
if message.message.method not in self._receive_request_methods:
# Unknown methods are METHOD_NOT_FOUND (-32601) per
# JSON-RPC 2.0, not validation failures (-32602).
error_response = JSONRPCError(
jsonrpc="2.0",
id=message.message.id,
error=ErrorData(
code=METHOD_NOT_FOUND, message="Method not found", data=message.message.method
),
)
await self._write_stream.send(SessionMessage(message=error_response))
return
try:
validated_request = self._receive_request_adapter.validate_python(
message.message.model_dump(by_alias=True, mode="json", exclude_none=True),
Expand Down
34 changes: 34 additions & 0 deletions src/mcp/shared/version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,37 @@
"""Protocol-version registry and comparison helpers.

Date-string protocol revisions happen to sort lexicographically, but versions
are an enumerated set, not an ordered scalar: future identifiers are not
guaranteed to be date-shaped, and unrecognized peer strings must compare
conservatively instead of accidentally (e.g. "zzz" > "2025-11-25"). All
ordering questions go through KNOWN_PROTOCOL_VERSIONS.
"""

from typing import Final

from mcp.types import LATEST_PROTOCOL_VERSION

KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = (
"2024-11-05",
"2025-03-26",
"2025-06-18",
"2025-11-25",
)
Comment on lines +14 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

not adding 2026-07-28 as part of this yet?

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.

yep not yet

"""Every released protocol revision, oldest to newest."""

SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION]
"""Protocol revisions this SDK can negotiate."""


def is_version_at_least(version: str, minimum: str) -> bool:
"""Return True if `version` is a known revision at least as new as `minimum`.

Unknown `version` strings return False (treat unrecognized peers
conservatively). `minimum` must be a member of KNOWN_PROTOCOL_VERSIONS;
passing anything else is programmer error and raises ValueError.
"""
if minimum not in KNOWN_PROTOCOL_VERSIONS:
raise ValueError(f"minimum must be a known protocol version, got {minimum!r}")
if version not in KNOWN_PROTOCOL_VERSIONS:
return False
return KNOWN_PROTOCOL_VERSIONS.index(version) >= KNOWN_PROTOCOL_VERSIONS.index(minimum)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

looks correct but feels brittle to tie the logic specifically to a random array constant being in the right order, but seems fine 🤷‍♂️ we won't change this that often I guess.

18 changes: 18 additions & 0 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,24 @@ async def test_resource_param_included_with_protected_resource_metadata(self, oa
assert "resource=" in content


@pytest.mark.parametrize(
("protocol_version", "expected"),
[
("2025-03-26", False),
("2025-06-18", True),
("2025-11-25", True),
# Unrecognized strings gate conservatively, even ones sorting after 2025-06-18.
("zzz", False),
("9999-99-99", False),
],
)
def test_should_include_resource_param_by_protocol_version(
oauth_provider: OAuthClientProvider, protocol_version: str, expected: bool
) -> None:
"""Resource param is included only for recognized versions >= 2025-06-18."""
assert oauth_provider.context.should_include_resource_param(protocol_version) is expected


@pytest.mark.anyio
async def test_validate_resource_rejects_mismatched_resource(
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
Expand Down
6 changes: 6 additions & 0 deletions tests/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ async def test_client_is_initialized(app: MCPServer):
assert client.initialize_result.server_info.name == "test"


async def test_client_initialize_result_exposes_negotiated_protocol_version(app: MCPServer):
"""The negotiated protocol version is readable after initialization."""
async with Client(app) as client:
assert client.initialize_result.protocol_version == types.LATEST_PROTOCOL_VERSION


async def test_client_with_simple_server(simple_server: Server):
"""Test that from_server works with a basic Server instance."""
async with Client(simple_server) as client:
Expand Down
4 changes: 3 additions & 1 deletion tests/interaction/lowlevel/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,6 @@ async def test_complete_without_handler_is_method_not_found(connect: Connect) ->
with pytest.raises(MCPError) as exc_info:
await client.complete(PromptReference(name="anything"), argument={"name": "topic", "value": ""})

assert exc_info.value.error == snapshot(ErrorData(code=METHOD_NOT_FOUND, message="Method not found"))
assert exc_info.value.error == snapshot(
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="completion/complete")
)
4 changes: 3 additions & 1 deletion tests/interaction/lowlevel/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,9 @@ async def list_resources(
with pytest.raises(MCPError) as exc_info:
await client.subscribe_resource("file:///watched.txt")

assert exc_info.value.error == snapshot(ErrorData(code=METHOD_NOT_FOUND, message="Method not found"))
assert exc_info.value.error == snapshot(
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/subscribe")
)


@requirement("resources:unsubscribe")
Expand Down
4 changes: 3 additions & 1 deletion tests/interaction/mcpserver/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,9 @@ async def collect(params: LoggingMessageNotificationParams) -> None:

await client.call_tool("chatter", {})

assert exc_info.value.error == snapshot(ErrorData(code=METHOD_NOT_FOUND, message="Method not found"))
assert exc_info.value.error == snapshot(
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="logging/setLevel")
)
assert received == snapshot(
[
LoggingMessageNotificationParams(level="debug", data="noise"),
Expand Down
Loading
Loading