diff --git a/docs/migration.md b/docs/migration.md index ddd9cd3a6..850e05255 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,28 +1182,36 @@ Tasks are expected to return as a separate MCP extension in a future release. Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior. -### Extra fields no longer allowed on top-level MCP types +### Unknown request methods now return `-32601` (Method not found) -MCP protocol types no longer accept arbitrary extra fields at the top level. This matches the MCP specification which only allows extra fields within `_meta` objects, not on the types themselves. +In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with `-32602` (`"Invalid request parameters"`, empty `data`). Any method the receiver doesn't serve — unrecognized, or a spec method with no registered handler — is now answered with the JSON-RPC-specified `-32601` (`"Method not found"`), with the method name in `data`, on both the server and the client side, in every initialization state. Update anything that matched on the old code for this case. + +### Extra fields on MCP types are no longer preserved + +In v1, MCP protocol types were configured with `extra="allow"`: unknown fields passed to a constructor or received from a peer were kept on the model and re-serialized on output. + +In v2, MCP types silently ignore extra fields. Unknown constructor keyword arguments and unknown keys in wire data are dropped during validation — no error is raised, and the values do not round-trip: ```python -# This will now raise a validation error from mcp.types import CallToolRequestParams params = CallToolRequestParams( name="my_tool", arguments={}, - unknown_field="value", # ValidationError: extra fields not permitted + unknown_field="value", # silently ignored, not stored ) +"unknown_field" in params.model_dump() # False -# Extra fields are still allowed in _meta +# _meta remains the supported place for custom data, per the MCP spec params = CallToolRequestParams( name="my_tool", arguments={}, - _meta={"my_custom_key": "value", "another": 123}, # OK + _meta={"my_custom_key": "value", "another": 123}, # OK, preserved ) ``` +If you relied on extra fields round-tripping through MCP types, move that data into `_meta`. + ## New Features ### `streamable_http_app()` available on lowlevel Server diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 3c546fda2..01bcc8234 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/client/session.py b/src/mcp/client/session.py index 08f532eca..3a0485649 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,14 @@ async def _default_logging_callback( ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) +_SERVER_REQUEST_METHODS: frozenset[str] = frozenset( + cast(type[BaseModel], arm).model_fields["method"].default for arm in get_args(types.ServerRequest) +) +"""Method names in the SDK's `ServerRequest` union, derived from the +discriminator literal on each arm. Requests for any other method — including +spec methods this SDK deliberately doesn't model, like `tasks/*` — are +answered with METHOD_NOT_FOUND instead of failing union validation.""" + class ClientSession( BaseSession[ @@ -134,6 +142,10 @@ def __init__( def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]: return types.server_request_adapter + @property + def _receive_request_methods(self) -> frozenset[str]: + return _SERVER_REQUEST_METHODS + @property def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]: return types.server_notification_adapter diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 686c709c9..1469e8bc1 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -83,6 +83,10 @@ class Connection: """The full `initialize` request params; `None` before initialization.""" protocol_version: str | None + """The protocol version negotiated during `initialize`; `None` before + initialization. Stateless connections don't require the handshake, so this + normally stays `None` there (a client that sends `initialize` anyway still + commits it). Handlers read this as `ServerSession.protocol_version`.""" initialized: anyio.Event """Set when `notifications/initialized` arrives (matches TS `oninitialized`); diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 4f8d23b8d..9b1037322 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) + # Methods without a handler are METHOD_NOT_FOUND regardless of + # initialization state: JSON-RPC 2.0 reserves -32601 for "not + # available on this server", and clients probing a server before + # the handshake key off that code. The init gate below therefore + # only ever applies to methods the server actually serves. + entry = self.server.get_request_handler(method) + if entry is None: + raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) if not self.connection.initialize_accepted and method not in _INIT_EXEMPT: # Pinned compat: the same error shape the union validation produced. raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="") - entry = self.server.get_request_handler(method) - if entry is None: - raise MCPError(code=METHOD_NOT_FOUND, message="Method not found") # Absent params validate as {} (required fields still reject), so # the handler receives the model with its defaults, never None. typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False) diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 14642152a..c5af4cbb0 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. Stateless connections don't + require the handshake, so this is normally `None` there (on streamable + HTTP the per-request version is the `MCP-Protocol-Version` header, + available via `ctx.request.headers`). + """ + return self._connection.protocol_version + async def send_request( self, request: types.ServerRequest, diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 2d1359e03..220d46f9a 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/session.py b/src/mcp/shared/session.py index 50597e10e..61279ad8b 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/src/mcp/shared/version.py b/src/mcp/shared/version.py index d2a1e462d..44154da36 100644 --- a/src/mcp/shared/version.py +++ b/src/mcp/shared/version.py @@ -1,3 +1,37 @@ +"""Protocol-version registry and comparison helpers. + +Date-string protocol revisions happen to sort lexicographically, but versions +are an enumerated set, not an ordered scalar: future identifiers are not +guaranteed to be date-shaped, and unrecognized peer strings must compare +conservatively instead of accidentally (e.g. "zzz" > "2025-11-25"). All +ordering questions go through KNOWN_PROTOCOL_VERSIONS. +""" + +from typing import Final + from mcp.types import LATEST_PROTOCOL_VERSION +KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ( + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25", +) +"""Every released protocol revision, oldest to newest.""" + SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION] +"""Protocol revisions this SDK can negotiate.""" + + +def is_version_at_least(version: str, minimum: str) -> bool: + """Return True if `version` is a known revision at least as new as `minimum`. + + Unknown `version` strings return False (treat unrecognized peers + conservatively). `minimum` must be a member of KNOWN_PROTOCOL_VERSIONS; + passing anything else is programmer error and raises ValueError. + """ + if minimum not in KNOWN_PROTOCOL_VERSIONS: + raise ValueError(f"minimum must be a known protocol version, got {minimum!r}") + if version not in KNOWN_PROTOCOL_VERSIONS: + return False + return KNOWN_PROTOCOL_VERSIONS.index(version) >= KNOWN_PROTOCOL_VERSIONS.index(minimum) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index bb0bce4c9..ca7a495e6 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -819,6 +819,24 @@ async def test_resource_param_included_with_protected_resource_metadata(self, oa assert "resource=" in content +@pytest.mark.parametrize( + ("protocol_version", "expected"), + [ + ("2025-03-26", False), + ("2025-06-18", True), + ("2025-11-25", True), + # Unrecognized strings gate conservatively, even ones sorting after 2025-06-18. + ("zzz", False), + ("9999-99-99", False), + ], +) +def test_should_include_resource_param_by_protocol_version( + oauth_provider: OAuthClientProvider, protocol_version: str, expected: bool +) -> None: + """Resource param is included only for recognized versions >= 2025-06-18.""" + assert oauth_provider.context.should_include_resource_param(protocol_version) is expected + + @pytest.mark.anyio async def test_validate_resource_rejects_mismatched_resource( client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage diff --git a/tests/client/test_client.py b/tests/client/test_client.py index ac52a9024..d9c792409 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/interaction/lowlevel/test_completion.py b/tests/interaction/lowlevel/test_completion.py index 6a35404df..f12671d93 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 4e369d364..e42b69d7e 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 26556fea7..fdf898c99 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 77081fbeb..74724136f 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -168,6 +168,45 @@ 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 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 async def test_runner_routes_to_handler_and_builds_context(server: SrvT): 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, _): 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 +235,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 diff --git a/tests/server/test_session.py b/tests/server/test_session.py index a713a79b6..6279eb4f5 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,64 @@ 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(): + """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 diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py index 8a53b0819..38f36d82c 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 diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 35b297b39..7db7e68fb 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -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 +@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 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 000000000..baffa032f --- /dev/null +++ b/tests/shared/test_version.py @@ -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))