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

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

View file

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