Skip to main content

tower_mcp/
session_store.rs

1//! Pluggable session storage for HTTP and WebSocket transports.
2//!
3//! Session state is split into two layers:
4//! - **Persistent metadata** ([`SessionRecord`]) -- serializable, can be stored
5//!   in Redis, Postgres, etc. Persisted via the [`SessionStore`] trait.
6//! - **Runtime state** -- broadcast channels, pending request handles, service
7//!   instances. Held in-memory per server instance; cannot be serialized.
8//!   This includes legacy server-to-client requests associated with an
9//!   originating HTTP POST. Shared session metadata does not make those live
10//!   exchanges portable; use session affinity while they are in flight.
11//!
12//! By default transports use [`MemorySessionStore`], which keeps metadata in an
13//! in-process `HashMap` (behavior identical to earlier versions). External
14//! stores (Redis, Postgres, etc.) can be plugged in to share session metadata
15//! across server instances behind a load balancer.
16//!
17//! # Example
18//!
19//! ```rust,no_run
20//! use std::sync::Arc;
21//! use tower_mcp::session_store::{MemorySessionStore, SessionStore};
22//!
23//! let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
24//! // `HttpTransport::new(router).session_store(store)` (once wired in).
25//! ```
26//!
27//! This API follows the shape of
28//! [`tower-sessions`](https://docs.rs/tower-sessions), adapted for MCP's session
29//! model (header-based `mcp-session-id`, structured metadata instead of
30//! arbitrary `HashMap<String, Value>`).
31
32use std::collections::HashMap;
33use std::sync::Arc;
34use std::time::{Duration, SystemTime};
35
36use async_trait::async_trait;
37use serde::{Deserialize, Serialize};
38use tokio::sync::RwLock;
39
40use crate::protocol::{ClientCapabilities, Implementation};
41
42/// Serializable session metadata persisted by a [`SessionStore`].
43///
44/// Contains everything needed to reconstruct a session after a restart or
45/// across server instances. Does **not** contain runtime state (channels,
46/// pending requests) -- those are rebuilt locally on restore.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[non_exhaustive]
49pub struct SessionRecord {
50    /// Session ID (as used in the `mcp-session-id` header).
51    pub id: String,
52    /// Negotiated MCP protocol version (e.g. `"2025-11-25"`).
53    pub protocol_version: String,
54    /// Client implementation info from the `initialize` request.
55    pub client_info: Option<Implementation>,
56    /// Client capabilities from the `initialize` request.
57    pub client_capabilities: Option<ClientCapabilities>,
58    /// When this session was created.
59    pub created_at: SystemTime,
60    /// When this session was last accessed.
61    pub last_accessed: SystemTime,
62    /// When this session expires. Implementations may remove expired records.
63    pub expires_at: SystemTime,
64}
65
66impl SessionRecord {
67    /// Create a new record with a generated ID and timestamps.
68    ///
69    /// `ttl` is used to derive `expires_at` from `now`.
70    pub fn new(id: impl Into<String>, protocol_version: impl Into<String>, ttl: Duration) -> Self {
71        let now = SystemTime::now();
72        Self {
73            id: id.into(),
74            protocol_version: protocol_version.into(),
75            client_info: None,
76            client_capabilities: None,
77            created_at: now,
78            last_accessed: now,
79            expires_at: now + ttl,
80        }
81    }
82
83    /// Refresh `last_accessed` and `expires_at` using the given TTL.
84    pub fn touch(&mut self, ttl: Duration) {
85        let now = SystemTime::now();
86        self.last_accessed = now;
87        self.expires_at = now + ttl;
88    }
89
90    /// Returns `true` if `expires_at` is in the past.
91    pub fn is_expired(&self) -> bool {
92        SystemTime::now() >= self.expires_at
93    }
94}
95
96/// Errors returned by [`SessionStore`] implementations.
97///
98/// Mirrors the three-variant shape used by `tower-sessions`: encode/decode
99/// errors from the record (de)serialization, and catch-all backend errors
100/// from the storage layer.
101#[derive(Debug, thiserror::Error)]
102#[non_exhaustive]
103pub enum SessionStoreError {
104    /// Failed to encode a [`SessionRecord`] (e.g. serde serialization error).
105    #[error("encode error: {0}")]
106    Encode(String),
107    /// Failed to decode a [`SessionRecord`] (e.g. corrupt data in the backend).
108    #[error("decode error: {0}")]
109    Decode(String),
110    /// Backend error (e.g. connection failure, transient storage error).
111    #[error("backend error: {0}")]
112    Backend(String),
113}
114
115/// Result alias for session store operations.
116pub type Result<T> = std::result::Result<T, SessionStoreError>;
117
118/// Storage backend for MCP session metadata.
119///
120/// Implementations persist [`SessionRecord`]s keyed by session ID. The default
121/// implementation is [`MemorySessionStore`]; external stores (Redis, Postgres,
122/// etc.) typically live in separate crates.
123///
124/// # Semantics
125///
126/// - [`create`](Self::create) must ensure the ID in the record is unique,
127///   retrying ID generation if necessary.
128/// - [`save`](Self::save) trusts the caller's ID and performs an upsert.
129/// - [`load`](Self::load) returns `None` for unknown or expired sessions.
130///   Implementations may choose to return expired records and let the caller
131///   decide, or filter them out.
132/// - [`delete`](Self::delete) is idempotent -- removing a non-existent ID is
133///   not an error.
134#[async_trait]
135pub trait SessionStore: Send + Sync + 'static {
136    /// Create a new session record.
137    ///
138    /// The implementation must ensure `record.id` does not collide with an
139    /// existing session, regenerating the ID if needed.
140    async fn create(&self, record: &mut SessionRecord) -> Result<()>;
141
142    /// Persist an existing session record. The ID is trusted.
143    async fn save(&self, record: &SessionRecord) -> Result<()>;
144
145    /// Load a session record by ID. Returns `None` if unknown or expired.
146    async fn load(&self, id: &str) -> Result<Option<SessionRecord>>;
147
148    /// Remove a session record. Idempotent.
149    async fn delete(&self, id: &str) -> Result<()>;
150}
151
152/// In-memory [`SessionStore`] backed by a `HashMap`.
153///
154/// This is the default store. Suitable for single-instance deployments. For
155/// horizontal scaling, use an external store that shares state across
156/// instances.
157#[derive(Debug, Default, Clone)]
158pub struct MemorySessionStore {
159    inner: Arc<RwLock<HashMap<String, SessionRecord>>>,
160}
161
162impl MemorySessionStore {
163    /// Create a new empty in-memory session store.
164    pub fn new() -> Self {
165        Self::default()
166    }
167
168    /// Returns the number of records currently in the store.
169    ///
170    /// Useful for metrics endpoints; not part of the [`SessionStore`] trait.
171    pub async fn len(&self) -> usize {
172        self.inner.read().await.len()
173    }
174
175    /// Returns `true` if the store has no records.
176    pub async fn is_empty(&self) -> bool {
177        self.inner.read().await.is_empty()
178    }
179
180    /// Remove all expired records. Returns the number removed.
181    pub async fn cleanup_expired(&self) -> usize {
182        let mut map = self.inner.write().await;
183        let before = map.len();
184        map.retain(|_, record| !record.is_expired());
185        before - map.len()
186    }
187}
188
189#[async_trait]
190impl SessionStore for MemorySessionStore {
191    async fn create(&self, record: &mut SessionRecord) -> Result<()> {
192        let mut map = self.inner.write().await;
193        // Ensure uniqueness. UUIDs collide with negligible probability, but
194        // retry to be safe and to let callers pass a preferred ID.
195        while map.contains_key(&record.id) {
196            record.id = uuid::Uuid::new_v4().to_string();
197        }
198        map.insert(record.id.clone(), record.clone());
199        Ok(())
200    }
201
202    async fn save(&self, record: &SessionRecord) -> Result<()> {
203        self.inner
204            .write()
205            .await
206            .insert(record.id.clone(), record.clone());
207        Ok(())
208    }
209
210    async fn load(&self, id: &str) -> Result<Option<SessionRecord>> {
211        let map = self.inner.read().await;
212        Ok(map.get(id).filter(|r| !r.is_expired()).cloned())
213    }
214
215    async fn delete(&self, id: &str) -> Result<()> {
216        self.inner.write().await.remove(id);
217        Ok(())
218    }
219}
220
221/// Two-tier [`SessionStore`] composed of a cache frontend and a store backend.
222///
223/// Reads hit the cache first; on miss, they fall through to the backend and
224/// populate the cache. Writes go to both tiers. Deletes remove from both.
225///
226/// This lets users pair a fast in-process cache (e.g. [`MemorySessionStore`])
227/// with a durable backend (e.g. a Redis-backed store), keeping in-memory
228/// read performance while gaining cross-instance durability.
229///
230/// Mirrors `tower_sessions_core::session_store::CachingSessionStore`.
231///
232/// # Example
233///
234/// ```rust,no_run
235/// use std::sync::Arc;
236/// use tower_mcp::session_store::{CachingSessionStore, MemorySessionStore, SessionStore};
237///
238/// // In production the backend would be a Redis/Postgres/etc store type.
239/// let backend = MemorySessionStore::new();
240/// let cache = MemorySessionStore::new();
241/// let store: Arc<dyn SessionStore> =
242///     Arc::new(CachingSessionStore::new(cache, backend));
243/// ```
244#[derive(Debug, Clone)]
245pub struct CachingSessionStore<Cache, Store> {
246    cache: Cache,
247    store: Store,
248}
249
250impl<Cache, Store> CachingSessionStore<Cache, Store> {
251    /// Create a new caching store with the given cache and backend.
252    pub fn new(cache: Cache, store: Store) -> Self {
253        Self { cache, store }
254    }
255}
256
257#[async_trait]
258impl<Cache, Store> SessionStore for CachingSessionStore<Cache, Store>
259where
260    Cache: SessionStore,
261    Store: SessionStore,
262{
263    async fn create(&self, record: &mut SessionRecord) -> Result<()> {
264        // Create in the backend first so the authoritative ID is established,
265        // then mirror into the cache.
266        self.store.create(record).await?;
267        self.cache.save(record).await?;
268        Ok(())
269    }
270
271    async fn save(&self, record: &SessionRecord) -> Result<()> {
272        self.store.save(record).await?;
273        self.cache.save(record).await?;
274        Ok(())
275    }
276
277    async fn load(&self, id: &str) -> Result<Option<SessionRecord>> {
278        if let Some(record) = self.cache.load(id).await? {
279            return Ok(Some(record));
280        }
281        match self.store.load(id).await? {
282            Some(record) => {
283                // Populate the cache for subsequent reads. Failures here are
284                // non-fatal — we still return the record.
285                let _ = self.cache.save(&record).await;
286                Ok(Some(record))
287            }
288            None => Ok(None),
289        }
290    }
291
292    async fn delete(&self, id: &str) -> Result<()> {
293        let cache_result = self.cache.delete(id).await;
294        let store_result = self.store.delete(id).await;
295        cache_result.and(store_result)
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use std::time::Duration;
303
304    fn sample_record(id: &str) -> SessionRecord {
305        SessionRecord::new(id, "2025-11-25", Duration::from_secs(60))
306    }
307
308    #[tokio::test]
309    async fn memory_store_create_load_delete() {
310        let store = MemorySessionStore::new();
311        let mut record = sample_record("abc");
312        store.create(&mut record).await.unwrap();
313
314        let loaded = store.load("abc").await.unwrap();
315        assert!(loaded.is_some());
316        assert_eq!(loaded.unwrap().id, "abc");
317
318        store.delete("abc").await.unwrap();
319        assert!(store.load("abc").await.unwrap().is_none());
320    }
321
322    #[tokio::test]
323    async fn memory_store_create_regenerates_id_on_collision() {
324        let store = MemorySessionStore::new();
325        let mut first = sample_record("dup");
326        store.create(&mut first).await.unwrap();
327
328        let mut second = sample_record("dup");
329        store.create(&mut second).await.unwrap();
330
331        assert_ne!(first.id, second.id);
332        assert!(store.load(&first.id).await.unwrap().is_some());
333        assert!(store.load(&second.id).await.unwrap().is_some());
334    }
335
336    #[tokio::test]
337    async fn memory_store_save_upserts() {
338        let store = MemorySessionStore::new();
339        let mut record = sample_record("upsert");
340        store.create(&mut record).await.unwrap();
341
342        record.protocol_version = "2025-06-18".into();
343        store.save(&record).await.unwrap();
344
345        let loaded = store.load("upsert").await.unwrap().unwrap();
346        assert_eq!(loaded.protocol_version, "2025-06-18");
347    }
348
349    #[tokio::test]
350    async fn memory_store_hides_expired_records() {
351        let store = MemorySessionStore::new();
352        let mut record = SessionRecord::new("expired", "2025-11-25", Duration::from_millis(1));
353        store.create(&mut record).await.unwrap();
354
355        tokio::time::sleep(Duration::from_millis(10)).await;
356
357        assert!(store.load("expired").await.unwrap().is_none());
358    }
359
360    #[tokio::test]
361    async fn memory_store_cleanup_removes_expired() {
362        let store = MemorySessionStore::new();
363
364        let mut live = SessionRecord::new("live", "2025-11-25", Duration::from_secs(60));
365        store.create(&mut live).await.unwrap();
366
367        let mut dead = SessionRecord::new("dead", "2025-11-25", Duration::from_millis(1));
368        store.create(&mut dead).await.unwrap();
369
370        tokio::time::sleep(Duration::from_millis(10)).await;
371
372        let removed = store.cleanup_expired().await;
373        assert_eq!(removed, 1);
374        assert_eq!(store.len().await, 1);
375    }
376
377    #[tokio::test]
378    async fn memory_store_delete_is_idempotent() {
379        let store = MemorySessionStore::new();
380        store.delete("nonexistent").await.unwrap();
381    }
382
383    #[tokio::test]
384    async fn record_round_trips_client_info_and_capabilities() {
385        // Issue #786: SessionRecord must be able to carry client identity
386        // and capabilities through a save/load round trip so a session
387        // restored from another instance retains the original client's
388        // advertised metadata.
389        let store = MemorySessionStore::new();
390        let mut record = sample_record("client-meta");
391        record.client_info = Some(crate::protocol::Implementation {
392            name: "test-client".into(),
393            version: "9.9.9".into(),
394            title: Some("Test Client".into()),
395            description: None,
396            icons: None,
397            website_url: None,
398            meta: None,
399        });
400        record.client_capabilities = Some(crate::protocol::ClientCapabilities {
401            roots: Some(crate::protocol::RootsCapability {
402                list_changed: true,
403                deprecated: None,
404            }),
405            ..Default::default()
406        });
407
408        store.create(&mut record).await.unwrap();
409        let loaded = store
410            .load(&record.id)
411            .await
412            .unwrap()
413            .expect("record should survive create/load round trip");
414
415        let info = loaded
416            .client_info
417            .expect("client_info should survive round trip");
418        assert_eq!(info.name, "test-client");
419        assert_eq!(info.version, "9.9.9");
420        assert_eq!(info.title.as_deref(), Some("Test Client"));
421
422        let caps = loaded
423            .client_capabilities
424            .expect("client_capabilities should survive round trip");
425        assert_eq!(caps.roots.map(|r| r.list_changed), Some(true));
426    }
427
428    #[tokio::test]
429    async fn record_touch_updates_timestamps() {
430        let mut record = SessionRecord::new("t", "2025-11-25", Duration::from_secs(60));
431        let original_expiry = record.expires_at;
432
433        tokio::time::sleep(Duration::from_millis(10)).await;
434        record.touch(Duration::from_secs(60));
435
436        assert!(record.expires_at > original_expiry);
437    }
438
439    #[tokio::test]
440    async fn dyn_session_store_object_safe() {
441        // Compile-time check that SessionStore is object-safe.
442        let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
443        let mut record = sample_record("dyn");
444        store.create(&mut record).await.unwrap();
445        assert!(store.load(&record.id).await.unwrap().is_some());
446    }
447
448    #[tokio::test]
449    async fn caching_store_writes_to_both_tiers() {
450        let cache = MemorySessionStore::new();
451        let backend = MemorySessionStore::new();
452        let store = CachingSessionStore::new(cache.clone(), backend.clone());
453
454        let mut record = sample_record("cached");
455        store.create(&mut record).await.unwrap();
456
457        assert!(cache.load(&record.id).await.unwrap().is_some());
458        assert!(backend.load(&record.id).await.unwrap().is_some());
459    }
460
461    #[tokio::test]
462    async fn caching_store_populates_cache_on_miss() {
463        let cache = MemorySessionStore::new();
464        let backend = MemorySessionStore::new();
465
466        // Prime the backend directly; cache is empty.
467        let mut record = sample_record("warm");
468        backend.create(&mut record).await.unwrap();
469        let id = record.id.clone();
470        assert!(cache.load(&id).await.unwrap().is_none());
471
472        // A load through the caching store should populate the cache.
473        let store = CachingSessionStore::new(cache.clone(), backend);
474        let loaded = store.load(&id).await.unwrap();
475        assert!(loaded.is_some());
476        assert!(cache.load(&id).await.unwrap().is_some());
477    }
478
479    #[tokio::test]
480    async fn caching_store_delete_clears_both() {
481        let cache = MemorySessionStore::new();
482        let backend = MemorySessionStore::new();
483        let store = CachingSessionStore::new(cache.clone(), backend.clone());
484
485        let mut record = sample_record("gone");
486        store.create(&mut record).await.unwrap();
487        store.delete(&record.id).await.unwrap();
488
489        assert!(cache.load(&record.id).await.unwrap().is_none());
490        assert!(backend.load(&record.id).await.unwrap().is_none());
491    }
492}