Skip to content

Commit 7267818

Browse files
authored
Fix unknown-method error code and add a protocol version registry (#2836)
1 parent 1e21814 commit 7267818

19 files changed

Lines changed: 430 additions & 23 deletions

docs/migration.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,7 +1135,7 @@ from mcp.server import ServerRequestContext
11351135

11361136
### `ServerSession` is now a thin proxy (no longer a `BaseSession`)
11371137

1138-
`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`.
1138+
`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`.
11391139

11401140
`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:
11411141

@@ -1182,28 +1182,36 @@ Tasks are expected to return as a separate MCP extension in a future release.
11821182

11831183
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.
11841184

1185-
### Extra fields no longer allowed on top-level MCP types
1185+
### Unknown request methods now return `-32601` (Method not found)
11861186

1187-
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.
1187+
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.
1188+
1189+
### Extra fields on MCP types are no longer preserved
1190+
1191+
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.
1192+
1193+
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:
11881194

11891195
```python
1190-
# This will now raise a validation error
11911196
from mcp.types import CallToolRequestParams
11921197

11931198
params = CallToolRequestParams(
11941199
name="my_tool",
11951200
arguments={},
1196-
unknown_field="value", # ValidationError: extra fields not permitted
1201+
unknown_field="value", # silently ignored, not stored
11971202
)
1203+
"unknown_field" in params.model_dump() # False
11981204

1199-
# Extra fields are still allowed in _meta
1205+
# _meta remains the supported place for custom data, per the MCP spec
12001206
params = CallToolRequestParams(
12011207
name="my_tool",
12021208
arguments={},
1203-
_meta={"my_custom_key": "value", "another": 123}, # OK
1209+
_meta={"my_custom_key": "value", "another": 123}, # OK, preserved
12041210
)
12051211
```
12061212

1213+
If you relied on extra fields round-tripping through MCP types, move that data into `_meta`.
1214+
12071215
## New Features
12081216

12091217
### `streamable_http_app()` available on lowlevel Server

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/client/session.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from __future__ import annotations
22

33
import logging
4-
from typing import Any, Protocol
4+
from typing import Any, Protocol, cast, get_args
55

66
import anyio.lowlevel
7-
from pydantic import TypeAdapter
7+
from pydantic import BaseModel, TypeAdapter
88

99
from mcp import types
1010
from mcp.client._transport import ReadStream, WriteStream
@@ -95,6 +95,14 @@ async def _default_logging_callback(
9595

9696
ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData)
9797

98+
_SERVER_REQUEST_METHODS: frozenset[str] = frozenset(
99+
cast(type[BaseModel], arm).model_fields["method"].default for arm in get_args(types.ServerRequest)
100+
)
101+
"""Method names in the SDK's `ServerRequest` union, derived from the
102+
discriminator literal on each arm. Requests for any other method — including
103+
spec methods this SDK deliberately doesn't model, like `tasks/*` — are
104+
answered with METHOD_NOT_FOUND instead of failing union validation."""
105+
98106

99107
class ClientSession(
100108
BaseSession[
@@ -134,6 +142,10 @@ def __init__(
134142
def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]:
135143
return types.server_request_adapter
136144

145+
@property
146+
def _receive_request_methods(self) -> frozenset[str]:
147+
return _SERVER_REQUEST_METHODS
148+
137149
@property
138150
def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]:
139151
return types.server_notification_adapter

src/mcp/server/connection.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ class Connection:
8383
"""The full `initialize` request params; `None` before initialization."""
8484

8585
protocol_version: str | None
86+
"""The protocol version negotiated during `initialize`; `None` before
87+
initialization. Stateless connections don't require the handshake, so this
88+
normally stays `None` there (a client that sends `initialize` anyway still
89+
commits it). Handlers read this as `ServerSession.protocol_version`."""
8690

8791
initialized: anyio.Event
8892
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);

src/mcp/server/runner.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,17 @@ async def _inner() -> HandlerResult:
243243
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
244244
if method == "initialize":
245245
return self._handle_initialize(params)
246+
# Methods without a handler are METHOD_NOT_FOUND regardless of
247+
# initialization state: JSON-RPC 2.0 reserves -32601 for "not
248+
# available on this server", and clients probing a server before
249+
# the handshake key off that code. The init gate below therefore
250+
# only ever applies to methods the server actually serves.
251+
entry = self.server.get_request_handler(method)
252+
if entry is None:
253+
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method)
246254
if not self.connection.initialize_accepted and method not in _INIT_EXEMPT:
247255
# Pinned compat: the same error shape the union validation produced.
248256
raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="")
249-
entry = self.server.get_request_handler(method)
250-
if entry is None:
251-
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found")
252257
# Absent params validate as {} (required fields still reject), so
253258
# the handler receives the model with its defaults, never None.
254259
typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False)

src/mcp/server/session.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ def client_params(self) -> types.InitializeRequestParams | None:
5050
"""The client's `initialize` request params; `None` before initialization."""
5151
return self._connection.client_params
5252

53+
@property
54+
def protocol_version(self) -> str | None:
55+
"""The protocol version negotiated during `initialize`.
56+
57+
`None` before initialization completes. Stateless connections don't
58+
require the handshake, so this is normally `None` there (on streamable
59+
HTTP the per-request version is the `MCP-Protocol-Version` header,
60+
available via `ctx.request.headers`).
61+
"""
62+
return self._connection.protocol_version
63+
5364
async def send_request(
5465
self,
5566
request: types.ServerRequest,

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/session.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from mcp.types import (
2121
CONNECTION_CLOSED,
2222
INVALID_PARAMS,
23+
METHOD_NOT_FOUND,
2324
REQUEST_TIMEOUT,
2425
CancelledNotification,
2526
ClientNotification,
@@ -286,6 +287,12 @@ def _receive_request_adapter(self) -> TypeAdapter[ReceiveRequestT]:
286287
"""Each subclass must provide its own request adapter."""
287288
raise NotImplementedError
288289

290+
@property
291+
def _receive_request_methods(self) -> frozenset[str]:
292+
"""Method names in the receive-request union; anything else is
293+
answered with METHOD_NOT_FOUND before validation is attempted."""
294+
raise NotImplementedError
295+
289296
@property
290297
def _receive_notification_adapter(self) -> TypeAdapter[ReceiveNotificationT]:
291298
raise NotImplementedError
@@ -297,6 +304,18 @@ async def _receive_loop(self) -> None:
297304
async def _handle_session_message(message: SessionMessage) -> None:
298305
sender_context: contextvars.Context | None = getattr(self._read_stream, "last_context", None)
299306
if isinstance(message.message, JSONRPCRequest):
307+
if message.message.method not in self._receive_request_methods:
308+
# Unknown methods are METHOD_NOT_FOUND (-32601) per
309+
# JSON-RPC 2.0, not validation failures (-32602).
310+
error_response = JSONRPCError(
311+
jsonrpc="2.0",
312+
id=message.message.id,
313+
error=ErrorData(
314+
code=METHOD_NOT_FOUND, message="Method not found", data=message.message.method
315+
),
316+
)
317+
await self._write_stream.send(SessionMessage(message=error_response))
318+
return
300319
try:
301320
validated_request = self._receive_request_adapter.validate_python(
302321
message.message.model_dump(by_alias=True, mode="json", exclude_none=True),

src/mcp/shared/version.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,37 @@
1+
"""Protocol-version registry and comparison 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+
)
20+
"""Every released protocol revision, oldest to newest."""
21+
322
SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION]
23+
"""Protocol revisions this SDK can negotiate."""
24+
25+
26+
def is_version_at_least(version: str, minimum: str) -> bool:
27+
"""Return True if `version` is a known revision at least as new as `minimum`.
28+
29+
Unknown `version` strings return False (treat unrecognized peers
30+
conservatively). `minimum` must be a member of KNOWN_PROTOCOL_VERSIONS;
31+
passing anything else is programmer error and raises ValueError.
32+
"""
33+
if minimum not in KNOWN_PROTOCOL_VERSIONS:
34+
raise ValueError(f"minimum must be a known protocol version, got {minimum!r}")
35+
if version not in KNOWN_PROTOCOL_VERSIONS:
36+
return False
37+
return KNOWN_PROTOCOL_VERSIONS.index(version) >= KNOWN_PROTOCOL_VERSIONS.index(minimum)

tests/client/test_auth.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,24 @@ 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+
# Unrecognized strings gate conservatively, even ones sorting after 2025-06-18.
829+
("zzz", False),
830+
("9999-99-99", False),
831+
],
832+
)
833+
def test_should_include_resource_param_by_protocol_version(
834+
oauth_provider: OAuthClientProvider, protocol_version: str, expected: bool
835+
) -> None:
836+
"""Resource param is included only for recognized versions >= 2025-06-18."""
837+
assert oauth_provider.context.should_include_resource_param(protocol_version) is expected
838+
839+
822840
@pytest.mark.anyio
823841
async def test_validate_resource_rejects_mismatched_resource(
824842
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage

0 commit comments

Comments
 (0)