Add SSE polling support (SEP-1699) (#1654)

This commit is contained in:
Felix Weinberger 2025-12-02 11:44:49 +00:00 • committed by GitHub
commit 281fd4765e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1461 additions and 45 deletions

View file

@ -14,6 +14,7 @@ import click
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.fastmcp.prompts.base import UserMessage
from mcp.server.session import ServerSession
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
from mcp.types import (
AudioContent,
Completion,
@ -21,6 +22,7 @@ from mcp.types import (
CompletionContext,
EmbeddedResource,
ImageContent,
JSONRPCMessage,
PromptReference,
ResourceTemplateReference,
SamplingMessage,
@ -31,6 +33,43 @@ from pydantic import AnyUrl, BaseModel, Field
logger = logging.getLogger(__name__)
# Type aliases for event store
StreamId = str
EventId = str
class InMemoryEventStore(EventStore):
"""Simple in-memory event store for SSE resumability testing."""
def __init__(self) -> None:
self._events: list[tuple[StreamId, EventId, JSONRPCMessage | None]] = []
self._event_id_counter = 0
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
"""Store an event and return its ID."""
self._event_id_counter += 1
event_id = str(self._event_id_counter)
self._events.append((stream_id, event_id, message))
return event_id
async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None:
"""Replay events after the specified ID."""
target_stream_id = None
for stream_id, event_id, _ in self._events:
if event_id == last_event_id:
target_stream_id = stream_id
break
if target_stream_id is None:
return None
last_event_id_int = int(last_event_id)
for stream_id, event_id, message in self._events:
if stream_id == target_stream_id and int(event_id) > last_event_id_int:
# Skip priming events (None message)
if message is not None:
await send_callback(EventMessage(message, event_id))
return target_stream_id
# Test data
TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
TEST_AUDIO_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="
@ -39,8 +78,13 @@ TEST_AUDIO_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA
resource_subscriptions: set[str] = set()
watched_resource_content = "Watched resource content"
# Create event store for SSE resumability (SEP-1699)
event_store = InMemoryEventStore()
mcp = FastMCP(
name="mcp-conformance-test-server",
event_store=event_store,
retry_interval=100, # 100ms retry interval for SSE polling
)
@ -263,6 +307,19 @@ def test_error_handling() -> str:
raise RuntimeError("This tool intentionally returns an error for testing")
@mcp.tool()
async def test_reconnection(ctx: Context[ServerSession, None]) -> str:
"""Tests SSE polling by closing stream mid-call (SEP-1699)"""
await ctx.info("Before disconnect")
await ctx.close_sse_stream()
await asyncio.sleep(0.2) # Wait for client to reconnect
await ctx.info("After reconnect")
return "Reconnection test completed"
# Resources
@mcp.resource("test://static-text")
def static_text_resource() -> str:

View file

@ -24,7 +24,7 @@ class EventEntry:
event_id: EventId
stream_id: StreamId
message: JSONRPCMessage
message: JSONRPCMessage | None
class InMemoryEventStore(EventStore):
@ -48,7 +48,7 @@ class InMemoryEventStore(EventStore):
# event_id -> EventEntry for quick lookup
self.event_index: dict[EventId, EventEntry] = {}
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage) -> EventId:
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
"""Stores an event with a generated event ID."""
event_id = str(uuid4())
event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message)
@ -88,7 +88,9 @@ class InMemoryEventStore(EventStore):
found_last = False
for event in stream_events:
if found_last:
await send_callback(EventMessage(event.message, event.event_id))
# Skip priming events (None message)
if event.message is not None:
await send_callback(EventMessage(event.message, event.event_id))
elif event.event_id == last_event_id:
found_last = True

View file

@ -0,0 +1,36 @@
# MCP SSE Polling Demo Server
Demonstrates the SSE polling pattern with server-initiated stream close for long-running tasks (SEP-1699).
## Features
- Priming events (automatic with EventStore)
- Server-initiated stream close via `close_sse_stream()` callback
- Client auto-reconnect with Last-Event-ID
- Progress notifications during long-running tasks
- Configurable retry interval
## Usage
```bash
# Start server on default port
uv run mcp-sse-polling-demo --port 3000
# Custom retry interval (milliseconds)
uv run mcp-sse-polling-demo --port 3000 --retry-interval 100
```
## Tool: process_batch
Processes items with periodic checkpoints that trigger SSE stream closes:
- `items`: Number of items to process (1-100, default: 10)
- `checkpoint_every`: Close stream after this many items (1-20, default: 3)
## Client
Use the companion `mcp-sse-polling-client` to test:
```bash
uv run mcp-sse-polling-client --url http://localhost:3000/mcp
```

View file

@ -0,0 +1 @@
"""SSE Polling Demo Server - demonstrates close_sse_stream for long-running tasks."""

View file

@ -0,0 +1,6 @@
"""Entry point for the SSE Polling Demo server."""
from .server import main
if __name__ == "__main__":
main()

View file

@ -0,0 +1,100 @@
"""
In-memory event store for demonstrating resumability functionality.
This is a simple implementation intended for examples and testing,
not for production use where a persistent storage solution would be more appropriate.
"""
import logging
from collections import deque
from dataclasses import dataclass
from uuid import uuid4
from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId
from mcp.types import JSONRPCMessage
logger = logging.getLogger(__name__)
@dataclass
class EventEntry:
"""Represents an event entry in the event store."""
event_id: EventId
stream_id: StreamId
message: JSONRPCMessage | None # None for priming events
class InMemoryEventStore(EventStore):
"""
Simple in-memory implementation of the EventStore interface for resumability.
This is primarily intended for examples and testing, not for production use
where a persistent storage solution would be more appropriate.
This implementation keeps only the last N events per stream for memory efficiency.
"""
def __init__(self, max_events_per_stream: int = 100):
"""Initialize the event store.
Args:
max_events_per_stream: Maximum number of events to keep per stream
"""
self.max_events_per_stream = max_events_per_stream
# for maintaining last N events per stream
self.streams: dict[StreamId, deque[EventEntry]] = {}
# event_id -> EventEntry for quick lookup
self.event_index: dict[EventId, EventEntry] = {}
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
"""Stores an event with a generated event ID.
Args:
stream_id: ID of the stream the event belongs to
message: The message to store, or None for priming events
"""
event_id = str(uuid4())
event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message)
# Get or create deque for this stream
if stream_id not in self.streams:
self.streams[stream_id] = deque(maxlen=self.max_events_per_stream)
# If deque is full, the oldest event will be automatically removed
# We need to remove it from the event_index as well
if len(self.streams[stream_id]) == self.max_events_per_stream:
oldest_event = self.streams[stream_id][0]
self.event_index.pop(oldest_event.event_id, None)
# Add new event
self.streams[stream_id].append(event_entry)
self.event_index[event_id] = event_entry
return event_id
async def replay_events_after(
self,
last_event_id: EventId,
send_callback: EventCallback,
) -> StreamId | None:
"""Replays events that occurred after the specified event ID."""
if last_event_id not in self.event_index:
logger.warning(f"Event ID {last_event_id} not found in store")
return None
# Get the stream and find events after the last one
last_event = self.event_index[last_event_id]
stream_id = last_event.stream_id
stream_events = self.streams.get(last_event.stream_id, deque())
# Events in deque are already in chronological order
found_last = False
for event in stream_events:
if found_last:
# Skip priming events (None messages) during replay
if event.message is not None:
await send_callback(EventMessage(event.message, event.event_id))
elif event.event_id == last_event_id:
found_last = True
return stream_id

