From 89722dd3df5a695dabe7082780eee2ae95b39ccb Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:59:26 +0000 Subject: [PATCH 1/9] Compare protocol versions through a known-version registry Protocol revisions are an enumerated set, not an ordered scalar: future identifiers are not guaranteed to be date-shaped, and unrecognized peer strings must not accidentally compare as newer ("zzz" sorts after every date). Add KNOWN_PROTOCOL_VERSIONS plus is_version_at_least and is_stateful_protocol_version helpers, and replace the lexicographic string comparisons in OAuth resource-param gating and streamable HTTP priming/close-callback gating. Unknown versions now gate conservatively (treated as older than every known revision). --- src/mcp/client/auth/oauth2.py | 5 +- src/mcp/server/streamable_http.py | 6 +-- src/mcp/shared/version.py | 56 +++++++++++++++++++ tests/client/test_auth.py | 19 +++++++ tests/shared/test_streamable_http.py | 79 +++++++++++++++++++++++++++ tests/shared/test_version.py | 81 ++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 tests/shared/test_version.py diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 3c546fda2b..01bcc82347 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -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__) @@ -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 diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 2d1359e03e..220d46f9a3 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -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, @@ -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) @@ -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) diff --git a/src/mcp/shared/version.py b/src/mcp/shared/version.py index d2a1e462d4..4184b42704 100644 --- a/src/mcp/shared/version.py +++ b/src/mcp/shared/version.py @@ -1,3 +1,59 @@ +"""Protocol-version registry and comparison/classification 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", + "2026-07-28", # draft: recognized for ordering/classification, NOT negotiable +) +"""Every protocol revision this SDK knows about, oldest to newest.""" + +DRAFT_PROTOCOL_VERSION: Final[str] = "2026-07-28" +"""The in-progress spec revision. + +Recognized by the helpers in this module but absent from +SUPPORTED_PROTOCOL_VERSIONS until the SDK actually implements it. +""" + SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION] +"""Protocol revisions this SDK can negotiate.""" + +STATEFUL_PROTOCOL_VERSIONS: Final[frozenset[str]] = frozenset({"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"}) +"""Revisions that negotiate via the initialize handshake. + +Closed by design: every revision after 2025-11-25 is stateless and negotiates +per-request, never via initialize. Hardcoded - do not derive from +SUPPORTED_PROTOCOL_VERSIONS. (Matches typescript-sdk's +STATEFUL_PROTOCOL_VERSIONS / isStatefulProtocolVersion.) +""" + + +def is_stateful_protocol_version(version: str) -> bool: + """Return True if `version` negotiates via the initialize handshake.""" + return version in STATEFUL_PROTOCOL_VERSIONS + + +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) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index bb0bce4c92..9d3ea31cff 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -819,6 +819,25 @@ 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), + ("2026-07-28", 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 diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 35b297b393..012e4423e8 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1696,6 +1696,85 @@ 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 +@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 + + # Priming events are stored with a None payload; none may exist for this client. + assert all(message is not None for _, _, message in event_store._events) + + @pytest.mark.anyio async def test_streamable_http_client_receives_priming_event( event_app: tuple[SimpleEventStore, Starlette], diff --git a/tests/shared/test_version.py b/tests/shared/test_version.py new file mode 100644 index 0000000000..a32b19a76a --- /dev/null +++ b/tests/shared/test_version.py @@ -0,0 +1,81 @@ +"""Tests for the protocol-version registry and comparison helpers.""" + +import pytest + +from mcp.shared.version import ( + DRAFT_PROTOCOL_VERSION, + KNOWN_PROTOCOL_VERSIONS, + STATEFUL_PROTOCOL_VERSIONS, + SUPPORTED_PROTOCOL_VERSIONS, + is_stateful_protocol_version, + is_version_at_least, +) +from mcp.types import LATEST_PROTOCOL_VERSION + + +@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), + ("2026-07-28", "2024-11-05", True), + # below + ("2025-06-18", "2025-11-25", False), + ("2024-11-05", "2026-07-28", False), + ], +) +def test_is_version_at_least_ordering(version: str, minimum: str, expected: bool) -> None: + 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_draft_version_is_known_but_not_negotiable_and_not_stateful() -> None: + assert DRAFT_PROTOCOL_VERSION in KNOWN_PROTOCOL_VERSIONS + assert DRAFT_PROTOCOL_VERSION not in SUPPORTED_PROTOCOL_VERSIONS + assert not is_stateful_protocol_version(DRAFT_PROTOCOL_VERSION) + + +def test_draft_version_is_at_least_every_released_version() -> None: + for released in SUPPORTED_PROTOCOL_VERSIONS: + assert is_version_at_least(DRAFT_PROTOCOL_VERSION, released) + + +def test_every_supported_version_is_stateful() -> None: + for version in SUPPORTED_PROTOCOL_VERSIONS: + assert is_stateful_protocol_version(version) + + +def test_supported_versions_are_a_strict_subset_of_known() -> None: + assert set(SUPPORTED_PROTOCOL_VERSIONS) < set(KNOWN_PROTOCOL_VERSIONS) + + +def test_latest_version_is_stateful() -> None: + assert LATEST_PROTOCOL_VERSION in STATEFUL_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)) From 3230c3f6dd9cca1712bc09b9be321b374e3ff4f4 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:59:35 +0000 Subject: [PATCH 2/9] Return METHOD_NOT_FOUND for requests with unknown methods A peer request whose method is not in the session's receive union previously failed union validation and was answered with -32602 (INVALID_PARAMS). JSON-RPC 2.0 reserves -32601 (METHOD_NOT_FOUND) for unknown methods, and peers probing for optional features key off that code. Check the method against the union's discriminator literals before validating and answer unknown ones with -32601, carrying the method name in the error data. Unknown notification methods are still dropped without a response. --- src/mcp/client/session.py | 15 +++++++++++-- src/mcp/shared/session.py | 19 ++++++++++++++++ tests/shared/test_session.py | 42 ++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 08f532eca5..7e379d702b 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -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 @@ -95,6 +95,13 @@ 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 spec `ServerRequest` union, derived from the +discriminator literal on each arm. Requests for any other method are answered +with METHOD_NOT_FOUND instead of failing union validation.""" + class ClientSession( BaseSession[ @@ -134,6 +141,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 diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 50597e10e8..61279ad8b8 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -20,6 +20,7 @@ from mcp.types import ( CONNECTION_CLOSED, INVALID_PARAMS, + METHOD_NOT_FOUND, REQUEST_TIMEOUT, CancelledNotification, ClientNotification, @@ -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 @@ -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), diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py index 8a53b0819d..38f36d82cc 100644 --- a/tests/shared/test_session.py +++ b/tests/shared/test_session.py @@ -9,6 +9,7 @@ from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder from mcp.types import ( + METHOD_NOT_FOUND, PARSE_ERROR, CancelledNotification, CancelledNotificationParams, @@ -16,6 +17,7 @@ EmptyResult, ErrorData, JSONRPCError, + JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, ServerNotification, @@ -403,3 +405,43 @@ async def make_request(client_session: ClientSession): # Pending request completed successfully assert len(result_holder) == 1 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 From 4468a7fdd368ba0578df3fb29e6237b4dcd1e5ef Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:59:44 +0000 Subject: [PATCH 3/9] Answer unknown methods with METHOD_NOT_FOUND before the initialize gate The runner's pre-initialize gate rejected every non-exempt request with INVALID_PARAMS, including methods the server does not know at all. JSON-RPC 2.0 reserves -32601 for unknown methods regardless of session state, and clients probing a server before the handshake key off that code. Check for unknown methods before the gate; known-but-ungated methods keep the existing rejection. METHOD_NOT_FOUND errors now carry the method name in the error data. --- src/mcp/server/runner.py | 7 ++++- tests/interaction/lowlevel/test_completion.py | 4 ++- tests/interaction/lowlevel/test_resources.py | 4 ++- tests/interaction/mcpserver/test_context.py | 4 ++- tests/server/test_runner.py | 31 +++++++++++++++++-- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 4f8d23b8dd..4c5409d5b7 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -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) + # Unknown methods are METHOD_NOT_FOUND regardless of initialization + # state: JSON-RPC 2.0 reserves -32601 for them, and clients probing + # a server before the handshake key off that code. + if method not in _SPEC_CLIENT_METHODS and self.server.get_request_handler(method) 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") + raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) # 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) diff --git a/tests/interaction/lowlevel/test_completion.py b/tests/interaction/lowlevel/test_completion.py index 6a35404df3..f12671d935 100644 --- a/tests/interaction/lowlevel/test_completion.py +++ b/tests/interaction/lowlevel/test_completion.py @@ -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") + ) diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py index 4e369d3645..e42b69d7ec 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -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") diff --git a/tests/interaction/mcpserver/test_context.py b/tests/interaction/mcpserver/test_context.py index 26556fea7a..fdf898c99f 100644 --- a/tests/interaction/mcpserver/test_context.py +++ b/tests/interaction/mcpserver/test_context.py @@ -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"), diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 77081fbeba..16a1006938 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -168,6 +168,33 @@ async def test_runner_gates_requests_before_initialize(server: SrvT): 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 knows.""" + 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_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 async def test_runner_routes_to_handler_and_builds_context(server: SrvT): async with connected_runner(server) as (client, runner): @@ -186,7 +213,7 @@ async def test_runner_spec_method_with_no_handler_raises_method_not_found(server async with connected_runner(server) as (client, _): with pytest.raises(MCPError) as exc: 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 @@ -196,7 +223,7 @@ async def test_runner_non_spec_method_with_no_handler_raises_method_not_found(se async with connected_runner(server) as (client, _): with pytest.raises(MCPError) as exc: 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 From 3789d001a58944976a8cbe1c5e01e4a369b671c5 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:59:53 +0000 Subject: [PATCH 4/9] Expose the negotiated protocol version on ServerSession Handlers had no way to read which protocol revision the initialize handshake settled on. Add ServerSession.protocol_version, backed by the connection state: None before initialization and always None on stateless connections, where no handshake reaches the session and the per-request version lives in the MCP-Protocol-Version header. --- src/mcp/server/connection.py | 3 ++ src/mcp/server/session.py | 11 +++++++ tests/client/test_client.py | 6 ++++ tests/server/test_session.py | 63 ++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+) diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 686c709c9a..0f2e2170c9 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -83,6 +83,9 @@ class Connection: """The full `initialize` request params; `None` before initialization.""" protocol_version: str | None + """The protocol version negotiated during `initialize`; `None` before + initialization, and always `None` on stateless connections (no handshake + reaches them). Handlers read this as `ServerSession.protocol_version`.""" initialized: anyio.Event """Set when `notifications/initialized` arrives (matches TS `oninitialized`); diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 14642152a6..a61aa9e516 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -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, and always `None` on stateless + connections (no handshake reaches them; 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, diff --git a/tests/client/test_client.py b/tests/client/test_client.py index ac52a9024a..d9c7924090 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -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: diff --git a/tests/server/test_session.py b/tests/server/test_session.py index a713a79b65..df2dc218fc 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -11,6 +11,7 @@ import pytest from mcp import types +from mcp.server import Server, ServerRequestContext from mcp.server.connection import Connection from mcp.server.session import ServerSession from mcp.shared.dispatcher import CallOptions @@ -26,6 +27,8 @@ SamplingToolsCapability, ) +from .test_runner import connected_runner + class StubDispatcher: """Records `send_raw_request` / `notify` calls and returns a canned result.""" @@ -158,3 +161,63 @@ def test_check_client_capability_delegates_to_connection(): session = _make_session(dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability())) assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True 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(): + 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 From 2374c997e0b585dcdf56d393f329c943e7065a83 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:00:02 +0000 Subject: [PATCH 5/9] docs: extra fields on MCP types are ignored, not rejected The migration guide claimed v2 MCP types raise a ValidationError for unknown top-level fields. They are configured with pydantic's default extra behavior, which silently drops unknown fields during validation. Describe the actual behavior (values do not round-trip, no error) and keep pointing users at _meta for custom data. --- docs/migration.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index ddd9cd3a65..3e6a6d5e3b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1182,28 +1182,32 @@ 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 +### Extra fields on MCP types are no longer preserved -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, 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 From 22176efcd13f6ad9ea79a12ffc4c40bbdac5e194 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:29:51 +0000 Subject: [PATCH 6/9] Limit the version registry to released protocol versions KNOWN_PROTOCOL_VERSIONS now lists only released revisions (2024-11-05 through 2025-11-25). Drop DRAFT_PROTOCOL_VERSION, STATEFUL_PROTOCOL_VERSIONS, and is_stateful_protocol_version: nothing uses them yet, and with no post-2025-11-25 revision in the registry the stateful classification is vacuous. They can return alongside the code that needs them. --- src/mcp/shared/version.py | 26 ++------------------------ tests/client/test_auth.py | 1 - tests/shared/test_version.py | 33 +++++---------------------------- 3 files changed, 7 insertions(+), 53 deletions(-) diff --git a/src/mcp/shared/version.py b/src/mcp/shared/version.py index 4184b42704..44154da365 100644 --- a/src/mcp/shared/version.py +++ b/src/mcp/shared/version.py @@ -1,4 +1,4 @@ -"""Protocol-version registry and comparison/classification helpers. +"""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 @@ -16,34 +16,12 @@ "2025-03-26", "2025-06-18", "2025-11-25", - "2026-07-28", # draft: recognized for ordering/classification, NOT negotiable ) -"""Every protocol revision this SDK knows about, oldest to newest.""" - -DRAFT_PROTOCOL_VERSION: Final[str] = "2026-07-28" -"""The in-progress spec revision. - -Recognized by the helpers in this module but absent from -SUPPORTED_PROTOCOL_VERSIONS until the SDK actually implements it. -""" +"""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.""" -STATEFUL_PROTOCOL_VERSIONS: Final[frozenset[str]] = frozenset({"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"}) -"""Revisions that negotiate via the initialize handshake. - -Closed by design: every revision after 2025-11-25 is stateless and negotiates -per-request, never via initialize. Hardcoded - do not derive from -SUPPORTED_PROTOCOL_VERSIONS. (Matches typescript-sdk's -STATEFUL_PROTOCOL_VERSIONS / isStatefulProtocolVersion.) -""" - - -def is_stateful_protocol_version(version: str) -> bool: - """Return True if `version` negotiates via the initialize handshake.""" - return version in STATEFUL_PROTOCOL_VERSIONS - def is_version_at_least(version: str, minimum: str) -> bool: """Return True if `version` is a known revision at least as new as `minimum`. diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 9d3ea31cff..ca7a495e6c 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -825,7 +825,6 @@ async def test_resource_param_included_with_protected_resource_metadata(self, oa ("2025-03-26", False), ("2025-06-18", True), ("2025-11-25", True), - ("2026-07-28", True), # Unrecognized strings gate conservatively, even ones sorting after 2025-06-18. ("zzz", False), ("9999-99-99", False), diff --git a/tests/shared/test_version.py b/tests/shared/test_version.py index a32b19a76a..b79fa576a3 100644 --- a/tests/shared/test_version.py +++ b/tests/shared/test_version.py @@ -3,14 +3,10 @@ import pytest from mcp.shared.version import ( - DRAFT_PROTOCOL_VERSION, KNOWN_PROTOCOL_VERSIONS, - STATEFUL_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS, - is_stateful_protocol_version, is_version_at_least, ) -from mcp.types import LATEST_PROTOCOL_VERSION @pytest.mark.parametrize( @@ -21,10 +17,10 @@ ("2024-11-05", "2024-11-05", True), # above ("2025-11-25", "2025-06-18", True), - ("2026-07-28", "2024-11-05", True), + ("2025-06-18", "2024-11-05", True), # below ("2025-06-18", "2025-11-25", False), - ("2024-11-05", "2026-07-28", False), + ("2024-11-05", "2025-03-26", False), ], ) def test_is_version_at_least_ordering(version: str, minimum: str, expected: bool) -> None: @@ -52,28 +48,9 @@ def test_is_version_at_least_matches_lexicographic_for_known_versions(version: s assert is_version_at_least(version, minimum) is (version >= minimum) -def test_draft_version_is_known_but_not_negotiable_and_not_stateful() -> None: - assert DRAFT_PROTOCOL_VERSION in KNOWN_PROTOCOL_VERSIONS - assert DRAFT_PROTOCOL_VERSION not in SUPPORTED_PROTOCOL_VERSIONS - assert not is_stateful_protocol_version(DRAFT_PROTOCOL_VERSION) - - -def test_draft_version_is_at_least_every_released_version() -> None: - for released in SUPPORTED_PROTOCOL_VERSIONS: - assert is_version_at_least(DRAFT_PROTOCOL_VERSION, released) - - -def test_every_supported_version_is_stateful() -> None: - for version in SUPPORTED_PROTOCOL_VERSIONS: - assert is_stateful_protocol_version(version) - - -def test_supported_versions_are_a_strict_subset_of_known() -> None: - assert set(SUPPORTED_PROTOCOL_VERSIONS) < set(KNOWN_PROTOCOL_VERSIONS) - - -def test_latest_version_is_stateful() -> None: - assert LATEST_PROTOCOL_VERSION in STATEFUL_PROTOCOL_VERSIONS +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: From b97838344709565def2513c1b33a4d7a8f3d35eb Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:57:50 +0000 Subject: [PATCH 7/9] docs: record the unknown-method error-code change in the migration guide The -32602 -> -32601 change for unknown request methods is wire-visible, so it gets a Bug Fixes entry. Also add the new protocol_version property to the ServerSession surface enumeration. --- docs/migration.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/migration.md b/docs/migration.md index 3e6a6d5e3b..ff2a93e2af 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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: @@ -1182,6 +1182,10 @@ 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. +### Unknown request methods now return `-32601` (Method not found) + +In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with `-32602` (`"Invalid request parameters"`, empty `data`). Unknown methods are 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, including before initialization completes. 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. From e1bf53f708fbd5bb5458084c430fff658de00d09 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:58:02 +0000 Subject: [PATCH 8/9] Clarify protocol-version docstrings and tighten new tests - _SERVER_REQUEST_METHODS documents the SDK union, not the spec union (the 2025-11-25 tasks methods are deliberately absent) - protocol_version docstrings no longer claim 'always None' in stateless mode: a client that sends initialize anyway still commits the version - the priming-event test asserts the store saw traffic, so the final all() cannot pass vacuously - add missing test docstrings --- src/mcp/client/session.py | 7 ++++--- src/mcp/server/connection.py | 5 +++-- src/mcp/server/session.py | 8 ++++---- tests/server/test_session.py | 1 + tests/shared/test_streamable_http.py | 4 +++- tests/shared/test_version.py | 1 + 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 7e379d702b..3a0485649f 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -98,9 +98,10 @@ async def _default_logging_callback( _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 spec `ServerRequest` union, derived from the -discriminator literal on each arm. Requests for any other method are answered -with METHOD_NOT_FOUND instead of failing union validation.""" +"""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( diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 0f2e2170c9..1469e8bc19 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -84,8 +84,9 @@ class Connection: protocol_version: str | None """The protocol version negotiated during `initialize`; `None` before - initialization, and always `None` on stateless connections (no handshake - reaches them). Handlers read this as `ServerSession.protocol_version`.""" + 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`); diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index a61aa9e516..c5af4cbb0a 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -54,10 +54,10 @@ def client_params(self) -> types.InitializeRequestParams | None: def protocol_version(self) -> str | None: """The protocol version negotiated during `initialize`. - `None` before initialization completes, and always `None` on stateless - connections (no handshake reaches them; on streamable HTTP the - per-request version is the `MCP-Protocol-Version` header, available - via `ctx.request.headers`). + `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 diff --git a/tests/server/test_session.py b/tests/server/test_session.py index df2dc218fc..6279eb4f54 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -185,6 +185,7 @@ def _init_params(protocol_version: str) -> dict[str, Any]: @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 diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 012e4423e8..7db7e68fb2 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1771,7 +1771,9 @@ async def test_initialize_with_unknown_protocol_version_gets_no_priming_event( ) assert response.status_code == 200 - # Priming events are stored with a None payload; none may exist for this client. + # 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) diff --git a/tests/shared/test_version.py b/tests/shared/test_version.py index b79fa576a3..baffa032fe 100644 --- a/tests/shared/test_version.py +++ b/tests/shared/test_version.py @@ -24,6 +24,7 @@ ], ) 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 From 5dd7e4a26aa5ab93876766ec2a90ad6e9c916b40 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:03:14 +0000 Subject: [PATCH 9/9] Answer any unserved method with METHOD_NOT_FOUND before the initialize gate A spec-defined method with no registered handler previously got the init gate's INVALID_PARAMS before initialization but METHOD_NOT_FOUND after. -32601 means "not available on this server", so the handler lookup now runs before the gate for every method: probing clients get the same answer in every initialization state, matching the TypeScript SDK. The gate still answers -32602 for methods the server does serve. --- docs/migration.md | 2 +- src/mcp/server/runner.py | 14 +++++++------- tests/server/test_runner.py | 14 +++++++++++++- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index ff2a93e2af..850e052550 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1184,7 +1184,7 @@ Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabi ### Unknown request methods now return `-32601` (Method not found) -In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with `-32602` (`"Invalid request parameters"`, empty `data`). Unknown methods are 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, including before initialization completes. Update anything that matched on the old code for this case. +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 diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 4c5409d5b7..9b10373229 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -243,17 +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) - # Unknown methods are METHOD_NOT_FOUND regardless of initialization - # state: JSON-RPC 2.0 reserves -32601 for them, and clients probing - # a server before the handshake key off that code. - if method not in _SPEC_CLIENT_METHODS and self.server.get_request_handler(method) is None: + # 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", data=method) # 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) diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 16a1006938..74724136f6 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -173,13 +173,25 @@ async def test_runner_unknown_method_before_initialize_raises_method_not_found(s """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 knows.""" + 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