Skip to content

Commit 89722dd

Browse files
committed
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).
1 parent 1e21814 commit 89722dd

6 files changed

Lines changed: 240 additions & 6 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
check_resource_allowed,
5050
resource_url_from_server_url,
5151
)
52+
from mcp.shared.version import is_version_at_least
5253

5354
logger = logging.getLogger(__name__)
5455

@@ -172,9 +173,7 @@ def should_include_resource_param(self, protocol_version: str | None = None) ->
172173
if not protocol_version:
173174
return False
174175

175-
# Check if protocol version is 2025-06-18 or later
176-
# Version format is YYYY-MM-DD, so string comparison works
177-
return protocol_version >= "2025-06-18"
176+
return is_version_at_least(protocol_version, "2025-06-18")
178177

179178
def prepare_token_auth(
180179
self, data: dict[str, str], headers: dict[str, str] | None = None

src/mcp/server/streamable_http.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
2929
from mcp.shared._stream_protocols import ReadStream, WriteStream
3030
from mcp.shared.message import ServerMessageMetadata, SessionMessage
31-
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
31+
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least
3232
from mcp.types import (
3333
DEFAULT_NEGOTIATED_VERSION,
3434
INTERNAL_ERROR,
@@ -238,7 +238,7 @@ def _create_session_message(
238238
the stream is closed early because they didn't receive a priming event.
239239
"""
240240
# Only provide close callbacks when client supports resumability
241-
if self._event_store and protocol_version >= "2025-11-25":
241+
if self._event_store and is_version_at_least(protocol_version, "2025-11-25"):
242242

243243
async def close_stream_callback() -> None:
244244
self.close_sse_stream(request_id)
@@ -271,7 +271,7 @@ async def _maybe_send_priming_event(
271271
if not self._event_store:
272272
return
273273
# Priming events have empty data which older clients cannot handle.
274-
if protocol_version < "2025-11-25":
274+
if not is_version_at_least(protocol_version, "2025-11-25"):
275275
return
276276
priming_event_id = await self._event_store.store_event(
277277
str(request_id), # Convert RequestId to StreamId (str)

src/mcp/shared/version.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,59 @@
1+
"""Protocol-version registry and comparison/classification helpers.
2+
3+
Date-string protocol revisions happen to sort lexicographically, but versions
4+
are an enumerated set, not an ordered scalar: future identifiers are not
5+
guaranteed to be date-shaped, and unrecognized peer strings must compare
6+
conservatively instead of accidentally (e.g. "zzz" > "2025-11-25"). All
7+
ordering questions go through KNOWN_PROTOCOL_VERSIONS.
8+
"""
9+
10+
from typing import Final
11+
112
from mcp.types import LATEST_PROTOCOL_VERSION
213

14+
KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = (
15+
"2024-11-05",
16+
"2025-03-26",
17+
"2025-06-18",
18+
"2025-11-25",
19+
"2026-07-28", # draft: recognized for ordering/classification, NOT negotiable
20+
)
21+
"""Every protocol revision this SDK knows about, oldest to newest."""
22+
23+
DRAFT_PROTOCOL_VERSION: Final[str] = "2026-07-28"
24+
"""The in-progress spec revision.
25+
26+
Recognized by the helpers in this module but absent from
27+
SUPPORTED_PROTOCOL_VERSIONS until the SDK actually implements it.
28+
"""
29+
330
SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION]
31+
"""Protocol revisions this SDK can negotiate."""
32+
33+
STATEFUL_PROTOCOL_VERSIONS: Final[frozenset[str]] = frozenset({"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"})
34+
"""Revisions that negotiate via the initialize handshake.
35+
36+
Closed by design: every revision after 2025-11-25 is stateless and negotiates
37+
per-request, never via initialize. Hardcoded - do not derive from
38+
SUPPORTED_PROTOCOL_VERSIONS. (Matches typescript-sdk's
39+
STATEFUL_PROTOCOL_VERSIONS / isStatefulProtocolVersion.)
40+
"""
41+
42+
43+
def is_stateful_protocol_version(version: str) -> bool:
44+
"""Return True if `version` negotiates via the initialize handshake."""
45+
return version in STATEFUL_PROTOCOL_VERSIONS
46+
47+
48+
def is_version_at_least(version: str, minimum: str) -> bool:
49+
"""Return True if `version` is a known revision at least as new as `minimum`.
50+
51+
Unknown `version` strings return False (treat unrecognized peers
52+
conservatively). `minimum` must be a member of KNOWN_PROTOCOL_VERSIONS;
53+
passing anything else is programmer error and raises ValueError.
54+
"""
55+
if minimum not in KNOWN_PROTOCOL_VERSIONS:
56+
raise ValueError(f"minimum must be a known protocol version, got {minimum!r}")
57+
if version not in KNOWN_PROTOCOL_VERSIONS:
58+
return False
59+
return KNOWN_PROTOCOL_VERSIONS.index(version) >= KNOWN_PROTOCOL_VERSIONS.index(minimum)

tests/client/test_auth.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,25 @@ async def test_resource_param_included_with_protected_resource_metadata(self, oa
819819
assert "resource=" in content
820820

821821

822+
@pytest.mark.parametrize(
823+
("protocol_version", "expected"),
824+
[
825+
("2025-03-26", False),
826+
("2025-06-18", True),
827+
("2025-11-25", True),
828+
("2026-07-28", True),
829+
# Unrecognized strings gate conservatively, even ones sorting after 2025-06-18.
830+
("zzz", False),
831+
("9999-99-99", False),
832+
],
833+
)
834+
def test_should_include_resource_param_by_protocol_version(
835+
oauth_provider: OAuthClientProvider, protocol_version: str, expected: bool
836+
) -> None:
837+
"""Resource param is included only for recognized versions >= 2025-06-18."""
838+
assert oauth_provider.context.should_include_resource_param(protocol_version) is expected
839+
840+
822841
@pytest.mark.anyio
823842
async def test_validate_resource_rejects_mismatched_resource(
824843
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage

tests/shared/test_streamable_http.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1696,6 +1696,85 @@ async def test_close_sse_stream_callback_not_provided_for_old_protocol_version()
16961696
assert session_msg_new.metadata.close_standalone_sse_stream is not None
16971697

16981698

1699+
@pytest.mark.anyio
1700+
async def test_priming_event_not_sent_for_unknown_protocol_version() -> None:
1701+
"""_maybe_send_priming_event treats unrecognized version strings conservatively.
1702+
1703+
A garbage version must not be mistaken for a future one (lexicographically
1704+
"zzz" sorts after every date-shaped revision).
1705+
"""
1706+
transport = StreamableHTTPServerTransport(
1707+
"/mcp",
1708+
event_store=SimpleEventStore(),
1709+
)
1710+
1711+
write_stream, read_stream = anyio.create_memory_object_stream[dict[str, Any]](1)
1712+
1713+
try:
1714+
await transport._maybe_send_priming_event("test-request-id", write_stream, "zzz")
1715+
1716+
# Nothing should have been written to the stream
1717+
assert write_stream.statistics().current_buffer_used == 0
1718+
finally:
1719+
await write_stream.aclose()
1720+
await read_stream.aclose()
1721+
1722+
1723+
@pytest.mark.anyio
1724+
async def test_close_sse_stream_callback_not_provided_for_unknown_protocol_version() -> None:
1725+
"""close_sse_stream callbacks are withheld when the client's version is unrecognized."""
1726+
transport = StreamableHTTPServerTransport(
1727+
"/mcp",
1728+
event_store=SimpleEventStore(),
1729+
)
1730+
1731+
mock_message = JSONRPCRequest(jsonrpc="2.0", id="test-1", method="tools/list")
1732+
mock_request = MagicMock()
1733+
1734+
session_msg = transport._create_session_message(mock_message, mock_request, "test-request-id", "zzz")
1735+
1736+
assert session_msg.metadata is not None
1737+
assert isinstance(session_msg.metadata, ServerMessageMetadata)
1738+
assert session_msg.metadata.close_sse_stream is None
1739+
assert session_msg.metadata.close_standalone_sse_stream is None
1740+
1741+
1742+
@pytest.mark.anyio
1743+
async def test_initialize_with_unknown_protocol_version_gets_no_priming_event(
1744+
event_app: tuple[SimpleEventStore, Starlette],
1745+
) -> None:
1746+
"""A garbage protocolVersion in initialize params must not trigger priming.
1747+
1748+
The priming decision reads the raw body params before any validation, so an
1749+
unrecognized string must gate conservatively (old-client behavior), not
1750+
compare lexicographically past "2025-11-25".
1751+
"""
1752+
event_store, app = event_app
1753+
init_request = {
1754+
"jsonrpc": "2.0",
1755+
"method": "initialize",
1756+
"params": {
1757+
"clientInfo": {"name": "test-client", "version": "1.0"},
1758+
"protocolVersion": "zzz",
1759+
"capabilities": {},
1760+
},
1761+
"id": "init-1",
1762+
}
1763+
async with make_client(app) as client:
1764+
response = await client.post(
1765+
"/mcp",
1766+
headers={
1767+
"Accept": "application/json, text/event-stream",
1768+
"Content-Type": "application/json",
1769+
},
1770+
json=init_request,
1771+
)
1772+
assert response.status_code == 200
1773+
1774+
# Priming events are stored with a None payload; none may exist for this client.
1775+
assert all(message is not None for _, _, message in event_store._events)
1776+
1777+
16991778
@pytest.mark.anyio
17001779
async def test_streamable_http_client_receives_priming_event(
17011780
event_app: tuple[SimpleEventStore, Starlette],

tests/shared/test_version.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Tests for the protocol-version registry and comparison helpers."""
2+
3+
import pytest
4+
5+
from mcp.shared.version import (
6+
DRAFT_PROTOCOL_VERSION,
7+
KNOWN_PROTOCOL_VERSIONS,
8+
STATEFUL_PROTOCOL_VERSIONS,
9+
SUPPORTED_PROTOCOL_VERSIONS,
10+
is_stateful_protocol_version,
11+
is_version_at_least,
12+
)
13+
from mcp.types import LATEST_PROTOCOL_VERSION
14+
15+
16+
@pytest.mark.parametrize(
17+
("version", "minimum", "expected"),
18+
[
19+
# equal
20+
("2025-11-25", "2025-11-25", True),
21+
("2024-11-05", "2024-11-05", True),
22+
# above
23+
("2025-11-25", "2025-06-18", True),
24+
("2026-07-28", "2024-11-05", True),
25+
# below
26+
("2025-06-18", "2025-11-25", False),
27+
("2024-11-05", "2026-07-28", False),
28+
],
29+
)
30+
def test_is_version_at_least_ordering(version: str, minimum: str, expected: bool) -> None:
31+
assert is_version_at_least(version, minimum) is expected
32+
33+
34+
@pytest.mark.parametrize("version", ["zzz", "", "2025-11-26", "draft", "9999-99-99"])
35+
def test_is_version_at_least_unknown_version_is_false(version: str) -> None:
36+
"""Unrecognized peer strings compare conservatively, never accidentally."""
37+
assert is_version_at_least(version, "2024-11-05") is False
38+
39+
40+
def test_is_version_at_least_unknown_minimum_raises() -> None:
41+
"""An unknown minimum is programmer error, not peer input."""
42+
with pytest.raises(ValueError, match="zzz"):
43+
is_version_at_least("2025-11-25", "zzz")
44+
45+
46+
@pytest.mark.parametrize(
47+
("version", "minimum"), [(v, m) for v in KNOWN_PROTOCOL_VERSIONS for m in KNOWN_PROTOCOL_VERSIONS]
48+
)
49+
def test_is_version_at_least_matches_lexicographic_for_known_versions(version: str, minimum: str) -> None:
50+
"""Drop-in equivalence: for every known (date-shaped) revision pair the helper
51+
agrees with the string comparison it replaced."""
52+
assert is_version_at_least(version, minimum) is (version >= minimum)
53+
54+
55+
def test_draft_version_is_known_but_not_negotiable_and_not_stateful() -> None:
56+
assert DRAFT_PROTOCOL_VERSION in KNOWN_PROTOCOL_VERSIONS
57+
assert DRAFT_PROTOCOL_VERSION not in SUPPORTED_PROTOCOL_VERSIONS
58+
assert not is_stateful_protocol_version(DRAFT_PROTOCOL_VERSION)
59+
60+
61+
def test_draft_version_is_at_least_every_released_version() -> None:
62+
for released in SUPPORTED_PROTOCOL_VERSIONS:
63+
assert is_version_at_least(DRAFT_PROTOCOL_VERSION, released)
64+
65+
66+
def test_every_supported_version_is_stateful() -> None:
67+
for version in SUPPORTED_PROTOCOL_VERSIONS:
68+
assert is_stateful_protocol_version(version)
69+
70+
71+
def test_supported_versions_are_a_strict_subset_of_known() -> None:
72+
assert set(SUPPORTED_PROTOCOL_VERSIONS) < set(KNOWN_PROTOCOL_VERSIONS)
73+
74+
75+
def test_latest_version_is_stateful() -> None:
76+
assert LATEST_PROTOCOL_VERSION in STATEFUL_PROTOCOL_VERSIONS
77+
78+
79+
def test_known_versions_are_strictly_ordered() -> None:
80+
"""The registry tuple is the ordering source of truth: ascending, no duplicates."""
81+
assert list(KNOWN_PROTOCOL_VERSIONS) == sorted(set(KNOWN_PROTOCOL_VERSIONS))

0 commit comments

Comments
 (0)