Acknowledge notification POSTs with 202 on the 2026-07-28 HTTP entry (#3326)
This commit is contained in:
parent
9057285683
commit
b2025ab815
2 changed files with 254 additions and 37 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue