Fix unknown-method error code and add a protocol version registry (#2836)

This commit is contained in:
Max 2026-06-11 16:47:22 +01:00 • committed by GitHub
commit 7267818e44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 430 additions and 23 deletions

View file

@ -1135,7 +1135,7 @@ from mcp.server import ServerRequestContext
### `ServerSession` is now a thin proxy (no longer a `BaseSession`) ### `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: `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:
@ -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. 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 ```python
# This will now raise a validation error
from mcp.types import CallToolRequestParams from mcp.types import CallToolRequestParams
params = CallToolRequestParams( params = CallToolRequestParams(
name="my_tool", name="my_tool",
arguments={}, 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( params = CallToolRequestParams(
name="my_tool", name="my_tool",
arguments={}, 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 ## New Features
### `streamable_http_app()` available on lowlevel Server ### `streamable_http_app()` available on lowlevel Server

View file

@ -49,6 +49,7 @@ from mcp.shared.auth_utils import (
check_resource_allowed, check_resource_allowed,
resource_url_from_server_url, resource_url_from_server_url,
) )
from mcp.shared.version import is_version_at_least
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -172,9 +173,7 @@ class OAuthContext:
if not protocol_version: if not protocol_version:
return False return False
# Check if protocol version is 2025-06-18 or later return is_version_at_least(protocol_version, "2025-06-18")
# Version format is YYYY-MM-DD, so string comparison works
return protocol_version >= "2025-06-18"
def prepare_token_auth( def prepare_token_auth(
self, data: dict[str, str], headers: dict[str, str] | None = None self, data: dict[str, str], headers: dict[str, str] | None = None

View file

@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, Protocol from typing import Any, Protocol, cast, get_args
import anyio.lowlevel import anyio.lowlevel
from pydantic import TypeAdapter from pydantic import BaseModel, TypeAdapter
from mcp import types from mcp import types
from mcp.client._transport import ReadStream, WriteStream from mcp.client._transport import ReadStream, WriteStream
@ -95,6 +95,14 @@ async def _default_logging_callback(
ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) 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( class ClientSession(
BaseSession[ BaseSession[
@ -134,6 +142,10 @@ class ClientSession(
def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]: def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]:
return types.server_request_adapter return types.server_request_adapter
@property
def _receive_request_methods(self) -> frozenset[str]:
return _SERVER_REQUEST_METHODS
@property @property
def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]: def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]:
return types.server_notification_adapter return types.server_notification_adapter

View file

@ -83,6 +83,10 @@ class Connection:
"""The full `initialize` request params; `None` before initialization.""" """The full `initialize` request params; `None` before initialization."""
protocol_version: str | None 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 initialized: anyio.Event
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`); """Set when `notifications/initialized` arrives (matches TS `oninitialized`);

View file

@ -243,12 +243,17 @@ class ServerRunner(Generic[LifespanT]):
# (read loop parked), so awaiting the peer anywhere on this path deadlocks. # (read loop parked), so awaiting the peer anywhere on this path deadlocks.
if method == "initialize": if method == "initialize":
return self._handle_initialize(params) 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: if not self.connection.initialize_accepted and method not in _INIT_EXEMPT:
# Pinned compat: the same error shape the union validation produced. # Pinned compat: the same error shape the union validation produced.
raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="") 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 # Absent params validate as {} (required fields still reject), so
# the handler receives the model with its defaults, never None. # 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) typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False)

View file

@ -50,6 +50,17 @@ class ServerSession:
"""The client's `initialize` request params; `None` before initialization.""" """The client's `initialize` request params; `None` before initialization."""
return self._connection.client_params 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( async def send_request(
self, self,
request: types.ServerRequest, request: types.ServerRequest,

View file

@ -28,7 +28,7 @@ from mcp.server.transport_security import TransportSecurityMiddleware, Transport
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.message import ServerMessageMetadata, SessionMessage 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 ( from mcp.types import (
DEFAULT_NEGOTIATED_VERSION, DEFAULT_NEGOTIATED_VERSION,
INTERNAL_ERROR, INTERNAL_ERROR,
@ -238,7 +238,7 @@ class StreamableHTTPServerTransport:
the stream is closed early because they didn't receive a priming event. the stream is closed early because they didn't receive a priming event.
""" """
# Only provide close callbacks when client supports resumability # 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: async def close_stream_callback() -> None:
self.close_sse_stream(request_id) self.close_sse_stream(request_id)
@ -271,7 +271,7 @@ class StreamableHTTPServerTransport:
if not self._event_store: if not self._event_store:
return return
# Priming events have empty data which older clients cannot handle. # 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 return
priming_event_id = await self._event_store.store_event( priming_event_id = await self._event_store.store_event(
str(request_id), # Convert RequestId to StreamId (str) str(request_id), # Convert RequestId to StreamId (str)

View file

@ -20,6 +20,7 @@ from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMe
from mcp.types import ( from mcp.types import (
CONNECTION_CLOSED, CONNECTION_CLOSED,
INVALID_PARAMS, INVALID_PARAMS,
METHOD_NOT_FOUND,
REQUEST_TIMEOUT, REQUEST_TIMEOUT,
CancelledNotification, CancelledNotification,
ClientNotification, ClientNotification,
@ -286,6 +287,12 @@ class BaseSession(
"""Each subclass must provide its own request adapter.""" """Each subclass must provide its own request adapter."""
raise NotImplementedError 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 @property
def _receive_notification_adapter(self) -> TypeAdapter[ReceiveNotificationT]: def _receive_notification_adapter(self) -> TypeAdapter[ReceiveNotificationT]:
raise NotImplementedError raise NotImplementedError
@ -297,6 +304,18 @@ class BaseSession(
async def _handle_session_message(message: SessionMessage) -> None: async def _handle_session_message(message: SessionMessage) -> None:
sender_context: contextvars.Context | None = getattr(self._read_stream, "last_context", None) sender_context: contextvars.Context | None = getattr(self._read_stream, "last_context", None)
if isinstance(message.message, JSONRPCRequest): 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: try:
validated_request = self._receive_request_adapter.validate_python( validated_request = self._receive_request_adapter.validate_python(
message.message.model_dump(by_alias=True, mode="json", exclude_none=True), message.message.model_dump(by_alias=True, mode="json", exclude_none=True),

View file

@ -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 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",
)
"""Every released protocol revision, oldest to newest."""
SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION] 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)

View file

@ -819,6 +819,24 @@ class TestProtectedResourceMetadata:
assert "resource=" in content 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 @pytest.mark.anyio
async def test_validate_resource_rejects_mismatched_resource( async def test_validate_resource_rejects_mismatched_resource(
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage

View file

@ -113,6 +113,12 @@ async def test_client_is_initialized(app: MCPServer):
assert client.initialize_result.server_info.name == "test" 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): async def test_client_with_simple_server(simple_server: Server):
"""Test that from_server works with a basic Server instance.""" """Test that from_server works with a basic Server instance."""
async with Client(simple_server) as client: async with Client(simple_server) as client:

View file

@ -128,4 +128,6 @@ async def test_complete_without_handler_is_method_not_found(connect: Connect) ->
with pytest.raises(MCPError) as exc_info: with pytest.raises(MCPError) as exc_info:
await client.complete(PromptReference(name="anything"), argument={"name": "topic", "value": ""}) 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")
)

View file

@ -236,7 +236,9 @@ async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect
with pytest.raises(MCPError) as exc_info: with pytest.raises(MCPError) as exc_info:
await client.subscribe_resource("file:///watched.txt") 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") @requirement("resources:unsubscribe")

View file

@ -262,7 +262,9 @@ async def test_set_logging_level_is_rejected_and_messages_are_never_filtered(con
await client.call_tool("chatter", {}) 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( assert received == snapshot(
[ [
LoggingMessageNotificationParams(level="debug", data="noise"), LoggingMessageNotificationParams(level="debug", data="noise"),

View file

@ -168,6 +168,45 @@ async def test_runner_gates_requests_before_initialize(server: SrvT):
assert await client.send_raw_request("ping", None) == {} assert await client.send_raw_request("ping", None) == {}
@pytest.mark.anyio
async def test_runner_unknown_method_before_initialize_raises_method_not_found(server: SrvT):
"""An unknown method is METHOD_NOT_FOUND even before initialize: JSON-RPC
2.0 reserves -32601 for it, and clients probing a server before the
handshake key off that code. The init gate only applies to methods the
server actually serves."""
async with connected_runner(server, initialized=False) as (client, _):
with pytest.raises(MCPError) as exc:
await client.send_raw_request("x/unknown", None)
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="x/unknown")
@pytest.mark.anyio
async def test_runner_spec_method_without_handler_before_initialize_raises_method_not_found(server: SrvT):
"""A spec method the server doesn't serve is METHOD_NOT_FOUND even before
initialize: -32601 means "not available on this server", so probing
clients get the same answer in every initialization state (the fixture
server registers no resources handlers)."""
async with connected_runner(server, initialized=False) as (client, _):
with pytest.raises(MCPError) as exc:
await client.send_raw_request("resources/list", None)
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/list")
@pytest.mark.anyio
async def test_runner_custom_method_with_handler_is_still_gated_before_initialize(server: SrvT):
"""A custom-registered method is a known method: before initialize it is
rejected by the init gate, not answered with METHOD_NOT_FOUND."""
async def greet(ctx: Ctx, params: RequestParams | None) -> Any:
raise NotImplementedError # the gate rejects the request first
server.add_request_handler("custom/greet", RequestParams, greet)
async with connected_runner(server, initialized=False) as (client, _):
with pytest.raises(MCPError) as exc:
await client.send_raw_request("custom/greet", None)
assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
@pytest.mark.anyio @pytest.mark.anyio
async def test_runner_routes_to_handler_and_builds_context(server: SrvT): async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
async with connected_runner(server) as (client, runner): async with connected_runner(server) as (client, runner):
@ -186,7 +225,7 @@ async def test_runner_spec_method_with_no_handler_raises_method_not_found(server
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
with pytest.raises(MCPError) as exc: with pytest.raises(MCPError) as exc:
await client.send_raw_request("resources/list", None) await client.send_raw_request("resources/list", None)
assert exc.value.error.code == METHOD_NOT_FOUND assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/list")
@pytest.mark.anyio @pytest.mark.anyio
@ -196,7 +235,7 @@ async def test_runner_non_spec_method_with_no_handler_raises_method_not_found(se
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
with pytest.raises(MCPError) as exc: with pytest.raises(MCPError) as exc:
await client.send_raw_request("nonexistent/method", None) await client.send_raw_request("nonexistent/method", None)
assert exc.value.error.code == METHOD_NOT_FOUND assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="nonexistent/method")
@pytest.mark.anyio @pytest.mark.anyio

View file

@ -11,6 +11,7 @@ from typing import Any, cast
import pytest import pytest
from mcp import types from mcp import types
from mcp.server import Server, ServerRequestContext
from mcp.server.connection import Connection from mcp.server.connection import Connection
from mcp.server.session import ServerSession from mcp.server.session import ServerSession
from mcp.shared.dispatcher import CallOptions from mcp.shared.dispatcher import CallOptions
@ -26,6 +27,8 @@ from mcp.types import (
SamplingToolsCapability, SamplingToolsCapability,
) )
from .test_runner import connected_runner
class StubDispatcher: class StubDispatcher:
"""Records `send_raw_request` / `notify` calls and returns a canned result.""" """Records `send_raw_request` / `notify` calls and returns a canned result."""
@ -158,3 +161,64 @@ def test_check_client_capability_delegates_to_connection():
session = _make_session(dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability())) session = _make_session(dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability()))
assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True
assert session.check_client_capability(ClientCapabilities(experimental={"x": {}})) is False assert session.check_client_capability(ClientCapabilities(experimental={"x": {}})) is False
def _runner_server(seen_versions: list[str | None]) -> Server[dict[str, Any]]:
"""A lowlevel Server whose tools/list handler records `ctx.session.protocol_version`."""
async def list_tools(
ctx: ServerRequestContext[dict[str, Any], Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
seen_versions.append(ctx.session.protocol_version)
return types.ListToolsResult(tools=[])
return Server(name="test-server", version="0.0.1", on_list_tools=list_tools)
def _init_params(protocol_version: str) -> dict[str, Any]:
return InitializeRequestParams(
protocol_version=protocol_version,
capabilities=ClientCapabilities(),
client_info=Implementation(name="test-client", version="1.0"),
).model_dump(by_alias=True, exclude_none=True)
@pytest.mark.anyio
async def test_protocol_version_is_none_before_initialize():
"""No negotiated version is readable before the initialize handshake."""
async with connected_runner(_runner_server([]), initialized=False) as (_client, runner):
assert runner.session.protocol_version is None
@pytest.mark.anyio
async def test_protocol_version_is_negotiated_version_after_initialize():
"""A supported requested version is echoed back and readable on the session,
both directly and from inside a handler via `ctx.session`."""
seen: list[str | None] = []
async with connected_runner(_runner_server(seen), initialized=False) as (client, runner):
result = await client.send_raw_request("initialize", _init_params("2025-03-26"))
assert result["protocolVersion"] == "2025-03-26"
assert runner.session.protocol_version == "2025-03-26"
await client.send_raw_request("tools/list", None)
assert seen == ["2025-03-26"]
@pytest.mark.anyio
async def test_protocol_version_reads_latest_when_requested_version_unsupported():
"""An unsupported requested version negotiates down to LATEST_PROTOCOL_VERSION."""
async with connected_runner(_runner_server([]), initialized=False) as (client, runner):
result = await client.send_raw_request("initialize", _init_params("1999-01-01"))
assert result["protocolVersion"] == LATEST_PROTOCOL_VERSION
assert runner.session.protocol_version == LATEST_PROTOCOL_VERSION
@pytest.mark.anyio
async def test_protocol_version_is_none_on_stateless_connection():
"""Stateless connections never see a handshake: requests flow, but the
negotiated version legitimately stays None."""
seen: list[str | None] = []
async with connected_runner(_runner_server(seen), initialized=False, stateless=True) as (client, runner):
result = await client.send_raw_request("tools/list", None)
assert result == {"tools": []}
assert seen == [None]
assert runner.session.protocol_version is None

View file

@ -9,6 +9,7 @@ from mcp.shared.memory import create_client_server_memory_streams
from mcp.shared.message import SessionMessage from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder from mcp.shared.session import RequestResponder
from mcp.types import ( from mcp.types import (
METHOD_NOT_FOUND,
PARSE_ERROR, PARSE_ERROR,
CancelledNotification, CancelledNotification,
CancelledNotificationParams, CancelledNotificationParams,
@ -16,6 +17,7 @@ from mcp.types import (
EmptyResult, EmptyResult,
ErrorData, ErrorData,
JSONRPCError, JSONRPCError,
JSONRPCNotification,
JSONRPCRequest, JSONRPCRequest,
JSONRPCResponse, JSONRPCResponse,
ServerNotification, ServerNotification,
@ -403,3 +405,43 @@ async def test_null_id_error_does_not_affect_pending_request():
# Pending request completed successfully # Pending request completed successfully
assert len(result_holder) == 1 assert len(result_holder) == 1
assert isinstance(result_holder[0], EmptyResult) assert isinstance(result_holder[0], EmptyResult)
@pytest.mark.anyio
async def test_receive_loop_answers_unknown_request_method_with_method_not_found():
"""A peer request whose method is not in the receive union gets -32601
(METHOD_NOT_FOUND) on the wire, not a validation failure (-32602)."""
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
async with ClientSession(read_stream=client_read, write_stream=client_write):
await server_write.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=7, method="x/unknown")))
with anyio.fail_after(5): # pragma: no branch
out = await server_read.receive()
assert isinstance(out, SessionMessage)
assert isinstance(out.message, JSONRPCError)
assert out.message.id == 7
assert out.message.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="x/unknown")
@pytest.mark.anyio
async def test_receive_loop_drops_unknown_notification_method_without_response():
"""An unknown notification method is dropped silently: JSON-RPC forbids
responses to notifications, and the receive loop keeps serving."""
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
async with ClientSession(read_stream=client_read, write_stream=client_write):
await server_write.send(SessionMessage(message=JSONRPCNotification(jsonrpc="2.0", method="x/unknown")))
# The next wire output must be the answer to this follow-up ping,
# proving the notification produced no response and the loop survived.
await server_write.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")))
with anyio.fail_after(5): # pragma: no branch
out = await server_read.receive()
assert isinstance(out, SessionMessage)
assert isinstance(out.message, JSONRPCResponse)
assert out.message.id == 1

View file

@ -1696,6 +1696,87 @@ async def test_close_sse_stream_callback_not_provided_for_old_protocol_version()
assert session_msg_new.metadata.close_standalone_sse_stream is not None assert session_msg_new.metadata.close_standalone_sse_stream is not None
@pytest.mark.anyio
async def test_priming_event_not_sent_for_unknown_protocol_version() -> None:
"""_maybe_send_priming_event treats unrecognized version strings conservatively.
A garbage version must not be mistaken for a future one (lexicographically
"zzz" sorts after every date-shaped revision).
"""
transport = StreamableHTTPServerTransport(
"/mcp",
event_store=SimpleEventStore(),
)
write_stream, read_stream = anyio.create_memory_object_stream[dict[str, Any]](1)
try:
await transport._maybe_send_priming_event("test-request-id", write_stream, "zzz")
# Nothing should have been written to the stream
assert write_stream.statistics().current_buffer_used == 0
finally:
await write_stream.aclose()
await read_stream.aclose()
@pytest.mark.anyio
async def test_close_sse_stream_callback_not_provided_for_unknown_protocol_version() -> None:
"""close_sse_stream callbacks are withheld when the client's version is unrecognized."""
transport = StreamableHTTPServerTransport(
"/mcp",
event_store=SimpleEventStore(),
)
mock_message = JSONRPCRequest(jsonrpc="2.0", id="test-1", method="tools/list")
mock_request = MagicMock()
session_msg = transport._create_session_message(mock_message, mock_request, "test-request-id", "zzz")
assert session_msg.metadata is not None
assert isinstance(session_msg.metadata, ServerMessageMetadata)
assert session_msg.metadata.close_sse_stream is None
assert session_msg.metadata.close_standalone_sse_stream is None
@pytest.mark.anyio
async def test_initialize_with_unknown_protocol_version_gets_no_priming_event(
event_app: tuple[SimpleEventStore, Starlette],
) -> None:
"""A garbage protocolVersion in initialize params must not trigger priming.
The priming decision reads the raw body params before any validation, so an
unrecognized string must gate conservatively (old-client behavior), not
compare lexicographically past "2025-11-25".
"""
event_store, app = event_app
init_request = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"clientInfo": {"name": "test-client", "version": "1.0"},
"protocolVersion": "zzz",
"capabilities": {},
},
"id": "init-1",
}
async with make_client(app) as client:
response = await client.post(
"/mcp",
headers={
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
},
json=init_request,
)
assert response.status_code == 200
# The store must have seen traffic (the initialize response), but no
# priming event — priming events are stored with a None payload.
assert event_store._events
assert all(message is not None for _, _, message in event_store._events)
@pytest.mark.anyio @pytest.mark.anyio
async def test_streamable_http_client_receives_priming_event( async def test_streamable_http_client_receives_priming_event(
event_app: tuple[SimpleEventStore, Starlette], event_app: tuple[SimpleEventStore, Starlette],

View file

@ -0,0 +1,59 @@
"""Tests for the protocol-version registry and comparison helpers."""
import pytest
from mcp.shared.version import (
KNOWN_PROTOCOL_VERSIONS,
SUPPORTED_PROTOCOL_VERSIONS,
is_version_at_least,
)
@pytest.mark.parametrize(
("version", "minimum", "expected"),
[
# equal
("2025-11-25", "2025-11-25", True),
("2024-11-05", "2024-11-05", True),
# above
("2025-11-25", "2025-06-18", True),
("2025-06-18", "2024-11-05", True),
# below
("2025-06-18", "2025-11-25", False),
("2024-11-05", "2025-03-26", False),
],
)
def test_is_version_at_least_ordering(version: str, minimum: str, expected: bool) -> None:
"""Known revisions order by registry position: equal, newer, and older pairs."""
assert is_version_at_least(version, minimum) is expected
@pytest.mark.parametrize("version", ["zzz", "", "2025-11-26", "draft", "9999-99-99"])
def test_is_version_at_least_unknown_version_is_false(version: str) -> None:
"""Unrecognized peer strings compare conservatively, never accidentally."""
assert is_version_at_least(version, "2024-11-05") is False
def test_is_version_at_least_unknown_minimum_raises() -> None:
"""An unknown minimum is programmer error, not peer input."""
with pytest.raises(ValueError, match="zzz"):
is_version_at_least("2025-11-25", "zzz")
@pytest.mark.parametrize(
("version", "minimum"), [(v, m) for v in KNOWN_PROTOCOL_VERSIONS for m in KNOWN_PROTOCOL_VERSIONS]
)
def test_is_version_at_least_matches_lexicographic_for_known_versions(version: str, minimum: str) -> None:
"""Drop-in equivalence: for every known (date-shaped) revision pair the helper
agrees with the string comparison it replaced."""
assert is_version_at_least(version, minimum) is (version >= minimum)
def test_supported_versions_are_known() -> None:
"""Every negotiable revision must be in the ordering registry."""
assert set(SUPPORTED_PROTOCOL_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS)
def test_known_versions_are_strictly_ordered() -> None:
"""The registry tuple is the ordering source of truth: ascending, no duplicates."""
assert list(KNOWN_PROTOCOL_VERSIONS) == sorted(set(KNOWN_PROTOCOL_VERSIONS))