Skip to main content

tower_mcp/
session.rs

1//! MCP session state management
2//!
3//! Tracks the lifecycle state of an MCP connection as per the specification.
4//! The session progresses through phases: Uninitialized -> Initializing -> Initialized.
5//!
6//! Sessions also support type-safe extensions for storing arbitrary data like
7//! authentication claims, user roles, or other session-scoped state.
8
9use std::sync::Arc;
10use std::sync::RwLock;
11use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
12
13use crate::router::Extensions;
14
15/// Session lifecycle phase
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[repr(u8)]
18#[non_exhaustive]
19pub enum SessionPhase {
20    /// Initial state - only `initialize` and `ping` requests are valid
21    Uninitialized = 0,
22    /// Server has responded to `initialize`, waiting for `initialized` notification
23    Initializing = 1,
24    /// `initialized` notification received, normal operation
25    Initialized = 2,
26}
27
28impl From<u8> for SessionPhase {
29    fn from(value: u8) -> Self {
30        match value {
31            0 => SessionPhase::Uninitialized,
32            1 => SessionPhase::Initializing,
33            2 => SessionPhase::Initialized,
34            _ => SessionPhase::Uninitialized,
35        }
36    }
37}
38
39/// Shared session state that can be cloned across requests.
40///
41/// Uses atomic operations for thread-safe state transitions. Includes a type-safe
42/// extensions map for storing session-scoped data like authentication claims.
43///
44/// # Example
45///
46/// ```rust
47/// use tower_mcp::SessionState;
48///
49/// #[derive(Debug, Clone)]
50/// struct UserClaims {
51///     user_id: String,
52///     role: String,
53/// }
54///
55/// let session = SessionState::new();
56///
57/// // Store auth claims in the session
58/// session.insert(UserClaims {
59///     user_id: "user123".to_string(),
60///     role: "admin".to_string(),
61/// });
62///
63/// // Retrieve claims later
64/// if let Some(claims) = session.get::<UserClaims>() {
65///     assert_eq!(claims.role, "admin");
66/// }
67/// ```
68#[derive(Clone)]
69pub struct SessionState {
70    phase: Arc<AtomicU8>,
71    handshake_started: Arc<AtomicBool>,
72    extensions: Arc<RwLock<Extensions>>,
73}
74
75impl Default for SessionState {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl SessionState {
82    /// Create a new session in the Uninitialized phase
83    pub fn new() -> Self {
84        Self {
85            phase: Arc::new(AtomicU8::new(SessionPhase::Uninitialized as u8)),
86            handshake_started: Arc::new(AtomicBool::new(false)),
87            extensions: Arc::new(RwLock::new(Extensions::new())),
88        }
89    }
90
91    /// Insert a value into the session extensions.
92    ///
93    /// This is typically used by auth middleware to store claims that can
94    /// be checked by capability filters.
95    ///
96    /// # Example
97    ///
98    /// ```rust
99    /// use tower_mcp::SessionState;
100    ///
101    /// let session = SessionState::new();
102    /// session.insert(42u32);
103    /// assert_eq!(session.get::<u32>(), Some(42));
104    /// ```
105    pub fn insert<T: Send + Sync + Clone + 'static>(&self, val: T) {
106        if let Ok(mut ext) = self.extensions.write() {
107            ext.insert(val);
108        }
109    }
110
111    /// Get a cloned value from the session extensions.
112    ///
113    /// Returns `None` if no value of the given type has been inserted or if
114    /// the lock cannot be acquired.
115    ///
116    /// # Example
117    ///
118    /// ```rust
119    /// use tower_mcp::SessionState;
120    ///
121    /// let session = SessionState::new();
122    /// session.insert("hello".to_string());
123    /// assert_eq!(session.get::<String>(), Some("hello".to_string()));
124    /// assert_eq!(session.get::<u32>(), None);
125    /// ```
126    pub fn get<T: Send + Sync + Clone + 'static>(&self) -> Option<T> {
127        self.extensions
128            .read()
129            .ok()
130            .and_then(|ext| ext.get::<T>().cloned())
131    }
132
133    /// Get the current session phase
134    pub fn phase(&self) -> SessionPhase {
135        SessionPhase::from(self.phase.load(Ordering::Acquire))
136    }
137
138    /// Check if the session is initialized (operation phase)
139    pub fn is_initialized(&self) -> bool {
140        self.phase() == SessionPhase::Initialized
141    }
142
143    /// Record that an `initialize` request has been received for this session.
144    ///
145    /// Transports that create a session *in response to* an `initialize` frame
146    /// call this at the front door, before the request is dispatched. That is
147    /// what lets [`mark_initialized`](Self::mark_initialized) tell the #458
148    /// race (the `initialized` notification overtook a dispatch that is still
149    /// running) apart from a client that never sent `initialize` at all.
150    ///
151    /// [`mark_initializing`](Self::mark_initializing) also sets this, so a
152    /// transport that dispatches `initialize` in frame order does not need to
153    /// call it. Idempotent, and never cleared.
154    pub fn mark_handshake_started(&self) {
155        self.handshake_started.store(true, Ordering::Release);
156    }
157
158    /// Whether an `initialize` request has been received for this session.
159    ///
160    /// False for a fresh session, and for one a client is trying to open with
161    /// an unsolicited `initialized` notification.
162    pub fn handshake_started(&self) -> bool {
163        self.handshake_started.load(Ordering::Acquire)
164    }
165
166    /// Transition from Uninitialized to Initializing.
167    /// Called after responding to an `initialize` request.
168    /// Returns true if the transition was successful.
169    ///
170    /// Also records that the handshake has started, so the notification that
171    /// follows can complete it.
172    pub fn mark_initializing(&self) -> bool {
173        self.mark_handshake_started();
174        self.phase
175            .compare_exchange(
176                SessionPhase::Uninitialized as u8,
177                SessionPhase::Initializing as u8,
178                Ordering::AcqRel,
179                Ordering::Acquire,
180            )
181            .is_ok()
182    }
183
184    /// Transition to Initialized phase.
185    /// Called when receiving an `initialized` notification.
186    ///
187    /// Accepts `Initializing → Initialized`, and `Uninitialized → Initialized`
188    /// only once [`mark_handshake_started`](Self::mark_handshake_started) has
189    /// run. The latter path handles a race in HTTP transports where the client
190    /// sends the `initialized` notification before the server has finished
191    /// processing the `initialize` request (#458); the handshake flag is what
192    /// keeps it from also accepting a client that skipped `initialize`, which
193    /// would leave the server serving a peer whose protocol version and
194    /// capabilities it never learned.
195    ///
196    /// Transports that serve without a handshake by design (restored sessions,
197    /// `optional_sessions`, the stateless 2026-07-28 path) want
198    /// [`mark_preinitialized`](Self::mark_preinitialized) instead.
199    ///
200    /// Returns true if the transition was successful.
201    pub fn mark_initialized(&self) -> bool {
202        // Try the expected path first: Initializing → Initialized
203        if self
204            .phase
205            .compare_exchange(
206                SessionPhase::Initializing as u8,
207                SessionPhase::Initialized as u8,
208                Ordering::AcqRel,
209                Ordering::Acquire,
210            )
211            .is_ok()
212        {
213            return true;
214        }
215
216        // Handle the race: Uninitialized → Initialized, but only for a session
217        // that has actually seen an `initialize` request. Without that check a
218        // single unsolicited notification opens the whole surface.
219        if !self.handshake_started() {
220            return false;
221        }
222
223        self.phase
224            .compare_exchange(
225                SessionPhase::Uninitialized as u8,
226                SessionPhase::Initialized as u8,
227                Ordering::AcqRel,
228                Ordering::Acquire,
229            )
230            .is_ok()
231    }
232
233    /// Move the session straight to `Initialized`, no handshake required.
234    ///
235    /// For transports that serve requests without an `initialize` exchange
236    /// because the protocol or the deployment says they should: a session
237    /// restored from a store (it was initialized on another instance), the
238    /// `optional_sessions` opt-in for clients that do not carry a session ID
239    /// forward, and the stateless 2026-07-28 path, which has no handshake at
240    /// all.
241    ///
242    /// This is a server-side decision, unlike
243    /// [`mark_initialized`](Self::mark_initialized), which acts on a frame the
244    /// client sent. Returns true if the phase changed.
245    pub fn mark_preinitialized(&self) -> bool {
246        self.mark_handshake_started();
247        self.phase
248            .swap(SessionPhase::Initialized as u8, Ordering::AcqRel)
249            != SessionPhase::Initialized as u8
250    }
251
252    /// Check if a request method is allowed in the current phase.
253    /// Per spec:
254    /// - Before initialization: only `initialize` and `ping` are valid
255    /// - During all phases: `ping` is always valid
256    pub fn is_request_allowed(&self, method: &str) -> bool {
257        match self.phase() {
258            SessionPhase::Uninitialized => {
259                // server/discover (SEP-1442) is allowed before initialization
260                matches!(method, "initialize" | "ping" | "server/discover")
261            }
262            SessionPhase::Initializing | SessionPhase::Initialized => true,
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use proptest::prelude::*;
271
272    #[derive(Clone, Debug)]
273    enum LifecycleOperation {
274        MarkInitializing,
275        MarkInitialized,
276        MarkHandshakeStarted,
277        MarkPreinitialized,
278        CheckRequest(String),
279    }
280
281    fn lifecycle_operation() -> impl Strategy<Value = LifecycleOperation> {
282        prop_oneof![
283            Just(LifecycleOperation::MarkInitializing),
284            Just(LifecycleOperation::MarkInitialized),
285            Just(LifecycleOperation::MarkHandshakeStarted),
286            Just(LifecycleOperation::MarkPreinitialized),
287            prop_oneof![
288                Just("initialize".to_string()),
289                Just("ping".to_string()),
290                Just("server/discover".to_string()),
291                Just("tools/list".to_string()),
292                "[a-z/_.-]{0,64}",
293            ]
294            .prop_map(LifecycleOperation::CheckRequest),
295        ]
296    }
297
298    proptest! {
299        #![proptest_config(ProptestConfig::with_cases(512))]
300
301        /// Model random lifecycle sequences and assert that the atomic state
302        /// machine never permits an illegal transition or pre-init request.
303        #[test]
304        fn lifecycle_matches_model(
305            operations in prop::collection::vec(lifecycle_operation(), 0..256)
306        ) {
307            let session = SessionState::new();
308            let mut expected_phase = SessionPhase::Uninitialized;
309            let mut expected_handshake = false;
310
311            for operation in operations {
312                match operation {
313                    LifecycleOperation::MarkInitializing => {
314                        let expected_success = expected_phase == SessionPhase::Uninitialized;
315                        prop_assert_eq!(session.mark_initializing(), expected_success);
316                        expected_handshake = true;
317                        if expected_success {
318                            expected_phase = SessionPhase::Initializing;
319                        }
320                    }
321                    LifecycleOperation::MarkInitialized => {
322                        // Uninitialized only advances once the handshake has
323                        // been recorded; Initializing always does.
324                        let expected_success = match expected_phase {
325                            SessionPhase::Initializing => true,
326                            SessionPhase::Uninitialized => expected_handshake,
327                            SessionPhase::Initialized => false,
328                        };
329                        prop_assert_eq!(session.mark_initialized(), expected_success);
330                        if expected_success {
331                            expected_phase = SessionPhase::Initialized;
332                        }
333                    }
334                    LifecycleOperation::MarkHandshakeStarted => {
335                        session.mark_handshake_started();
336                        expected_handshake = true;
337                    }
338                    LifecycleOperation::MarkPreinitialized => {
339                        let expected_success = expected_phase != SessionPhase::Initialized;
340                        prop_assert_eq!(session.mark_preinitialized(), expected_success);
341                        expected_handshake = true;
342                        expected_phase = SessionPhase::Initialized;
343                    }
344                    LifecycleOperation::CheckRequest(method) => {
345                        let expected_allowed = expected_phase != SessionPhase::Uninitialized
346                            || matches!(method.as_str(), "initialize" | "ping" | "server/discover");
347                        prop_assert_eq!(
348                            session.is_request_allowed(&method),
349                            expected_allowed,
350                            "phase={:?}, method={:?}",
351                            expected_phase,
352                            method
353                        );
354                    }
355                }
356                prop_assert_eq!(session.phase(), expected_phase);
357                prop_assert_eq!(session.handshake_started(), expected_handshake);
358                prop_assert_eq!(
359                    session.is_initialized(),
360                    expected_phase == SessionPhase::Initialized
361                );
362                // The invariant the phase is a proxy for: a session can only
363                // be serving if an initialize was seen or the server waived it.
364                prop_assert!(expected_phase != SessionPhase::Initialized || expected_handshake);
365            }
366        }
367    }
368
369    #[test]
370    fn test_session_lifecycle() {
371        let session = SessionState::new();
372
373        // Initial state
374        assert_eq!(session.phase(), SessionPhase::Uninitialized);
375        assert!(!session.is_initialized());
376
377        // Only initialize and ping allowed
378        assert!(session.is_request_allowed("initialize"));
379        assert!(session.is_request_allowed("ping"));
380        assert!(!session.is_request_allowed("tools/list"));
381
382        // Transition to initializing
383        assert!(session.mark_initializing());
384        assert_eq!(session.phase(), SessionPhase::Initializing);
385        assert!(!session.is_initialized());
386
387        // Can't mark initializing again
388        assert!(!session.mark_initializing());
389
390        // All requests allowed during initializing
391        assert!(session.is_request_allowed("tools/list"));
392
393        // Transition to initialized
394        assert!(session.mark_initialized());
395        assert_eq!(session.phase(), SessionPhase::Initialized);
396        assert!(session.is_initialized());
397
398        // Can't mark initialized again
399        assert!(!session.mark_initialized());
400    }
401
402    #[test]
403    fn test_session_clone_shares_state() {
404        let session1 = SessionState::new();
405        let session2 = session1.clone();
406
407        session1.mark_initializing();
408        assert_eq!(session2.phase(), SessionPhase::Initializing);
409
410        session2.mark_initialized();
411        assert_eq!(session1.phase(), SessionPhase::Initialized);
412    }
413
414    #[test]
415    fn test_session_extensions_insert_and_get() {
416        let session = SessionState::new();
417
418        // Insert and retrieve a value
419        session.insert(42u32);
420        assert_eq!(session.get::<u32>(), Some(42));
421
422        // Different type returns None
423        assert_eq!(session.get::<String>(), None);
424    }
425
426    #[test]
427    fn test_session_extensions_overwrite() {
428        let session = SessionState::new();
429
430        session.insert(42u32);
431        assert_eq!(session.get::<u32>(), Some(42));
432
433        // Overwrite with new value
434        session.insert(100u32);
435        assert_eq!(session.get::<u32>(), Some(100));
436    }
437
438    #[test]
439    fn test_session_extensions_multiple_types() {
440        let session = SessionState::new();
441
442        session.insert(42u32);
443        session.insert("hello".to_string());
444        session.insert(true);
445
446        assert_eq!(session.get::<u32>(), Some(42));
447        assert_eq!(session.get::<String>(), Some("hello".to_string()));
448        assert_eq!(session.get::<bool>(), Some(true));
449    }
450
451    #[test]
452    fn test_session_extensions_shared_across_clones() {
453        let session1 = SessionState::new();
454        let session2 = session1.clone();
455
456        // Insert in one clone
457        session1.insert(42u32);
458
459        // Should be visible in the other
460        assert_eq!(session2.get::<u32>(), Some(42));
461
462        // Insert in the second clone
463        session2.insert("world".to_string());
464
465        // Should be visible in the first
466        assert_eq!(session1.get::<String>(), Some("world".to_string()));
467    }
468
469    /// The #458 race: the `initialized` notification arrives before the
470    /// `initialize` request has finished dispatching, so the phase is still
471    /// Uninitialized. The transport recorded the handshake when it created the
472    /// session, so the notification still completes it.
473    #[test]
474    fn test_mark_initialized_from_uninitialized_after_handshake_started() {
475        let session = SessionState::new();
476        session.mark_handshake_started();
477
478        assert_eq!(session.phase(), SessionPhase::Uninitialized);
479        assert!(session.mark_initialized());
480        assert_eq!(session.phase(), SessionPhase::Initialized);
481        assert!(session.is_initialized());
482
483        // All requests allowed
484        assert!(session.is_request_allowed("tools/list"));
485        assert!(session.is_request_allowed("ping"));
486    }
487
488    /// #1269: without a handshake there is nothing to complete. A client that
489    /// sends only the notification has negotiated no protocol version and
490    /// declared no capabilities, so the guard must hold.
491    #[test]
492    fn test_mark_initialized_from_uninitialized_without_handshake_is_refused() {
493        let session = SessionState::new();
494
495        assert!(!session.handshake_started());
496        assert!(!session.mark_initialized());
497        assert_eq!(session.phase(), SessionPhase::Uninitialized);
498        assert!(!session.is_initialized());
499
500        // The pre-initialize guard still applies.
501        assert!(!session.is_request_allowed("tools/list"));
502        assert!(session.is_request_allowed("initialize"));
503        assert!(session.is_request_allowed("ping"));
504
505        // And a repeat does not wear it down.
506        assert!(!session.mark_initialized());
507        assert!(!session.mark_initialized());
508        assert_eq!(session.phase(), SessionPhase::Uninitialized);
509    }
510
511    /// A refused notification must not poison the real handshake that follows.
512    #[test]
513    fn test_handshake_still_works_after_a_refused_notification() {
514        let session = SessionState::new();
515
516        assert!(!session.mark_initialized());
517        assert!(session.mark_initializing());
518        assert_eq!(session.phase(), SessionPhase::Initializing);
519        assert!(session.mark_initialized());
520        assert!(session.is_initialized());
521    }
522
523    /// Server-side promotion needs no handshake, and works from either phase.
524    #[test]
525    fn test_mark_preinitialized_skips_the_handshake() {
526        let session = SessionState::new();
527        assert!(session.mark_preinitialized());
528        assert_eq!(session.phase(), SessionPhase::Initialized);
529        assert!(session.handshake_started());
530        assert!(session.is_request_allowed("tools/list"));
531
532        // Already there: no change to report.
533        assert!(!session.mark_preinitialized());
534
535        // From Initializing as well.
536        let mid = SessionState::new();
537        mid.mark_initializing();
538        assert!(mid.mark_preinitialized());
539        assert_eq!(mid.phase(), SessionPhase::Initialized);
540    }
541
542    #[test]
543    fn test_handshake_flag_is_shared_across_clones() {
544        let session1 = SessionState::new();
545        let session2 = session1.clone();
546
547        assert!(!session2.handshake_started());
548        session1.mark_handshake_started();
549        assert!(session2.handshake_started());
550
551        // Which means the clone can absorb the race too.
552        assert!(session2.mark_initialized());
553        assert!(session1.is_initialized());
554    }
555
556    #[test]
557    fn test_mark_initialized_idempotent_when_already_initialized() {
558        let session = SessionState::new();
559
560        // Normal lifecycle
561        session.mark_initializing();
562        session.mark_initialized();
563        assert_eq!(session.phase(), SessionPhase::Initialized);
564
565        // Calling mark_initialized again should fail (already in target state)
566        assert!(!session.mark_initialized());
567        assert_eq!(session.phase(), SessionPhase::Initialized);
568    }
569
570    #[test]
571    fn test_session_extensions_custom_type() {
572        #[derive(Debug, Clone, PartialEq)]
573        struct UserClaims {
574            user_id: String,
575            role: String,
576        }
577
578        let session = SessionState::new();
579
580        session.insert(UserClaims {
581            user_id: "user123".to_string(),
582            role: "admin".to_string(),
583        });
584
585        let claims = session.get::<UserClaims>();
586        assert!(claims.is_some());
587        let claims = claims.unwrap();
588        assert_eq!(claims.user_id, "user123");
589        assert_eq!(claims.role, "admin");
590    }
591}