Files
2026-07-13 12:59:43 +08:00

383 lines
16 KiB
Python

#!/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()