#!/usr/bin/env python3 """ Resumable MCP Server Implementation This server provides full session resumption capabilities using an event store. It supports long-running tasks that can be resumed after client disconnection. """ import argparse import asyncio import logging import re from typing import Optional import anyio import uvicorn from pydantic import AnyUrl from starlette.applications import Starlette from starlette.routing import Mount from pydantic import BaseModel, Field from mcp.server import Server from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings from mcp.types import TextContent, Tool, SamplingMessage from .event_store import SimpleEventStore logger = logging.getLogger(__name__) class PriceConfirmationSchema(BaseModel): confirm: bool = Field(description="Confirm the price for this trip") notes: str = Field(default="", description="Any additional notes about the price") class ResumableServer(Server): """Server implementation with long-running tools and notifications for resumption testing.""" def __init__(self, name: str = "resumable_mcp_server"): super().__init__(name) logger.info(f"ResumableServer '{name}' initialized") @self.list_tools() async def handle_list_tools() -> list[Tool]: """List available tools including resumable ones.""" return [ Tool( name="travel_agent", description="Book a travel trip with progress updates and price confirmation", inputSchema={ "type": "object", "properties": { "destination": { "type": "string", "description": "Travel destination", "default": "Paris" } } }, ), Tool( name="research_agent", description="Research a topic with progress updates and interactive summaries", inputSchema={ "type": "object", "properties": { "topic": { "type": "string", "description": "Research topic", "default": "AI trends" } } }, ), Tool( name="long_running_agent", description="A long-running task for testing resumption (50 steps, 2 seconds each)", inputSchema={ "type": "object", "properties": {} }, ), ] @self.call_tool() async def handle_call_tool(name: str, args: dict) -> list[TextContent]: """Handle tool execution with support for long-running tasks.""" ctx = self.request_context logger.info(f"Tool called: {name} with args: {args}") if name == "travel_agent": destination = args.get("destination", "Paris") logger.info(f"Travel agent: destination={destination}") # Simple travel booking flow with progress updates steps = [ "Checking flights...", "Finding available dates...", "Confirming prices...", "Booking flight..." ] elicitation_result = None booking_cancelled = False for i, step in enumerate(steps): await ctx.session.send_progress_notification( progress_token=ctx.request_id, progress=i * 25, total=100, message=step, related_request_id=str(ctx.request_id) ) # Add elicitation request at step 3 (Confirming prices) if i == 2: # "Confirming prices..." step try: elicit_result = await ctx.session.elicit( message=f"Please confirm the estimated price of $1200 for your trip to {destination}", requestedSchema=PriceConfirmationSchema.model_json_schema(), related_request_id=ctx.request_id, ) elicitation_result = elicit_result if elicit_result and elicit_result.action == "accept": logger.info(f"User confirmed price: {elicit_result.content}") # Continue with booking elif elicit_result and elicit_result.action == "decline": logger.info(f"User declined price confirmation: {elicit_result.content}") booking_cancelled = True # Stop the booking process await ctx.session.send_progress_notification( progress_token=ctx.request_id, progress=100, total=100, message="Booking cancelled by user", related_request_id= str(ctx.request_id) ) break else: logger.info("User cancelled elicitation") booking_cancelled = True await ctx.session.send_progress_notification( progress_token=ctx.request_id, progress=100, total=100, message="Booking cancelled" ) break except Exception as e: logger.info(f"Elicitation request failed (this is normal in tests): {e}") # Continue with booking anyway for fallback if not booking_cancelled: await anyio.sleep(2) # Fixed 0.5 second delay between steps # Generate final result based on elicitation outcome if booking_cancelled: if elicitation_result and hasattr(elicitation_result, 'content') and elicitation_result.content: notes = elicitation_result.content.get('notes', 'No reason provided') result_text = f"āŒ Booking cancelled for trip to {destination}. Reason: {notes}" else: result_text = f"āŒ Booking cancelled for trip to {destination}." else: # Final progress update for successful booking await ctx.session.send_progress_notification( progress_token=ctx.request_id, progress=100, total=100, message="Trip booked successfully" ) # Include confirmation details in success message if elicitation_result and elicitation_result.action == "accept" and elicitation_result.content: notes = elicitation_result.content.get('notes', 'No additional notes') result_text = f"āœ… Trip booked successfully to {destination}! Price confirmed with notes: '{notes}'" else: result_text = f"āœ… Trip booked successfully to {destination}!" return [TextContent(type="text", text=result_text)] elif name == "research_agent": topic = args.get("topic", "AI trends") logger.info(f"Research agent: topic={topic}") # Simple research flow with progress updates steps = [ "Gathering sources...", "Analyzing data...", "Summarizing findings...", "Finalizing report..." ] sampling_summary = None for i, step in enumerate(steps): await ctx.session.send_progress_notification( progress_token=ctx.request_id, progress=i * 25, total=100, message=step ) # Add sampling request at step 3 (Summarizing findings) if i == 2: # "Summarizing findings..." step try: sampling_result = await ctx.session.create_message( messages=[ SamplingMessage( role="user", content=TextContent(type="text", text=f"Please summarize the key findings for research on: {topic}") ) ], max_tokens=100, related_request_id=ctx.request_id, ) if sampling_result and sampling_result.content: if sampling_result.content.type == "text": sampling_summary = sampling_result.content.text logger.info(f"Received sampling summary: {sampling_summary}") except Exception as e: logger.info(f"Sampling request failed (this is normal in tests): {e}") await anyio.sleep(2) # Fixed 0.5 second delay between steps # Final progress update await ctx.session.send_progress_notification( progress_token=ctx.request_id, progress=100, total=100, message="Research completed successfully" ) # Use sampling summary if available, otherwise default message if sampling_summary: result_text = f"šŸ” Research on '{topic}' completed successfully!\n\nšŸ“Š Key Findings (from user input): {sampling_summary}" else: result_text = f"šŸ” Research on '{topic}' completed successfully!" return [TextContent(type="text", text=result_text)] elif name == "long_running_agent": # Fixed values optimized for resumption testing steps = 50 duration = 2.0 logger.info(f"Long running agent: {steps} steps, {duration}s each") # Send initial log message await ctx.session.send_log_message( level="info", data="Long-running task started", logger="long_running_agent", related_request_id=ctx.request_id, ) # Execute the long-running task for i in range(steps): current_step = i + 1 # Use integer arithmetic to avoid floating point precision issues progress_percent = (current_step * 100) // steps # Send log message for each step await ctx.session.send_log_message( level="info", data=f"Processing step {current_step}/{steps} ({progress_percent}%)", logger="long_running_agent", related_request_id=ctx.request_id, ) # Wait for 2 seconds await anyio.sleep(duration) # Send completion log message await ctx.session.send_log_message( level="info", data=f"Task completed successfully! Processed {steps} steps in {steps * duration:.0f} seconds.", logger="long_running_agent", related_request_id=ctx.request_id, ) # Final completion message result_text = f"āœ… Long-running task completed successfully! Processed {steps} steps in {steps * duration:.0f} seconds." return [TextContent(type="text", text=result_text)] else: raise ValueError(f"Unknown tool: {name}") def create_server_app(event_store: Optional[EventStore] = None) -> Starlette: """Create the Starlette application with resumable MCP server.""" # Create server instance server = ResumableServer() # Create security settings security_settings = TransportSecuritySettings( allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] ) # Create session manager with event store session_manager = StreamableHTTPSessionManager( app=server, event_store=event_store, json_response=False, # Use SSE streams security_settings=security_settings, ) # Create ASGI application app = Starlette( debug=True, routes=[ Mount("/mcp", app=session_manager.handle_request), ], lifespan=lambda app: session_manager.run(), ) return app async def run_server(port: int = 8006, with_event_store: bool = True) -> None: """Run the resumable HTTP server.""" # Create event store if requested event_store = SimpleEventStore() if with_event_store else None # Create application app = create_server_app(event_store) # Configure server config = uvicorn.Config( app=app, host="127.0.0.1", port=port, log_level="info", limit_concurrency=10, timeout_keep_alive=30, access_log=True, ) logger.info(f"Starting Resumable HTTP MCP Server on http://127.0.0.1:{port}/mcp") if event_store: logger.info("Event store enabled - resumption supported") else: logger.info("Event store disabled - no resumption support") # Start the server server = uvicorn.Server(config=config) try: await server.serve() except KeyboardInterrupt: logger.info("Server stopped by user") except Exception as e: logger.error(f"Server error: {e}") raise def main(): """Main entry point.""" parser = argparse.ArgumentParser(description="Resumable HTTP MCP Server") parser.add_argument("--port", type=int, default=8006, help="Port to listen on (default: 8006)") parser.add_argument("--no-event-store", action="store_true", help="Disable event store (no resumption)") args = parser.parse_args() # Configure logging logging.basicConfig(level=logging.INFO) # Run the server asyncio.run(run_server( port=args.port, with_event_store=not args.no_event_store )) if __name__ == "__main__": main()