"""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." ), input_schema={ "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()