49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
77 lines
3.7 KiB
Markdown
77 lines
3.7 KiB
Markdown
# sse-polling
|
|
|
|
> **Legacy mechanism (2025 handshake era).** `Last-Event-ID` resumability and
|
|
> the sessionful transport are removed in the 2026-07-28 protocol (SEP-2575)
|
|
> with no modern-era equivalent; the closest 2026-era pattern is client-side
|
|
> reconnection over a persisted `DiscoverResult` —
|
|
> [`reconnect/`](../reconnect/). TODO(maxisbey): revisit before beta.
|
|
|
|
SEP-1699 server-initiated SSE disconnection with `Last-Event-ID` replay. The
|
|
server's `EventStore` stamps every SSE event with an ID and opens each response
|
|
stream with a priming event; mid-handler the tool calls
|
|
`ctx.close_sse_stream()` to release the open HTTP response (freeing a
|
|
connection slot), keeps emitting progress into the event store, and returns.
|
|
The client transport sees the stream end, reconnects with `Last-Event-ID`, and
|
|
the event store replays everything it missed — `await client.call_tool(...)`
|
|
resolves as if the disconnect never happened.
|
|
|
|
## Run it
|
|
|
|
```bash
|
|
# HTTP — the client self-hosts the app on a free port, runs, then tears it down
|
|
uv run python -m stories.sse_polling.client --http --legacy
|
|
# same, against the lowlevel-API server variant
|
|
uv run python -m stories.sse_polling.client --http --legacy --server server_lowlevel
|
|
|
|
# against a server you run yourself (real uvicorn on :8000)
|
|
uv run python -m stories.sse_polling.server --port 8000 &
|
|
SERVER_PID=$!
|
|
uv run python -m stories.sse_polling.client --http http://127.0.0.1:8000/mcp --legacy
|
|
kill "$SERVER_PID"
|
|
```
|
|
|
|
## What to look at
|
|
|
|
- **`client.py` `main` — opens with `async with Client(target, mode=mode)`.**
|
|
There is no client-side resumability configuration: the `Client` and the
|
|
`streamable_http_client` transport handle the priming event, the SSE `retry:`
|
|
hint, and the `Last-Event-ID` reconnect automatically. The assertion that the
|
|
`"after-close"` progress message arrived is the proof — it was emitted while
|
|
no SSE stream was open.
|
|
- **`server.py` — `streamable_http_app(event_store=..., retry_interval=0)`.**
|
|
Passing an `EventStore` is what enables resumability: every SSE event gets an
|
|
ID and the response opens with a priming event so the client always has a
|
|
`Last-Event-ID` to reconnect with. `retry_interval=0` makes the client's
|
|
reconnect wait a no-op (the SSE `retry:` hint).
|
|
- **`server.py` — `await ctx.close_sse_stream()`.** Ends the current request's
|
|
SSE response without cancelling the handler. Everything emitted afterwards
|
|
goes to the event store and is replayed on reconnect. A no-op when no
|
|
`event_store` is configured.
|
|
- **`server_lowlevel.py` — `ctx.close_sse_stream`.** On the lowlevel API the
|
|
callback is an optional field on `ServerRequestContext`; it is `None` unless
|
|
an event store is wired and the negotiated version is in the 2025 era.
|
|
|
|
## Caveats
|
|
|
|
- `streamable_http_app(...)` is a hosting entry that reshapes in a later
|
|
release; this story calls it directly because the event-store and
|
|
retry-interval kwargs are the point.
|
|
- DNS-rebinding protection is disabled (`transport_security=NO_DNS_REBIND`)
|
|
because the in-process httpx client sends no `Origin` header. Drop the kwarg
|
|
for a real deployment.
|
|
- `event_store.py` here is example-grade only (sequential IDs, no eviction). A
|
|
production server would back the `EventStore` interface with persistent
|
|
storage.
|
|
|
|
## Spec
|
|
|
|
[Resumability and Redelivery](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery)
|
|
· SEP-1699 (server-initiated SSE close)
|
|
|
|
## See also
|
|
|
|
`standalone_get/` (the standalone-stream sibling of `close_sse_stream()`),
|
|
`reconnect/` (the modern-era reconnection story — persisted `DiscoverResult`,
|
|
no event store), `streaming/` (in-flight progress + cancellation without the
|
|
disconnect).
|