Skip to content

Commit 3230c3f

Browse files
committed
Return METHOD_NOT_FOUND for requests with unknown methods
A peer request whose method is not in the session's receive union previously failed union validation and was answered with -32602 (INVALID_PARAMS). JSON-RPC 2.0 reserves -32601 (METHOD_NOT_FOUND) for unknown methods, and peers probing for optional features key off that code. Check the method against the union's discriminator literals before validating and answer unknown ones with -32601, carrying the method name in the error data. Unknown notification methods are still dropped without a response.
1 parent 89722dd commit 3230c3f

3 files changed

Lines changed: 74 additions & 2 deletions

File tree

src/mcp/client/session.py

Lines changed: 13 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,13 @@ 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 spec `ServerRequest` union, derived from the
102+
discriminator literal on each arm. Requests for any other method are answered
103+
with METHOD_NOT_FOUND instead of failing union validation."""
104+
98105

99106
class ClientSession(
100107
BaseSession[
@@ -134,6 +141,10 @@ def __init__(
134141
def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]:
135142
return types.server_request_adapter
136143

144+
@property
145+
def _receive_request_methods(self) -> frozenset[str]:
146+
return _SERVER_REQUEST_METHODS
147+
137148
@property
138149
def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]:
139150
return types.server_notification_adapter

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

tests/shared/test_session.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
from mcp.shared.message import SessionMessage
1010
from mcp.shared.session import RequestResponder
1111
from mcp.types import (
12+
METHOD_NOT_FOUND,
1213
PARSE_ERROR,
1314
CancelledNotification,
1415
CancelledNotificationParams,
1516
ClientResult,
1617
EmptyResult,
1718
ErrorData,
1819
JSONRPCError,
20+
JSONRPCNotification,
1921
JSONRPCRequest,
2022
JSONRPCResponse,
2123
ServerNotification,
@@ -403,3 +405,43 @@ async def make_request(client_session: ClientSession):
403405
# Pending request completed successfully
404406
assert len(result_holder) == 1
405407
assert isinstance(result_holder[0], EmptyResult)
408+
409+
410+
@pytest.mark.anyio
411+
async def test_receive_loop_answers_unknown_request_method_with_method_not_found():
412+
"""A peer request whose method is not in the receive union gets -32601
413+
(METHOD_NOT_FOUND) on the wire, not a validation failure (-32602)."""
414+
async with create_client_server_memory_streams() as (client_streams, server_streams):
415+
client_read, client_write = client_streams
416+
server_read, server_write = server_streams
417+
418+
async with ClientSession(read_stream=client_read, write_stream=client_write):
419+
await server_write.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=7, method="x/unknown")))
420+
with anyio.fail_after(5): # pragma: no branch
421+
out = await server_read.receive()
422+
423+
assert isinstance(out, SessionMessage)
424+
assert isinstance(out.message, JSONRPCError)
425+
assert out.message.id == 7
426+
assert out.message.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="x/unknown")
427+
428+
429+
@pytest.mark.anyio
430+
async def test_receive_loop_drops_unknown_notification_method_without_response():
431+
"""An unknown notification method is dropped silently: JSON-RPC forbids
432+
responses to notifications, and the receive loop keeps serving."""
433+
async with create_client_server_memory_streams() as (client_streams, server_streams):
434+
client_read, client_write = client_streams
435+
server_read, server_write = server_streams
436+
437+
async with ClientSession(read_stream=client_read, write_stream=client_write):
438+
await server_write.send(SessionMessage(message=JSONRPCNotification(jsonrpc="2.0", method="x/unknown")))
439+
# The next wire output must be the answer to this follow-up ping,
440+
# proving the notification produced no response and the loop survived.
441+
await server_write.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")))
442+
with anyio.fail_after(5): # pragma: no branch
443+
out = await server_read.receive()
444+
445+
assert isinstance(out, SessionMessage)
446+
assert isinstance(out.message, JSONRPCResponse)
447+
assert out.message.id == 1

0 commit comments

Comments
 (0)