Add SSE polling support (SEP-1699) (#1654)
This commit is contained in:
parent
2cd178a962
commit
281fd4765e
4 changed files with 1461 additions and 45 deletions
|
|
@ -0,0 +1 @@
|
|||
"""SSE Polling Demo Server - demonstrates close_sse_stream for long-running tasks."""
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""Entry point for the SSE Polling Demo server."""
|
||||
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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
|
||||
177
examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py
Normal file
177
examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py
Normal 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()
|
||||
Loading…
Reference in a new issue