Fix unknown-method error code and add a protocol version registry (#2836)
This commit is contained in:
parent
1e21814ed5
commit
7267818e44
8 changed files with 430 additions and 23 deletions
|
|
@ -49,6 +49,7 @@ from mcp.shared.auth_utils import (
|
|||
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 @@ class OAuthContext:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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 @@ class ClientSession(
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -243,12 +243,17 @@ class ServerRunner(Generic[LifespanT]):
|
|||
# (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)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,17 @@ class ServerSession:
|
|||
"""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,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from mcp.server.transport_security import TransportSecurityMiddleware, Transport
|
|||
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 @@ class StreamableHTTPServerTransport:
|
|||
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 @@ class StreamableHTTPServerTransport:
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMe
|
|||
from mcp.types import (
|
||||
CONNECTION_CLOSED,
|
||||
INVALID_PARAMS,
|
||||
METHOD_NOT_FOUND,
|
||||
REQUEST_TIMEOUT,
|
||||
CancelledNotification,
|
||||
ClientNotification,
|
||||
|
|
@ -286,6 +287,12 @@ class BaseSession(
|
|||
"""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 @@ class BaseSession(
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue