|
8 | 8 | `Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON |
9 | 9 | mode handles the request directly in the ASGI task. SSE mode runs the handler |
10 | 10 | as a sibling task and defers committing to `text/event-stream` until the |
11 | | -handler actually emits a notification: a handler that completes (or raises) |
12 | | -without emitting still gets a JSON response with the table-mapped HTTP |
13 | | -status, so the spec's `404`/`400` MUSTs hold for kernel-dispatch errors. |
| 11 | +handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever |
| 12 | +comes first: a handler that completes (or raises) within that window without |
| 13 | +emitting still gets a JSON response with the table-mapped HTTP status, so |
| 14 | +the spec's `404`/`400` MUSTs hold for kernel-dispatch errors; a handler that |
| 15 | +runs silent past the window commits SSE so the keepalive ping can keep the |
| 16 | +connection open behind a proxy idle-read timeout. |
14 | 17 | """ |
15 | 18 |
|
16 | 19 | from __future__ import annotations |
@@ -298,24 +301,33 @@ async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None: |
298 | 301 | tg.start_soon(run_handler) |
299 | 302 | tg.start_soon(watch_disconnect, tg.cancel_scope) |
300 | 303 |
|
301 | | - try: |
302 | | - first = await recv_ch.receive() |
303 | | - except anyio.EndOfStream: |
304 | | - first = None |
305 | | - |
306 | | - if first is None: |
| 304 | + event: bytes | None = None |
| 305 | + done = False |
| 306 | + with anyio.move_on_after(_SSE_PING_INTERVAL): |
| 307 | + try: |
| 308 | + event = await recv_ch.receive() |
| 309 | + except anyio.EndOfStream: |
| 310 | + done = True |
| 311 | + |
| 312 | + if done: |
| 313 | + # Handler completed within the deferral window without emitting: |
| 314 | + # `application/json` with the table-mapped status. Kernel-dispatch |
| 315 | + # errors (METHOD_NOT_FOUND, missing-capability, INVALID_PARAMS) |
| 316 | + # resolve here in practice. |
307 | 317 | await _write(result[0], scope, receive, send) |
308 | 318 | else: |
| 319 | + # First notification arrived, or the deferral window elapsed: commit |
| 320 | + # `text/event-stream` and start pinging so a proxy idle-read timeout |
| 321 | + # cannot close the stream (which on this path cancels the handler). |
309 | 322 | await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS}) |
310 | | - event: bytes | None = first |
311 | | - while True: |
| 323 | + while not done: |
312 | 324 | await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True}) |
313 | 325 | event = None |
314 | 326 | with anyio.move_on_after(_SSE_PING_INTERVAL): |
315 | 327 | try: |
316 | 328 | event = await recv_ch.receive() |
317 | 329 | except anyio.EndOfStream: |
318 | | - break |
| 330 | + done = True |
319 | 331 | await send({"type": "http.response.body", "body": _sse_event(result[0]), "more_body": False}) |
320 | 332 |
|
321 | 333 | tg.cancel_scope.cancel() |
0 commit comments