#!/usr/bin/env python3 """ Event Store Implementation for MCP Session Resumption This module provides event store implementations that enable MCP session resumption by storing and replaying events after client reconnection. """ import asyncio import logging import sqlite3 from typing import Optional from pydantic import TypeAdapter from mcp.server.streamable_http import ( EventCallback, EventId, EventMessage, EventStore, StreamId, ) from mcp.types import JSONRPCMessage logger = logging.getLogger(__name__) class SimpleEventStore(EventStore): """Simple in-memory event store for testing resumption functionality.""" def __init__(self): self._events: list[tuple[StreamId, EventId, JSONRPCMessage]] = [] self._event_id_counter = 0 logger.info("SimpleEventStore initialized") async def store_event(self, stream_id: StreamId, message: JSONRPCMessage) -> 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)) logger.info(f"Stored event {event_id} for stream {stream_id}") return event_id async def replay_events_after( self, last_event_id: EventId, send_callback: EventCallback, ) -> StreamId | None: """Replay events after the specified ID.""" logger.info(f"Replaying events after {last_event_id}") # Find the index of the last event ID start_index = None for i, (_, event_id, _) in enumerate(self._events): if event_id == last_event_id: start_index = i + 1 break if start_index is None: # If event ID not found, start from beginning start_index = 0 logger.info("Event ID not found, starting from beginning") stream_id = None # Replay events replayed_count = 0 for _, event_id, message in self._events[start_index:]: await send_callback(EventMessage(message, event_id)) replayed_count += 1 # Capture the stream ID from the first replayed event if stream_id is None and len(self._events) > start_index: stream_id = self._events[start_index][0] logger.info(f"Replayed {replayed_count} events, stream_id: {stream_id}") return stream_id def get_event_count(self) -> int: """Get the total number of stored events.""" return len(self._events) def clear_events(self) -> None: """Clear all stored events.""" self._events.clear() self._event_id_counter = 0 logger.info("Event store cleared") class PersistentEventStore(EventStore): """ Event store that persists events to disk using SQLite. """ def __init__(self, storage_path: str = "events.db") -> None: self.storage_path = storage_path self._adapter = TypeAdapter(JSONRPCMessage) # Use check_same_thread=False to allow access from asyncio executor threads self._conn = sqlite3.connect(self.storage_path, check_same_thread=False) self._create_table() logger.info(f"PersistentEventStore initialized with {self.storage_path}") def _create_table(self) -> None: """Create the events table if it doesn't exist.""" cursor = self._conn.cursor() try: cursor.execute(""" CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, stream_id TEXT NOT NULL, message TEXT NOT NULL ) """) self._conn.commit() except sqlite3.Error: logger.exception("Failed to create 'events' table in PersistentEventStore") try: self._conn.close() except Exception: logger.exception("Failed to close SQLite connection after table creation error") raise finally: try: cursor.close() except Exception: logger.exception("Failed to close SQLite cursor after table creation") async def store_event(self, stream_id: StreamId, message: JSONRPCMessage) -> EventId: """Store an event and return its ID.""" # Serialize message to JSON json_str = self._adapter.dump_json(message).decode('utf-8') # Run DB operation in thread pool to avoid blocking event loop return await asyncio.to_thread(self._store_event_sync, stream_id, json_str) def _store_event_sync(self, stream_id: StreamId, json_str: str) -> EventId: cursor = self._conn.cursor() cursor.execute( "INSERT INTO events (stream_id, message) VALUES (?, ?)", (stream_id, json_str) ) self._conn.commit() event_id = str(cursor.lastrowid) logger.info(f"Stored event {event_id} for stream {stream_id}") return event_id async def replay_events_after( self, last_event_id: EventId, send_callback: EventCallback, ) -> StreamId | None: """Replay events after the specified ID, filtered by the stream of the last event.""" logger.info(f"Replaying events after {last_event_id}") # Fetch events in thread pool events_data = await asyncio.to_thread(self._fetch_events_sync, last_event_id) if events_data is None: logger.warning(f"Could not resume stream from event {last_event_id}") return None stream_id = None replayed_count = 0 for event_id, row_stream_id, message_json in events_data: if stream_id is None: stream_id = row_stream_id try: message = self._adapter.validate_json(message_json) await send_callback(EventMessage(message, event_id)) replayed_count += 1 except Exception as e: logger.error(f"Failed to deserialize event {event_id}: {e}") logger.info(f"Replayed {replayed_count} events for stream {stream_id}") return stream_id def _fetch_events_sync(self, last_event_id: EventId) -> list[tuple[EventId, StreamId, str]] | None: try: target_id = int(last_event_id) except (ValueError, TypeError): logger.warning(f"Invalid event ID format: {last_event_id}") return None cursor = self._conn.cursor() # 1. Identify the stream from the last event ID cursor.execute("SELECT stream_id FROM events WHERE id = ?", (target_id,)) result = cursor.fetchone() if not result: logger.warning(f"Event ID {target_id} not found") return None stream_id = result[0] # 2. Fetch subsequent events for THIS STREAM ONLY cursor.execute( "SELECT id, stream_id, message FROM events WHERE id > ? AND stream_id = ? ORDER BY id ASC", (target_id, stream_id) ) # Convert rows to list of (str_id, stream_id, msg_json) return [(str(row[0]), row[1], row[2]) for row in cursor.fetchall()] def get_event_count(self) -> int: """Get the total number of stored events.""" cursor = self._conn.cursor() cursor.execute("SELECT COUNT(*) FROM events") result = cursor.fetchone() return result[0] if result else 0 def clear_events(self) -> None: """Clear all stored events.""" cursor = self._conn.cursor() cursor.execute("DELETE FROM events") # Reset auto-increment sequence cursor.execute("DELETE FROM sqlite_sequence WHERE name='events'") self._conn.commit() logger.info("Event store cleared") def close(self) -> None: """Close the underlying SQLite connection.""" conn = getattr(self, "_conn", None) if conn is None: return try: conn.close() logger.info("PersistentEventStore connection closed") except sqlite3.Error as exc: logger.warning("Error closing PersistentEventStore connection: %s", exc) finally: self._conn = None def __enter__(self) -> "PersistentEventStore": """Enter the runtime context related to this object.""" return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: """Exit the runtime context and close the connection.""" self.close()