Fix unknown-method error code and add a protocol version registry (#2836)
This commit is contained in:
parent
1e21814ed5
commit
7267818e44
19 changed files with 430 additions and 23 deletions
|
|
@ -1135,7 +1135,7 @@ from mcp.server import ServerRequestContext
|
||||||
|
|
||||||
### `ServerSession` is now a thin proxy (no longer a `BaseSession`)
|
### `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:
|
`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.
|
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
|
```python
|
||||||
# This will now raise a validation error
|
|
||||||
from mcp.types import CallToolRequestParams
|
from mcp.types import CallToolRequestParams
|
||||||
|
|
||||||
params = CallToolRequestParams(
|
params = CallToolRequestParams(
|
||||||
name="my_tool",
|
name="my_tool",
|
||||||
arguments={},
|
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(
|
params = CallToolRequestParams(
|
||||||
name="my_tool",
|
name="my_tool",
|
||||||
arguments={},
|
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
|
## New Features
|
||||||
|
|
||||||
### `streamable_http_app()` available on lowlevel Server
|
### `streamable_http_app()` available on lowlevel Server
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ from mcp.shared.auth_utils import (
|
||||||
check_resource_allowed,
|
check_resource_allowed,
|
||||||
resource_url_from_server_url,
|
resource_url_from_server_url,
|
||||||
)
|
)
|
||||||
|
from mcp.shared.version import is_version_at_least
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -172,9 +173,7 @@ class OAuthContext:
|
||||||
if not protocol_version:
|
if not protocol_version:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if protocol version is 2025-06-18 or later
|
return is_version_at_least(protocol_version, "2025-06-18")
|
||||||
# Version format is YYYY-MM-DD, so string comparison works
|
|
||||||
return protocol_version >= "2025-06-18"
|
|
||||||
|
|
||||||
def prepare_token_auth(
|
def prepare_token_auth(
|
||||||
self, data: dict[str, str], headers: dict[str, str] | None = None
|
self, data: dict[str, str], headers: dict[str, str] | None = None
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol, cast, get_args
|
||||||
|
|
||||||
import anyio.lowlevel
|
import anyio.lowlevel
|
||||||
from pydantic import TypeAdapter
|
from pydantic import BaseModel, TypeAdapter
|
||||||
|
|
||||||
from mcp import types
|
from mcp import types
|
||||||
from mcp.client._transport import ReadStream, WriteStream
|
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)
|
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(
|
class ClientSession(
|
||||||
BaseSession[
|
BaseSession[
|
||||||
|
|
@ -134,6 +142,10 @@ class ClientSession(
|
||||||
def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]:
|
def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]:
|
||||||
return types.server_request_adapter
|
return types.server_request_adapter
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _receive_request_methods(self) -> frozenset[str]:
|
||||||
|
return _SERVER_REQUEST_METHODS
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]:
|
def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]:
|
||||||
return types.server_notification_adapter
|
return types.server_notification_adapter
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,10 @@ class Connection:
|
||||||
"""The full `initialize` request params; `None` before initialization."""
|
"""The full `initialize` request params; `None` before initialization."""
|
||||||
|
|
||||||
protocol_version: str | None
|
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
|
initialized: anyio.Event
|
||||||
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);
|
"""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.
|
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
|
||||||
if method == "initialize":
|
if method == "initialize":
|
||||||
return self._handle_initialize(params)
|
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:
|
if not self.connection.initialize_accepted and method not in _INIT_EXEMPT:
|
||||||
# Pinned compat: the same error shape the union validation produced.
|
# Pinned compat: the same error shape the union validation produced.
|
||||||
raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
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
|
# Absent params validate as {} (required fields still reject), so
|
||||||
# the handler receives the model with its defaults, never None.
|
# 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)
|
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."""
|
"""The client's `initialize` request params; `None` before initialization."""
|
||||||
return self._connection.client_params
|
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(
|
async def send_request(
|
||||||
self,
|
self,
|
||||||
request: types.ServerRequest,
|
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._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
|
||||||
from mcp.shared._stream_protocols import ReadStream, WriteStream
|
from mcp.shared._stream_protocols import ReadStream, WriteStream
|
||||||
from mcp.shared.message import ServerMessageMetadata, SessionMessage
|
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 (
|
from mcp.types import (
|
||||||
DEFAULT_NEGOTIATED_VERSION,
|
DEFAULT_NEGOTIATED_VERSION,
|
||||||
INTERNAL_ERROR,
|
INTERNAL_ERROR,
|
||||||
|
|
@ -238,7 +238,7 @@ class StreamableHTTPServerTransport:
|
||||||
the stream is closed early because they didn't receive a priming event.
|
the stream is closed early because they didn't receive a priming event.
|
||||||
"""
|
"""
|
||||||
# Only provide close callbacks when client supports resumability
|
# 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:
|
async def close_stream_callback() -> None:
|
||||||
self.close_sse_stream(request_id)
|
self.close_sse_stream(request_id)
|
||||||
|
|
@ -271,7 +271,7 @@ class StreamableHTTPServerTransport:
|
||||||
if not self._event_store:
|
if not self._event_store:
|
||||||
return
|
return
|
||||||
# Priming events have empty data which older clients cannot handle.
|
# 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
|
return
|
||||||
priming_event_id = await self._event_store.store_event(
|
priming_event_id = await self._event_store.store_event(
|
||||||
str(request_id), # Convert RequestId to StreamId (str)
|
str(request_id), # Convert RequestId to StreamId (str)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMe
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
CONNECTION_CLOSED,
|
CONNECTION_CLOSED,
|
||||||
INVALID_PARAMS,
|
INVALID_PARAMS,
|
||||||
|
METHOD_NOT_FOUND,
|
||||||
REQUEST_TIMEOUT,
|
REQUEST_TIMEOUT,
|
||||||
CancelledNotification,
|
CancelledNotification,
|
||||||
ClientNotification,
|
ClientNotification,
|
||||||
|
|
@ -286,6 +287,12 @@ class BaseSession(
|
||||||
"""Each subclass must provide its own request adapter."""
|
"""Each subclass must provide its own request adapter."""
|
||||||
raise NotImplementedError
|
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
|
@property
|
||||||
def _receive_notification_adapter(self) -> TypeAdapter[ReceiveNotificationT]:
|
def _receive_notification_adapter(self) -> TypeAdapter[ReceiveNotificationT]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
@ -297,6 +304,18 @@ class BaseSession(
|
||||||
async def _handle_session_message(message: SessionMessage) -> None:
|
async def _handle_session_message(message: SessionMessage) -> None:
|
||||||
sender_context: contextvars.Context | None = getattr(self._read_stream, "last_context", None)
|
sender_context: contextvars.Context | None = getattr(self._read_stream, "last_context", None)
|
||||||
if isinstance(message.message, JSONRPCRequest):
|
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:
|
try:
|
||||||
validated_request = self._receive_request_adapter.validate_python(
|
validated_request = self._receive_request_adapter.validate_python(
|
||||||
message.message.model_dump(by_alias=True, mode="json", exclude_none=True),
|
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
|
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]
|
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)
|
||||||
|
|
|
||||||
|
|
@ -819,6 +819,24 @@ class TestProtectedResourceMetadata:
|
||||||
assert "resource=" in content
|
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
|
@pytest.mark.anyio
|
||||||
async def test_validate_resource_rejects_mismatched_resource(
|
async def test_validate_resource_rejects_mismatched_resource(
|
||||||
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
|
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,12 @@ async def test_client_is_initialized(app: MCPServer):
|
||||||
assert client.initialize_result.server_info.name == "test"
|
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):
|
async def test_client_with_simple_server(simple_server: Server):
|
||||||
"""Test that from_server works with a basic Server instance."""
|
"""Test that from_server works with a basic Server instance."""
|
||||||
async with Client(simple_server) as client:
|
async with Client(simple_server) as client:
|
||||||
|
|
|
||||||
|
|
@ -128,4 +128,6 @@ async def test_complete_without_handler_is_method_not_found(connect: Connect) ->
|
||||||
with pytest.raises(MCPError) as exc_info:
|
with pytest.raises(MCPError) as exc_info:
|
||||||
await client.complete(PromptReference(name="anything"), argument={"name": "topic", "value": ""})
|
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")
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,9 @@ async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect
|
||||||
with pytest.raises(MCPError) as exc_info:
|
with pytest.raises(MCPError) as exc_info:
|
||||||
await client.subscribe_resource("file:///watched.txt")
|
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")
|
@requirement("resources:unsubscribe")
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,9 @@ async def test_set_logging_level_is_rejected_and_messages_are_never_filtered(con
|
||||||
|
|
||||||
await client.call_tool("chatter", {})
|
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(
|
assert received == snapshot(
|
||||||
[
|
[
|
||||||
LoggingMessageNotificationParams(level="debug", data="noise"),
|
LoggingMessageNotificationParams(level="debug", data="noise"),
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,45 @@ async def test_runner_gates_requests_before_initialize(server: SrvT):
|
||||||
assert await client.send_raw_request("ping", None) == {}
|
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
|
@pytest.mark.anyio
|
||||||
async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
|
async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
|
||||||
async with connected_runner(server) as (client, runner):
|
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, _):
|
async with connected_runner(server) as (client, _):
|
||||||
with pytest.raises(MCPError) as exc:
|
with pytest.raises(MCPError) as exc:
|
||||||
await client.send_raw_request("resources/list", None)
|
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
|
@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, _):
|
async with connected_runner(server) as (client, _):
|
||||||
with pytest.raises(MCPError) as exc:
|
with pytest.raises(MCPError) as exc:
|
||||||
await client.send_raw_request("nonexistent/method", None)
|
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
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from typing import Any, cast
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mcp import types
|
from mcp import types
|
||||||
|
from mcp.server import Server, ServerRequestContext
|
||||||
from mcp.server.connection import Connection
|
from mcp.server.connection import Connection
|
||||||
from mcp.server.session import ServerSession
|
from mcp.server.session import ServerSession
|
||||||
from mcp.shared.dispatcher import CallOptions
|
from mcp.shared.dispatcher import CallOptions
|
||||||
|
|
@ -26,6 +27,8 @@ from mcp.types import (
|
||||||
SamplingToolsCapability,
|
SamplingToolsCapability,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .test_runner import connected_runner
|
||||||
|
|
||||||
|
|
||||||
class StubDispatcher:
|
class StubDispatcher:
|
||||||
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
|
"""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()))
|
session = _make_session(dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability()))
|
||||||
assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True
|
assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True
|
||||||
assert session.check_client_capability(ClientCapabilities(experimental={"x": {}})) is False
|
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
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from mcp.shared.memory import create_client_server_memory_streams
|
||||||
from mcp.shared.message import SessionMessage
|
from mcp.shared.message import SessionMessage
|
||||||
from mcp.shared.session import RequestResponder
|
from mcp.shared.session import RequestResponder
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
|
METHOD_NOT_FOUND,
|
||||||
PARSE_ERROR,
|
PARSE_ERROR,
|
||||||
CancelledNotification,
|
CancelledNotification,
|
||||||
CancelledNotificationParams,
|
CancelledNotificationParams,
|
||||||
|
|
@ -16,6 +17,7 @@ from mcp.types import (
|
||||||
EmptyResult,
|
EmptyResult,
|
||||||
ErrorData,
|
ErrorData,
|
||||||
JSONRPCError,
|
JSONRPCError,
|
||||||
|
JSONRPCNotification,
|
||||||
JSONRPCRequest,
|
JSONRPCRequest,
|
||||||
JSONRPCResponse,
|
JSONRPCResponse,
|
||||||
ServerNotification,
|
ServerNotification,
|
||||||
|
|
@ -403,3 +405,43 @@ async def test_null_id_error_does_not_affect_pending_request():
|
||||||
# Pending request completed successfully
|
# Pending request completed successfully
|
||||||
assert len(result_holder) == 1
|
assert len(result_holder) == 1
|
||||||
assert isinstance(result_holder[0], EmptyResult)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
@pytest.mark.anyio
|
||||||
async def test_streamable_http_client_receives_priming_event(
|
async def test_streamable_http_client_receives_priming_event(
|
||||||
event_app: tuple[SimpleEventStore, Starlette],
|
event_app: tuple[SimpleEventStore, Starlette],
|
||||||
|
|
|
||||||
59
tests/shared/test_version.py
Normal file
59
tests/shared/test_version.py
Normal 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))
|
||||||
Loading…
Reference in a new issue