Source code for duck.contrib.mcp.session

"""
MCP session store.

Sessions are stored entirely in the configured InMemoryCache, which provides
LRU eviction and TTL expiration automatically.
"""

from __future__ import annotations

import uuid

from typing import Optional
from functools import wraps

from duck.utils.caching import InMemoryCache

from duck.contrib.mcp.exceptions import SessionError


# Registry for all sessions
DEFAULT_SESSION_REGISTRY = InMemoryCache(maxkeys=2048)


[docs] class SessionStore(dict): """ Represents a single MCP session. Session data is cached in-memory using the provided cache instance. """ __slots__ = ("_session_id", "_cache", "_ttl", "_loaded", "_modified") def __init__( self, cache: Optional[InMemoryCache] = None, session_id: Optional[str] = None, ttl: int = 3600, ): """ Initialize a session. Args: cache: Shared in-memory cache. session_id: Existing session ID. A new one is generated if omitted. ttl: Session lifetime in seconds. """ super().__init__() # Initialize the session store. self._session_id = session_id # Some private attributes self._cache = cache or DEFAULT_SESSION_REGISTRY self._ttl = ttl self._loaded = False self._modified = False @property def session_id(self) -> Optional[str]: return self._session_id @session_id.setter def session_id(self, value: Optional[str]): # Switching identities invalidates whatever we'd loaded for the old one. if value != self._session_id: self._session_id = value self._loaded = False self._modified = False super().clear() @property def loaded(self) -> bool: return self._loaded @property def modified(self) -> bool: """ Whether the session data has changed since it was last loaded or saved. """ return self._modified
[docs] @staticmethod def generate_session_id() -> str: """ Generate a new session identifier. """ return str(uuid.uuid4().hex)
[docs] @staticmethod def ensure_session_loaded(method): """ Decorator which ensures that the session is loaded. """ @wraps(method) def wrapper(self, *args, **kwargs): if not self.loaded and self._session_id: # Load from session cache self.load() # Execute the decorated method return method(self, *args, **kwargs) # Return wrapper return wrapper
[docs] def assign_new_session_id(self) -> str: """ Creates a new session with a new session id. """ self.session_id = self.generate_session_id() self._loaded = True # brand-new id, nothing in cache to fetch return self.session_id
[docs] def load(self) -> dict: """ Load session data from cache. """ super().clear() # Fetch new data. data = self._cache.get(self.session_id) if data: super().update(data) # Flag that session has been loaded self._loaded = True # Freshly loaded data reflects what's in cache, so nothing's dirty yet. self._modified = False # Return loaded data. return dict(self)
[docs] @ensure_session_loaded def save(self) -> None: """ Persist the session to cache. """ if not self.session_id: raise SessionError("Cannot save session without session ID assigned.") # Set the session in cache. self._cache.set(self.session_id, dict(self), expiry=self._ttl) # Persisted, so there are no pending changes anymore. self._modified = False
[docs] @ensure_session_loaded def delete(self) -> None: """ Delete the session. """ self._cache.delete(self.session_id) super().clear() self._modified = False
[docs] @ensure_session_loaded def exists(self) -> bool: """ Return whether the session exists. """ return self._cache.get(self.session_id) is not None
[docs] def touch(self) -> None: """ Refresh the session TTL. """ self.save()
[docs] def needs_update(self) -> bool: """ Return whether the session has pending changes that haven't been saved yet. """ return self.modified
[docs] @ensure_session_loaded def setdefault(self, *a, **kw): """ Set a default value for a key if it's not already present, marking the session as modified. """ self._modified = True return super().setdefault(*a, **kw)
[docs] @ensure_session_loaded def __setitem__(self, key, value): """ Set a key's value, marking the session as modified. """ self._modified = True super().__setitem__(key, value)
[docs] @ensure_session_loaded def __delitem__(self, key): """ Delete a key, marking the session as modified. """ self._modified = True super().__delitem__(key)
[docs] @ensure_session_loaded def update(self, *a, **kw): """ Update multiple keys at once, marking the session as modified. """ self._modified = True super().update(*a, **kw)
[docs] @ensure_session_loaded def pop(self, *a, **kw): """ Remove and return a key's value, marking the session as modified. """ self._modified = True return super().pop(*a, **kw)
[docs] @ensure_session_loaded def popitem(self): """ Remove and return a (key, value) pair, marking the session as modified. """ self._modified = True return super().popitem()
[docs] @ensure_session_loaded def clear(self): """ Remove all data from the session, marking the session as modified. """ self._modified = True super().clear()
[docs] @ensure_session_loaded def __getitem__(self, key): return super().__getitem__(key)
[docs] @ensure_session_loaded def __contains__(self, key): return super().__contains__(key)
[docs] @ensure_session_loaded def __repr__(self) -> str: return f"<SessionStore {dict(self)}>"
[docs] def __str__(self) -> str: return self.__repr__()