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