chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Call a tool whose SSE stream the server closes mid-flight; the call still completes. HTTP-only — no SSE on stdio."""
|
||||
|
||||
import anyio
|
||||
from mcp_types import TextContent
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||
async with Client(target, mode=mode) as client:
|
||||
messages: list[str | None] = []
|
||||
|
||||
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
||||
messages.append(message)
|
||||
|
||||
with anyio.fail_after(10):
|
||||
result = await client.call_tool("long_operation", {}, progress_callback=on_progress)
|
||||
|
||||
# The result arrived — the client transport survived the server-initiated close,
|
||||
# reconnected with Last-Event-ID, and received the replayed response.
|
||||
assert not result.is_error, result
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "resumed"
|
||||
|
||||
# "after-close" was emitted while no SSE stream was open; receiving it proves the
|
||||
# event store buffered it and the reconnect replayed it.
|
||||
assert messages == ["before-close", "after-close"], messages
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Minimal in-memory `EventStore` for the SSE-resumability example.
|
||||
|
||||
Sequential integer IDs so the wire is readable; a production server would back
|
||||
this interface with persistent storage so replay survives a process restart.
|
||||
"""
|
||||
|
||||
from mcp_types import JSONRPCMessage
|
||||
|
||||
from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId
|
||||
|
||||
|
||||
class InMemoryEventStore(EventStore):
|
||||
"""Stores every event in arrival order and replays the same-stream tail after a given ID."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._events: list[tuple[StreamId, JSONRPCMessage | None]] = []
|
||||
|
||||
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
|
||||
self._events.append((stream_id, message))
|
||||
return str(len(self._events))
|
||||
|
||||
async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None:
|
||||
try:
|
||||
cursor = int(last_event_id)
|
||||
except ValueError:
|
||||
return None
|
||||
if not 0 < cursor <= len(self._events):
|
||||
return None
|
||||
stream_id, _ = self._events[cursor - 1]
|
||||
for index in range(cursor, len(self._events)):
|
||||
event_stream_id, message = self._events[index]
|
||||
if event_stream_id == stream_id and message is not None:
|
||||
await send_callback(EventMessage(message, str(index + 1)))
|
||||
return stream_id
|
||||
@@ -0,0 +1,35 @@
|
||||
"""SEP-1699: a tool closes its own SSE stream mid-call; the event store buffers the rest. Exports `build_app()`."""
|
||||
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
from stories.sse_polling.event_store import InMemoryEventStore
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
mcp = MCPServer("sse-polling-example")
|
||||
|
||||
@mcp.tool()
|
||||
async def long_operation(ctx: Context) -> str:
|
||||
"""Emit progress, close this call's SSE stream, emit more progress, then return.
|
||||
|
||||
Everything sent after `close_sse_stream()` lands in the event store and is
|
||||
replayed when the client reconnects with `Last-Event-ID`.
|
||||
"""
|
||||
await ctx.report_progress(0.5, total=1.0, message="before-close")
|
||||
await ctx.close_sse_stream()
|
||||
await ctx.report_progress(1.0, total=1.0, message="after-close")
|
||||
return "resumed"
|
||||
|
||||
# event_store enables Last-Event-ID replay; retry_interval=0 makes the client's
|
||||
# reconnect wait a no-op so the example is deterministic without real time.
|
||||
return mcp.streamable_http_app(
|
||||
event_store=InMemoryEventStore(),
|
||||
retry_interval=0,
|
||||
transport_security=NO_DNS_REBIND,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""SEP-1699 polling on the lowlevel `Server`: close the request's SSE stream mid-handler."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
from stories.sse_polling.event_store import InMemoryEventStore
|
||||
|
||||
_TOOL = types.Tool(
|
||||
name="long_operation",
|
||||
description="Emit progress, close the SSE stream, emit more, return.",
|
||||
input_schema={"type": "object", "properties": {}},
|
||||
)
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[_TOOL])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.name == "long_operation"
|
||||
await ctx.session.report_progress(0.5, total=1.0, message="before-close")
|
||||
# The transport only wires this callback when an event_store is configured and the
|
||||
# negotiated version is in the 2025 era; it is None otherwise.
|
||||
if ctx.close_sse_stream is not None:
|
||||
await ctx.close_sse_stream()
|
||||
await ctx.session.report_progress(1.0, total=1.0, message="after-close")
|
||||
return types.CallToolResult(content=[types.TextContent(text="resumed")])
|
||||
|
||||
server = Server("sse-polling-example", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
return server.streamable_http_app(
|
||||
event_store=InMemoryEventStore(),
|
||||
retry_interval=0,
|
||||
transport_security=NO_DNS_REBIND,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
Reference in New Issue
Block a user