View file

@ -0,0 +1,177 @@
"""
SSE Polling Demo Server
Demonstrates the SSE polling pattern with close_sse_stream() for long-running tasks.
Features demonstrated:
- Priming events (automatic with EventStore)
- Server-initiated stream close via close_sse_stream callback
- Client auto-reconnect with Last-Event-ID
- Progress notifications during long-running tasks
Run with:
uv run mcp-sse-polling-demo --port 3000
"""
import contextlib
import logging
from collections.abc import AsyncIterator
from typing import Any
import anyio
import click
import mcp.types as types
from mcp.server.lowlevel import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.types import Receive, Scope, Send
from .event_store import InMemoryEventStore
logger = logging.getLogger(__name__)
@click.command()
@click.option("--port", default=3000, help="Port to listen on")
@click.option(
"--log-level",
default="INFO",
help="Logging level (DEBUG, INFO, WARNING, ERROR)",
)
@click.option(
"--retry-interval",
default=100,
help="SSE retry interval in milliseconds (sent to client)",
)
def main(port: int, log_level: str, retry_interval: int) -> int:
"""Run the SSE Polling Demo server."""
logging.basicConfig(
level=getattr(logging, log_level.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
# Create the lowlevel server
app = Server("sse-polling-demo")
@app.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]:
"""Handle tool calls."""
ctx = app.request_context
if name == "process_batch":
items = arguments.get("items", 10)
checkpoint_every = arguments.get("checkpoint_every", 3)
if items < 1 or items > 100:
return [types.TextContent(type="text", text="Error: items must be between 1 and 100")]
if checkpoint_every < 1 or checkpoint_every > 20:
return [types.TextContent(type="text", text="Error: checkpoint_every must be between 1 and 20")]
await ctx.session.send_log_message(
level="info",
data=f"Starting batch processing of {items} items...",
logger="process_batch",
related_request_id=ctx.request_id,
)
for i in range(1, items + 1):
# Simulate work
await anyio.sleep(0.5)
# Report progress
await ctx.session.send_log_message(
level="info",
data=f"[{i}/{items}] Processing item {i}",
logger="process_batch",
related_request_id=ctx.request_id,
)
# Checkpoint: close stream to trigger client reconnect
if i % checkpoint_every == 0 and i < items:
await ctx.session.send_log_message(
level="info",
data=f"Checkpoint at item {i} - closing SSE stream for polling",
logger="process_batch",
related_request_id=ctx.request_id,
)
if ctx.close_sse_stream:
logger.info(f"Closing SSE stream at checkpoint {i}")
await ctx.close_sse_stream()
# Wait for client to reconnect (must be > retry_interval of 100ms)
await anyio.sleep(0.2)
return [
types.TextContent(
type="text",
text=f"Successfully processed {items} items with checkpoints every {checkpoint_every} items",
)
]
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
@app.list_tools()
async def list_tools() -> list[types.Tool]:
"""List available tools."""
return [
types.Tool(
name="process_batch",
description=(
"Process a batch of items with periodic checkpoints. "
"Demonstrates SSE polling where server closes stream periodically."
),
inputSchema={
"type": "object",
"properties": {
"items": {
"type": "integer",
"description": "Number of items to process (1-100)",
"default": 10,
},
"checkpoint_every": {
"type": "integer",
"description": "Close stream after this many items (1-20)",
"default": 3,
},
},
},
)
]
# Create event store for resumability
event_store = InMemoryEventStore()
# Create session manager with event store and retry interval
session_manager = StreamableHTTPSessionManager(
app=app,
event_store=event_store,
retry_interval=retry_interval,
)
async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None:
await session_manager.handle_request(scope, receive, send)
@contextlib.asynccontextmanager
async def lifespan(starlette_app: Starlette) -> AsyncIterator[None]:
async with session_manager.run():
logger.info(f"SSE Polling Demo server started on port {port}")
logger.info("Try: POST /mcp with tools/call for 'process_batch'")
yield
logger.info("Server shutting down...")
starlette_app = Starlette(
debug=True,
routes=[
Mount("/mcp", app=handle_streamable_http),
],
lifespan=lifespan,
)
import uvicorn
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
return 0
if __name__ == "__main__":
main()

View file

@ -0,0 +1,36 @@
[project]
name = "mcp-sse-polling-demo"
version = "0.1.0"
description = "Demo server showing SSE polling with close_sse_stream for long-running tasks"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Anthropic, PBC." }]
keywords = ["mcp", "sse", "polling", "streamable", "http"]
license = { text = "MIT" }
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"]
[project.scripts]
mcp-sse-polling-demo = "mcp_sse_polling_demo.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["mcp_sse_polling_demo"]
[tool.pyright]
include = ["mcp_sse_polling_demo"]
venvPath = "."
venv = ".venv"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = []
[tool.ruff]
line-length = 120
target-version = "py310"
[dependency-groups]
dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"]