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
3 changed files with 430 additions and 23 deletions

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.session import RequestResponder
from mcp.types import (
METHOD_NOT_FOUND,
PARSE_ERROR,
CancelledNotification,
CancelledNotificationParams,
@ -16,6 +17,7 @@ from mcp.types import (
EmptyResult,
ErrorData,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ServerNotification,
@ -403,3 +405,43 @@ async def test_null_id_error_does_not_affect_pending_request():
# 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

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
@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],

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))