Acknowledge notification POSTs with 202 on the 2026-07-28 HTTP entry (#3326)
This commit is contained in:
parent
9057285683
commit
b2025ab815
8 changed files with 254 additions and 37 deletions
|
|
@ -173,7 +173,7 @@ The constructor covers the methods MCP defines. `add_request_handler` covers eve
|
|||
--8<-- "docs_src/lowlevel/tutorial006.py"
|
||||
```
|
||||
|
||||
* The first argument is the method string. Notifications have a twin, `add_notification_handler`.
|
||||
* The first argument is the method string. Notifications have a twin, `add_notification_handler`. Its handlers fire on stdio and on handshake-era HTTP connections; on the `2026-07-28` streamable-HTTP path a client's notification POST is acknowledged `202` and not dispatched, because that revision defines no client-to-server notifications over HTTP.
|
||||
* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's.
|
||||
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.
|
||||
|
||||
|
|
|
|||
|
|
@ -48,8 +48,11 @@ That is the point. Middleware wraps **every** inbound message:
|
|||
|
||||
* The connection setup: `server/discover`, or `initialize` and `notifications/initialized`
|
||||
on a legacy session.
|
||||
* Every request and every notification. For a notification, `ctx.request_id is None`,
|
||||
`call_next(ctx)` returns `None`, and whatever you return is discarded.
|
||||
* Every request and every notification that reaches the server. For a notification,
|
||||
`ctx.request_id is None`, `call_next(ctx)` returns `None`, and whatever you return is discarded.
|
||||
(On the `2026-07-28` streamable-HTTP path a client's notification POST is acknowledged `202` at
|
||||
the transport and never dispatched, so it does not reach middleware either; that revision
|
||||
defines no client-to-server notifications over HTTP.)
|
||||
* Even a method the server has no handler for: `call_next` raises the
|
||||
`MCPError(-32601, "Method not found")` *through* your middleware on its way to the client.
|
||||
|
||||
|
|
@ -105,8 +108,8 @@ don't think about it. It is a no-op until you install an exporter, and it has it
|
|||
|
||||
* A middleware is `async (ctx, call_next) -> result`, passed as `MCPServer(middleware=[...])` (or
|
||||
appended to `mcp.middleware`), and appended to `server.middleware` on the low-level `Server`.
|
||||
* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications,
|
||||
unknown methods) and runs outermost-first.
|
||||
* It wraps **every** inbound message that reaches the server (`server/discover`, `initialize`,
|
||||
requests, notifications, unknown methods) and runs outermost-first.
|
||||
* `ctx.request_id is None` is how you tell a notification from a request.
|
||||
* Raise instead of calling `call_next` to refuse one message; the connection survives.
|
||||
* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ The legacy streamable-HTTP transport is untouched and remains the supported
|
|||
path for earlier protocol revisions.
|
||||
|
||||
A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
|
||||
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON
|
||||
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. A
|
||||
notification POST is acknowledged `202` and dropped: the core protocol defines
|
||||
no client-to-server notifications on this wire (cancellation is closing the
|
||||
response stream), and a per-request entry has nothing for one to act on. JSON
|
||||
mode handles the request directly in the ASGI task. SSE mode runs the handler
|
||||
as a sibling task and defers committing to `text/event-stream` until the
|
||||
handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever
|
||||
|
|
@ -56,10 +59,12 @@ from mcp.shared.exceptions import NoBackChannelError
|
|||
from mcp.shared.inbound import (
|
||||
ERROR_CODE_HTTP_STATUS,
|
||||
MCP_PARAM_HEADER_PREFIX,
|
||||
MCP_PROTOCOL_VERSION_HEADER,
|
||||
InboundLadderRejection,
|
||||
InboundModernRoute,
|
||||
classify_inbound_request,
|
||||
find_duplicated_routing_header,
|
||||
unsupported_protocol_version_rejection,
|
||||
validate_mcp_param_headers,
|
||||
)
|
||||
from mcp.shared.jsonrpc_dispatcher import progress_token_from_params
|
||||
|
|
@ -162,7 +167,7 @@ def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> byt
|
|||
|
||||
async def _write_rejection(
|
||||
rejection: InboundLadderRejection,
|
||||
request_id: RequestId,
|
||||
request_id: RequestId | None,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
|
|
@ -196,6 +201,60 @@ async def _write(
|
|||
)(scope, receive, send)
|
||||
|
||||
|
||||
_INVALID_BODY: Final = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=None,
|
||||
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"),
|
||||
)
|
||||
"""Well-formed JSON that is not one request or notification: a batch, a posted response, a malformed envelope."""
|
||||
|
||||
|
||||
def _is_notification_shaped(decoded: Any) -> bool:
|
||||
"""Whether a decoded POST body is a single JSON object without an `id` member.
|
||||
|
||||
JSON-RPC 2.0 §4.1: a notification is a request object without an "id"
|
||||
member, so key presence — not which model happens to validate — picks the
|
||||
arm. (The notification model ignores unknown keys; letting it catch a
|
||||
request whose id is malformed would 202 a message that is owed an error.)
|
||||
"""
|
||||
return isinstance(decoded, dict) and "id" not in decoded
|
||||
|
||||
|
||||
async def _acknowledge_notification(
|
||||
decoded: dict[str, Any],
|
||||
request: Request,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
) -> None:
|
||||
"""Answer an id-less POST body: `202` for a notification at a served version, a rejection otherwise.
|
||||
|
||||
Streamable-http §Sending Messages item 5 lets a server accept (202, no
|
||||
body) or refuse (4xx) a notification POST; this entry accepts and drops.
|
||||
The 2026-07-28 core protocol defines no client-to-server notifications over
|
||||
HTTP (a client cancels by closing the response stream) and a per-request
|
||||
entry holds no cross-request state for one to act on — honouring a posted
|
||||
`notifications/cancelled` by client-chosen request id would let one
|
||||
anonymous caller cancel another's work — but clients in the field still
|
||||
POST them, and notifications are fire-and-forget, so they are acknowledged
|
||||
as the handshake-era transport does rather than answered with an error
|
||||
nobody reads. Header requirements for notification POSTs are undefined at
|
||||
this revision; only the routing header that brought the POST here is
|
||||
checked, so a version this entry does not serve is told so, as a request is.
|
||||
"""
|
||||
try:
|
||||
notification = JSONRPCNotification.model_validate(decoded)
|
||||
except ValidationError:
|
||||
await _write(_INVALID_BODY, scope, receive, send)
|
||||
return
|
||||
requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "")
|
||||
if (unsupported := unsupported_protocol_version_rejection(requested)) is not None:
|
||||
await _write_rejection(unsupported, None, scope, receive, send)
|
||||
return
|
||||
logger.debug("acknowledged and dropped client notification %s at %s", notification.method, requested)
|
||||
await Response(status_code=202)(scope, receive, send)
|
||||
|
||||
|
||||
_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower()
|
||||
|
||||
_MCP_PARAM_LIST_PAGE_CAP: Final = 100
|
||||
|
|
@ -346,22 +405,15 @@ async def handle_modern_request(
|
|||
rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
|
||||
await _write(rej, scope, receive, send)
|
||||
return
|
||||
if _is_notification_shaped(decoded):
|
||||
await _acknowledge_notification(decoded, request, scope, receive, send)
|
||||
return
|
||||
try:
|
||||
req = JSONRPCRequest.model_validate(decoded)
|
||||
except ValidationError:
|
||||
# Well-formed JSON that isn't a single request object. The transport
|
||||
# spec permits notification POSTs and gives the server two responses
|
||||
# (202 accept / 4xx cannot-accept; streamable-http §Sending Messages
|
||||
# item 5). The core protocol defines no client→server notifications
|
||||
# over HTTP at 2026-07-28 (cancellation is SSE-stream close), so this
|
||||
# entry takes the cannot-accept branch. TODO(L57): S4 owns the
|
||||
# strict-vs-lenient choice.
|
||||
rej = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=None,
|
||||
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request object"),
|
||||
)
|
||||
await _write(rej, scope, receive, send)
|
||||
# A batch, a posted response (clients MUST NOT send those: streamable-http
|
||||
# §Sending Messages item 4), or a request whose envelope is malformed.
|
||||
await _write(_INVALID_BODY, scope, receive, send)
|
||||
return
|
||||
|
||||
if req.method == "subscriptions/listen" and not has_sse:
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ __all__ = [
|
|||
"find_duplicated_routing_header",
|
||||
"find_invalid_x_mcp_header",
|
||||
"mcp_param_headers",
|
||||
"unsupported_protocol_version_rejection",
|
||||
"validate_mcp_param_headers",
|
||||
"x_mcp_header_map",
|
||||
]
|
||||
|
|
@ -367,6 +368,25 @@ def find_duplicated_routing_header(headers: Iterable[tuple[str, str]]) -> str |
|
|||
return None
|
||||
|
||||
|
||||
def unsupported_protocol_version_rejection(
|
||||
requested: str, supported_modern_versions: Sequence[str] = MODERN_PROTOCOL_VERSIONS
|
||||
) -> InboundLadderRejection | None:
|
||||
"""The `UNSUPPORTED_PROTOCOL_VERSION` rejection for `requested`, or `None` if it is served.
|
||||
|
||||
The request ladder's last rung, shared with the transport's notification arm
|
||||
so both message kinds name the same `supported` list in the same words.
|
||||
"""
|
||||
if requested in supported_modern_versions:
|
||||
return None
|
||||
return InboundLadderRejection(
|
||||
code=UNSUPPORTED_PROTOCOL_VERSION,
|
||||
message="Unsupported protocol version",
|
||||
data=UnsupportedProtocolVersionErrorData(
|
||||
supported=list(supported_modern_versions), requested=requested
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
def classify_inbound_request(
|
||||
body: Mapping[str, Any],
|
||||
*,
|
||||
|
|
@ -464,14 +484,8 @@ def classify_inbound_request(
|
|||
message="the protocol-version envelope value must be a string",
|
||||
)
|
||||
|
||||
if protocol_version not in supported_modern_versions:
|
||||
return InboundLadderRejection(
|
||||
code=UNSUPPORTED_PROTOCOL_VERSION,
|
||||
message="Unsupported protocol version",
|
||||
data=UnsupportedProtocolVersionErrorData(
|
||||
supported=list(supported_modern_versions), requested=protocol_version
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
if (unsupported := unsupported_protocol_version_rejection(protocol_version, supported_modern_versions)) is not None:
|
||||
return unsupported
|
||||
|
||||
return InboundModernRoute(
|
||||
protocol_version=protocol_version,
|
||||
|
|
|
|||
|
|
@ -3182,7 +3182,12 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
|
||||
behavior="A POST containing only notifications or responses returns 202 with no body.",
|
||||
transports=("streamable-http",),
|
||||
note="Only observable over HTTP: 202 is an HTTP status code.",
|
||||
removed_in="2026-07-28",
|
||||
superseded_by="hosting:http:modern:notification-post-202",
|
||||
note=(
|
||||
"Only observable over HTTP: 202 is an HTTP status code. At 2026-07-28 clients no longer post "
|
||||
"responses (streamable-http §Sending Messages item 4), so only the notification half carries over."
|
||||
),
|
||||
),
|
||||
"hosting:http:onerror": Requirement(
|
||||
source="sdk",
|
||||
|
|
@ -3375,6 +3380,22 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
transports=("streamable-http",),
|
||||
note="Only observable over streamable HTTP: the modern entry's JSONRPCError-to-HTTP-status mapping.",
|
||||
),
|
||||
"hosting:http:modern:notification-post-202": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#sending-messages",
|
||||
behavior=(
|
||||
"A 2026-07-28 POST whose body is a single JSON-RPC notification is acknowledged 202 with no "
|
||||
"body (the spec's accept branch) and is not dispatched; a posted JSON-RPC response is rejected "
|
||||
"INVALID_REQUEST at HTTP 400."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
supersedes=("hosting:http:notifications-202",),
|
||||
transports=("streamable-http",),
|
||||
note=(
|
||||
"Only observable over streamable HTTP: the HTTP status is the assertion. The revision defines no "
|
||||
"client-to-server notifications on this transport (cancellation is closing the response stream), "
|
||||
"so accept-and-drop is the SDK's choice between the two responses the spec permits."
|
||||
),
|
||||
),
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Client transport: streamable HTTP
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from mcp_types import (
|
|||
HEADER_MISMATCH,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
SERVER_INFO_META_KEY,
|
||||
|
|
@ -27,6 +28,7 @@ from mcp_types import (
|
|||
CallToolResult,
|
||||
DiscoverResult,
|
||||
EmptyResult,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
JSONRPCError,
|
||||
JSONRPCResponse,
|
||||
|
|
@ -153,6 +155,39 @@ async def test_modern_response_carries_no_session_id_header() -> None:
|
|||
assert "mcp-session-id" not in response.headers
|
||||
|
||||
|
||||
@requirement("hosting:http:modern:notification-post-202")
|
||||
@pytest.mark.parametrize("json_response", [True, False], ids=["json", "sse"])
|
||||
@pytest.mark.parametrize("stateless_http", [True, False], ids=["stateless-flag", "default"])
|
||||
async def test_modern_notification_post_is_acknowledged_202_and_a_posted_response_is_rejected(
|
||||
json_response: bool, stateless_http: bool
|
||||
) -> None:
|
||||
"""A 2026-07-28 notification POST is answered 202 with no body; a posted response is 400 INVALID_REQUEST.
|
||||
|
||||
Spec-permitted (streamable-http §Sending Messages item 5): the server may accept (202) or refuse
|
||||
(4xx) a notification POST, and the SDK accepts -- the same answer the legacy leg gives, so a
|
||||
client's courtesy `notifications/cancelled` is not met with an error on one era only.
|
||||
Spec-mandated (item 4): clients MUST NOT post responses, so one is refused. Driven through the
|
||||
mounted app so the manager's header routing is in the path, under both response modes and both
|
||||
values of the legacy-only `stateless_http` flag (neither is read before the modern entry answers).
|
||||
"""
|
||||
notification = {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}
|
||||
posted_response: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "result": {}}
|
||||
async with mounted_app(_server(), json_response=json_response, stateless_http=stateless_http) as (http, _):
|
||||
acknowledged = await http.post(
|
||||
"/mcp", json=notification, headers=_modern_headers(method="notifications/cancelled")
|
||||
)
|
||||
refused = await http.post("/mcp", json=posted_response, headers=_modern_headers(method="tools/list"))
|
||||
|
||||
assert (acknowledged.status_code, acknowledged.content) == (202, b"")
|
||||
assert "mcp-session-id" not in acknowledged.headers
|
||||
assert refused.status_code == 400
|
||||
assert JSONRPCError.model_validate(refused.json()) == JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=None,
|
||||
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"),
|
||||
)
|
||||
|
||||
|
||||
@requirement("hosting:http:modern:initialize-removed")
|
||||
async def test_modern_initialize_is_method_not_found() -> None:
|
||||
"""A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from mcp_types import (
|
|||
PARSE_ERROR,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
SERVER_INFO_META_KEY,
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
ClientCapabilities,
|
||||
|
|
@ -34,10 +35,11 @@ from mcp_types import (
|
|||
ListToolsResult,
|
||||
LoggingMessageNotification,
|
||||
LoggingMessageNotificationParams,
|
||||
NotificationParams,
|
||||
PaginatedRequestParams,
|
||||
Tool,
|
||||
)
|
||||
from mcp_types.version import LATEST_MODERN_VERSION
|
||||
from mcp_types.version import LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
from trio.testing import MockClock
|
||||
|
||||
|
|
@ -110,18 +112,91 @@ async def test_handle_modern_request_rejects_non_post_with_http_405_and_allow_he
|
|||
assert response.content == b""
|
||||
|
||||
|
||||
async def test_handle_modern_request_rejects_a_notification_body_with_invalid_request() -> None:
|
||||
"""SDK-defined: well-formed JSON that isn't a single JSON-RPC request object (e.g. a
|
||||
notification, which lacks ``id``) is ``INVALID_REQUEST`` — distinct from ``PARSE_ERROR``,
|
||||
which is for malformed JSON."""
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param(
|
||||
{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}, id="cancelled"
|
||||
),
|
||||
pytest.param({"jsonrpc": "2.0", "method": "notifications/roots/list_changed"}, id="removed-at-2026"),
|
||||
pytest.param({"jsonrpc": "2.0", "method": "acme/heartbeat", "params": {"n": 1}}, id="custom"),
|
||||
pytest.param(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/cancelled",
|
||||
"params": {"requestId": "listen:0", "_meta": {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION}},
|
||||
},
|
||||
id="with-envelope",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_handle_modern_request_acknowledges_a_notification_post_with_202_and_drops_it(
|
||||
body: dict[str, Any],
|
||||
) -> None:
|
||||
"""Spec-permitted (streamable-http §Sending Messages item 5, the accept branch): a POST whose
|
||||
body is one JSON-RPC notification is answered 202 with no body, whatever its method and
|
||||
whether or not it carries a `_meta` envelope. SDK-defined: it is dropped, not dispatched --
|
||||
a registered handler for the method never runs (strict-no-cover fails CI if it does)."""
|
||||
|
||||
async def on_notify(ctx: Any, params: NotificationParams) -> None:
|
||||
raise AssertionError("unreachable") # pragma: no cover
|
||||
|
||||
server: Server[Any] = Server("test")
|
||||
server.add_notification_handler(body["method"], NotificationParams, on_notify)
|
||||
async with _asgi_client(server) as http:
|
||||
response = await http.post("/mcp", json=body)
|
||||
assert (response.status_code, response.content) == (202, b"")
|
||||
|
||||
|
||||
async def test_handle_modern_request_rejects_a_notification_post_at_an_unserved_version() -> None:
|
||||
"""SDK-defined: the manager routes any non-handshake `MCP-Protocol-Version` here, so a
|
||||
notification claiming a version this entry does not serve gets the same
|
||||
`UNSUPPORTED_PROTOCOL_VERSION` answer (HTTP 400, `supported` list) a request would."""
|
||||
async with _asgi_client(Server("test")) as http:
|
||||
response = await http.post(
|
||||
"/mcp",
|
||||
content=b'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}',
|
||||
headers={"content-type": "application/json"},
|
||||
json={"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}},
|
||||
headers={MCP_PROTOCOL_VERSION_HEADER: "2099-01-01"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["code"] == INVALID_REQUEST
|
||||
assert response.json() == {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {
|
||||
"code": UNSUPPORTED_PROTOCOL_VERSION,
|
||||
"message": "Unsupported protocol version",
|
||||
"data": {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": "2099-01-01"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param({"jsonrpc": "2.0", "id": 1, "result": {}}, id="posted-response"),
|
||||
pytest.param({"jsonrpc": "2.0", "id": 1, "error": {"code": -1, "message": "x"}}, id="posted-error"),
|
||||
pytest.param([{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}], id="batch"),
|
||||
pytest.param({"jsonrpc": "2.0", "id": None, "method": "tools/list"}, id="null-id-request"),
|
||||
pytest.param({"jsonrpc": "2.0", "id": [1], "method": "tools/list"}, id="non-scalar-id-request"),
|
||||
pytest.param({"jsonrpc": "2.0", "method": 7}, id="non-string-method-notification"),
|
||||
pytest.param({"jsonrpc": "1.0", "method": "notifications/cancelled"}, id="wrong-jsonrpc-version"),
|
||||
pytest.param("just a string", id="scalar"),
|
||||
],
|
||||
)
|
||||
async def test_handle_modern_request_rejects_a_body_that_is_neither_request_nor_notification(body: Any) -> None:
|
||||
"""Spec-mandated (streamable-http §Sending Messages item 4): the body MUST be a single request
|
||||
or notification and clients MUST NOT post responses. SDK-defined: anything else -- a posted
|
||||
response, a batch, a request whose `id` is malformed, a scalar -- is `INVALID_REQUEST` at
|
||||
HTTP 400 with `id: null`, distinct from `PARSE_ERROR` (malformed JSON). A malformed-`id`
|
||||
request in particular must not be mistaken for a notification and silently 202'd."""
|
||||
async with _asgi_client(Server("test")) as http:
|
||||
response = await http.post("/mcp", json=body)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": INVALID_REQUEST, "message": "Body must be a single JSON-RPC request or notification object"},
|
||||
}
|
||||
|
||||
|
||||
async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -> None:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from mcp.shared.inbound import (
|
|||
find_duplicated_routing_header,
|
||||
find_invalid_x_mcp_header,
|
||||
mcp_param_headers,
|
||||
unsupported_protocol_version_rejection,
|
||||
validate_mcp_param_headers,
|
||||
x_mcp_header_map,
|
||||
)
|
||||
|
|
@ -225,6 +226,22 @@ def test_version_rung_data_reflects_supplied_supported_list() -> None:
|
|||
assert rejection.data == {"supported": list(custom), "requested": LATEST_MODERN_VERSION}
|
||||
|
||||
|
||||
def test_unsupported_protocol_version_rejection_is_the_version_rung_standalone() -> None:
|
||||
"""SDK-defined: the standalone helper (used by the HTTP notification arm) yields `None` for a
|
||||
served version and otherwise the very rejection the request ladder's version rung produces."""
|
||||
assert unsupported_protocol_version_rejection(LATEST_MODERN_VERSION) is None
|
||||
assert unsupported_protocol_version_rejection("2099-01-01") == classify_inbound_request(
|
||||
envelope(version="2099-01-01")
|
||||
)
|
||||
assert unsupported_protocol_version_rejection(LATEST_MODERN_VERSION, (LATEST_HANDSHAKE_VERSION,)) == (
|
||||
InboundLadderRejection(
|
||||
code=UNSUPPORTED_PROTOCOL_VERSION,
|
||||
message="Unsupported protocol version",
|
||||
data={"supported": [LATEST_HANDSHAKE_VERSION], "requested": LATEST_MODERN_VERSION},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --- rung 3: header ↔ envelope agreement ---------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue