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