|
5 | 5 | path for earlier protocol revisions. |
6 | 6 |
|
7 | 7 | A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no |
8 | | -`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON |
| 8 | +`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. A |
| 9 | +notification POST is acknowledged `202` and dropped: the core protocol defines |
| 10 | +no client-to-server notifications on this wire (cancellation is closing the |
| 11 | +response stream), and a per-request entry has nothing for one to act on. JSON |
9 | 12 | mode handles the request directly in the ASGI task. SSE mode runs the handler |
10 | 13 | as a sibling task and defers committing to `text/event-stream` until the |
11 | 14 | handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever |
|
56 | 59 | from mcp.shared.inbound import ( |
57 | 60 | ERROR_CODE_HTTP_STATUS, |
58 | 61 | MCP_PARAM_HEADER_PREFIX, |
| 62 | + MCP_PROTOCOL_VERSION_HEADER, |
59 | 63 | InboundLadderRejection, |
60 | 64 | InboundModernRoute, |
61 | 65 | classify_inbound_request, |
62 | 66 | find_duplicated_routing_header, |
| 67 | + unsupported_protocol_version_rejection, |
63 | 68 | validate_mcp_param_headers, |
64 | 69 | ) |
65 | 70 | from mcp.shared.jsonrpc_dispatcher import progress_token_from_params |
@@ -162,7 +167,7 @@ def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> byt |
162 | 167 |
|
163 | 168 | async def _write_rejection( |
164 | 169 | rejection: InboundLadderRejection, |
165 | | - request_id: RequestId, |
| 170 | + request_id: RequestId | None, |
166 | 171 | scope: Scope, |
167 | 172 | receive: Receive, |
168 | 173 | send: Send, |
@@ -196,6 +201,60 @@ async def _write( |
196 | 201 | )(scope, receive, send) |
197 | 202 |
|
198 | 203 |
|
| 204 | +_INVALID_BODY: Final = JSONRPCError( |
| 205 | + jsonrpc="2.0", |
| 206 | + id=None, |
| 207 | + error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"), |
| 208 | +) |
| 209 | +"""Well-formed JSON that is not one request or notification: a batch, a posted response, a malformed envelope.""" |
| 210 | + |
| 211 | + |
| 212 | +def _is_notification_shaped(decoded: Any) -> bool: |
| 213 | + """Whether a decoded POST body is a single JSON object without an `id` member. |
| 214 | +
|
| 215 | + JSON-RPC 2.0 §4.1: a notification is a request object without an "id" |
| 216 | + member, so key presence — not which model happens to validate — picks the |
| 217 | + arm. (The notification model ignores unknown keys; letting it catch a |
| 218 | + request whose id is malformed would 202 a message that is owed an error.) |
| 219 | + """ |
| 220 | + return isinstance(decoded, dict) and "id" not in decoded |
| 221 | + |
| 222 | + |
| 223 | +async def _acknowledge_notification( |
| 224 | + decoded: dict[str, Any], |
| 225 | + request: Request, |
| 226 | + scope: Scope, |
| 227 | + receive: Receive, |
| 228 | + send: Send, |
| 229 | +) -> None: |
| 230 | + """Answer an id-less POST body: `202` for a notification at a served version, a rejection otherwise. |
| 231 | +
|
| 232 | + Streamable-http §Sending Messages item 5 lets a server accept (202, no |
| 233 | + body) or refuse (4xx) a notification POST; this entry accepts and drops. |
| 234 | + The 2026-07-28 core protocol defines no client-to-server notifications over |
| 235 | + HTTP (a client cancels by closing the response stream) and a per-request |
| 236 | + entry holds no cross-request state for one to act on — honouring a posted |
| 237 | + `notifications/cancelled` by client-chosen request id would let one |
| 238 | + anonymous caller cancel another's work — but clients in the field still |
| 239 | + POST them, and notifications are fire-and-forget, so they are acknowledged |
| 240 | + as the handshake-era transport does rather than answered with an error |
| 241 | + nobody reads. Header requirements for notification POSTs are undefined at |
| 242 | + this revision; only the routing header that brought the POST here is |
| 243 | + checked, so a version this entry does not serve is told so, as a request is. |
| 244 | + """ |
| 245 | + try: |
| 246 | + notification = JSONRPCNotification.model_validate(decoded) |
| 247 | + except ValidationError: |
| 248 | + await _write(_INVALID_BODY, scope, receive, send) |
| 249 | + return |
| 250 | + requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "") |
| 251 | + if (unsupported := unsupported_protocol_version_rejection(requested)) is not None: |
| 252 | + await _write_rejection(unsupported, None, scope, receive, send) |
| 253 | + return |
| 254 | + logger.debug("acknowledged and dropped client notification %s at %s", notification.method, requested) |
| 255 | + await Response(status_code=202)(scope, receive, send) |
| 256 | + |
| 257 | + |
199 | 258 | _MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower() |
200 | 259 |
|
201 | 260 | _MCP_PARAM_LIST_PAGE_CAP: Final = 100 |
@@ -346,22 +405,15 @@ async def handle_modern_request( |
346 | 405 | rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")) |
347 | 406 | await _write(rej, scope, receive, send) |
348 | 407 | return |
| 408 | + if _is_notification_shaped(decoded): |
| 409 | + await _acknowledge_notification(decoded, request, scope, receive, send) |
| 410 | + return |
349 | 411 | try: |
350 | 412 | req = JSONRPCRequest.model_validate(decoded) |
351 | 413 | except ValidationError: |
352 | | - # Well-formed JSON that isn't a single request object. The transport |
353 | | - # spec permits notification POSTs and gives the server two responses |
354 | | - # (202 accept / 4xx cannot-accept; streamable-http §Sending Messages |
355 | | - # item 5). The core protocol defines no client→server notifications |
356 | | - # over HTTP at 2026-07-28 (cancellation is SSE-stream close), so this |
357 | | - # entry takes the cannot-accept branch. TODO(L57): S4 owns the |
358 | | - # strict-vs-lenient choice. |
359 | | - rej = JSONRPCError( |
360 | | - jsonrpc="2.0", |
361 | | - id=None, |
362 | | - error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request object"), |
363 | | - ) |
364 | | - await _write(rej, scope, receive, send) |
| 414 | + # A batch, a posted response (clients MUST NOT send those: streamable-http |
| 415 | + # §Sending Messages item 4), or a request whose envelope is malformed. |
| 416 | + await _write(_INVALID_BODY, scope, receive, send) |
365 | 417 | return |
366 | 418 |
|
367 | 419 | if req.method == "subscriptions/listen" and not has_sse: |
|
0 commit comments