Acknowledge notification POSTs with 202 on the 2026-07-28 HTTP entry (#3326)

This commit is contained in:
Max 2026-08-17 21:15:35 +01:00 • committed by GitHub
commit b2025ab815
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 254 additions and 37 deletions

View file

@ -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
# ═══════════════════════════════════════════════════════════════════════════

View file

@ -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.

View file

@ -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:

View file

@ -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 ---------------------------------------