FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Fix unknown-method error code and add a protocol version registry by maxisbey · Pull Request #2836 · modelcontextprotocol/python-sdk · GitHub

22 changes: 15 additions & 7 deletions docs/migration.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

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.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

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.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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

Back | FazBrowse Home | New Git URL