Skip to main content

mcp_execution_server/
state.rs

1//! State management for pending generation sessions.
2//!
3//! The `StateManager` stores temporary session data between `introspect_server`
4//! and `save_categorized_tools` calls. Sessions expire after 30 minutes and
5//! are cleaned up lazily on each operation.
6
7use crate::clock::{Clock, SystemClock};
8use crate::types::PendingGeneration;
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::sync::RwLock;
12use uuid::Uuid;
13
14/// State manager for pending generation sessions.
15///
16/// Uses an in-memory `HashMap` protected by `RwLock` for thread-safe access.
17/// Sessions expire after 30 minutes and are cleaned up lazily.
18///
19/// # Examples
20///
21/// ```
22/// use mcp_execution_server::state::StateManager;
23/// use mcp_execution_server::types::PendingGeneration;
24/// use mcp_execution_server::clock::SystemClock;
25/// use mcp_execution_core::{ServerId, ServerConfig};
26/// use mcp_execution_introspector::ServerInfo;
27/// use std::path::PathBuf;
28///
29/// # async fn example() {
30/// let state = StateManager::new();
31///
32/// # let server_info = ServerInfo {
33/// #     id: ServerId::new("test"),
34/// #     name: "Test".to_string(),
35/// #     version: "1.0.0".to_string(),
36/// #     capabilities: mcp_execution_introspector::ServerCapabilities {
37/// #         supports_tools: true,
38/// #         supports_resources: false,
39/// #         supports_prompts: false,
40/// #     },
41/// #     tools: vec![],
42/// # };
43/// let pending = PendingGeneration::new(
44///     ServerId::new("github"),
45///     server_info,
46///     ServerConfig::builder().command("npx".to_string()).build(),
47///     PathBuf::from("/tmp/output"),
48///     &SystemClock,
49/// );
50///
51/// // Store and get session ID
52/// let session_id = state.store(pending).await;
53///
54/// // Retrieve session data
55/// let retrieved = state.take(session_id).await;
56/// assert!(retrieved.is_some());
57/// # }
58/// ```
59#[derive(Debug)]
60pub struct StateManager {
61    pending: Arc<RwLock<HashMap<Uuid, PendingGeneration>>>,
62    clock: Arc<dyn Clock>,
63}
64
65impl Default for StateManager {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl StateManager {
72    /// Creates a new state manager using the real system clock.
73    #[must_use]
74    pub fn new() -> Self {
75        Self::with_clock(Arc::new(SystemClock))
76    }
77
78    /// Creates a new state manager backed by a custom clock.
79    ///
80    /// Used in tests to inject a fake clock so session expiry can be
81    /// exercised deterministically.
82    #[must_use]
83    pub(crate) fn with_clock(clock: Arc<dyn Clock>) -> Self {
84        Self {
85            pending: Arc::new(RwLock::new(HashMap::new())),
86            clock,
87        }
88    }
89
90    /// Stores a pending generation and returns a session ID.
91    ///
92    /// This operation also performs lazy cleanup of expired sessions.
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use mcp_execution_server::state::StateManager;
98    /// # use mcp_execution_server::types::PendingGeneration;
99    /// # use mcp_execution_core::{ServerId, ServerConfig};
100    /// # use mcp_execution_introspector::ServerInfo;
101    /// # use std::path::PathBuf;
102    ///
103    /// # async fn example(pending: PendingGeneration) {
104    /// let state = StateManager::new();
105    /// let session_id = state.store(pending).await;
106    /// # }
107    /// ```
108    pub async fn store(&self, generation: PendingGeneration) -> Uuid {
109        let session_id = Uuid::new_v4();
110        let mut pending = self.pending.write().await;
111
112        // Clean up expired sessions
113        let clock = self.clock.as_ref();
114        pending.retain(|_, g| !g.is_expired(clock));
115
116        pending.insert(session_id, generation);
117        session_id
118    }
119
120    /// Retrieves and removes a pending generation.
121    ///
122    /// Returns `None` if the session is not found or has expired.
123    /// This operation also performs lazy cleanup of expired sessions.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use mcp_execution_server::state::StateManager;
129    /// # use mcp_execution_server::types::PendingGeneration;
130    /// # use mcp_execution_core::{ServerId, ServerConfig};
131    /// # use mcp_execution_introspector::ServerInfo;
132    /// # use std::path::PathBuf;
133    ///
134    /// # async fn example(pending: PendingGeneration) {
135    /// let state = StateManager::new();
136    /// let session_id = state.store(pending).await;
137    ///
138    /// let retrieved = state.take(session_id).await;
139    /// assert!(retrieved.is_some());
140    ///
141    /// // Second take returns None (already removed)
142    /// let second = state.take(session_id).await;
143    /// assert!(second.is_none());
144    /// # }
145    /// ```
146    pub async fn take(&self, session_id: Uuid) -> Option<PendingGeneration> {
147        let generation = {
148            let mut pending = self.pending.write().await;
149
150            // Clean up expired sessions
151            let clock = self.clock.as_ref();
152            pending.retain(|_, g| !g.is_expired(clock));
153
154            pending.remove(&session_id)?
155        };
156
157        // Verify not expired (lock already released)
158        if generation.is_expired(self.clock.as_ref()) {
159            return None;
160        }
161
162        Some(generation)
163    }
164
165    /// Gets a pending generation without removing it.
166    ///
167    /// Returns `None` if the session is not found or has expired.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use mcp_execution_server::state::StateManager;
173    /// # use mcp_execution_server::types::PendingGeneration;
174    /// # use mcp_execution_core::{ServerId, ServerConfig};
175    /// # use mcp_execution_introspector::ServerInfo;
176    /// # use std::path::PathBuf;
177    ///
178    /// # async fn example(pending: PendingGeneration) {
179    /// let state = StateManager::new();
180    /// let session_id = state.store(pending).await;
181    ///
182    /// // Get without removing
183    /// let peeked = state.get(session_id).await;
184    /// assert!(peeked.is_some());
185    ///
186    /// // Still available
187    /// let peeked_again = state.get(session_id).await;
188    /// assert!(peeked_again.is_some());
189    /// # }
190    /// ```
191    pub async fn get(&self, session_id: Uuid) -> Option<PendingGeneration> {
192        let pending = self.pending.read().await;
193        let clock = self.clock.as_ref();
194        pending
195            .get(&session_id)
196            .filter(|g| !g.is_expired(clock))
197            .cloned()
198    }
199
200    /// Returns the current pending session count (excluding expired).
201    ///
202    /// # Examples
203    ///
204    /// ```
205    /// use mcp_execution_server::state::StateManager;
206    ///
207    /// # async fn example() {
208    /// let state = StateManager::new();
209    /// assert_eq!(state.pending_count().await, 0);
210    /// # }
211    /// ```
212    pub async fn pending_count(&self) -> usize {
213        let pending = self.pending.read().await;
214        let clock = self.clock.as_ref();
215        pending.values().filter(|g| !g.is_expired(clock)).count()
216    }
217
218    /// Cleans up all expired sessions.
219    ///
220    /// Returns the number of sessions that were removed.
221    ///
222    /// # Examples
223    ///
224    /// ```
225    /// use mcp_execution_server::state::StateManager;
226    ///
227    /// # async fn example() {
228    /// let state = StateManager::new();
229    /// let removed = state.cleanup_expired().await;
230    /// assert_eq!(removed, 0);
231    /// # }
232    /// ```
233    pub async fn cleanup_expired(&self) -> usize {
234        let mut pending = self.pending.write().await;
235        let before = pending.len();
236        let clock = self.clock.as_ref();
237        pending.retain(|_, g| !g.is_expired(clock));
238        before - pending.len()
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::clock::{SystemClock, TestClock};
246    use crate::types::PendingGeneration;
247    use mcp_execution_core::{ServerConfig, ServerId, ToolName};
248    use mcp_execution_introspector::ServerInfo;
249    use std::path::PathBuf;
250
251    fn create_test_pending() -> PendingGeneration {
252        create_test_pending_with_clock(&SystemClock)
253    }
254
255    fn create_test_pending_with_clock(clock: &dyn Clock) -> PendingGeneration {
256        use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
257
258        let server_id = ServerId::new("test");
259        let server_info = ServerInfo {
260            id: server_id.clone(),
261            name: "Test Server".to_string(),
262            version: "1.0.0".to_string(),
263            capabilities: ServerCapabilities {
264                supports_tools: true,
265                supports_resources: false,
266                supports_prompts: false,
267            },
268            tools: vec![ToolInfo {
269                name: ToolName::new("test_tool"),
270                description: "Test tool".to_string(),
271                input_schema: serde_json::json!({}),
272                output_schema: None,
273            }],
274        };
275        let config = ServerConfig::builder().command("echo".to_string()).build();
276        let output_dir = PathBuf::from("/tmp/test");
277
278        PendingGeneration::new(server_id, server_info, config, output_dir, clock)
279    }
280
281    /// Builds an already-expired pending generation by constructing it with a
282    /// clock fixed an hour in the past, instead of rewinding `expires_at`
283    /// after construction.
284    fn create_expired_pending() -> PendingGeneration {
285        let past_clock = TestClock::new(chrono::Utc::now() - chrono::Duration::hours(1));
286        create_test_pending_with_clock(&past_clock)
287    }
288
289    #[tokio::test]
290    async fn test_store_and_retrieve() {
291        let state = StateManager::new();
292        let pending = create_test_pending();
293
294        let session_id = state.store(pending.clone()).await;
295        let retrieved = state.take(session_id).await;
296
297        assert!(retrieved.is_some());
298        let retrieved = retrieved.unwrap();
299        assert_eq!(retrieved.server_id, pending.server_id);
300    }
301
302    #[tokio::test]
303    async fn test_take_removes_session() {
304        let state = StateManager::new();
305        let pending = create_test_pending();
306
307        let session_id = state.store(pending).await;
308
309        // First take succeeds
310        let first = state.take(session_id).await;
311        assert!(first.is_some());
312
313        // Second take returns None
314        let second = state.take(session_id).await;
315        assert!(second.is_none());
316    }
317
318    #[tokio::test]
319    async fn test_get_does_not_remove() {
320        let state = StateManager::new();
321        let pending = create_test_pending();
322
323        let session_id = state.store(pending).await;
324
325        // Get multiple times
326        let first = state.get(session_id).await;
327        assert!(first.is_some());
328
329        let second = state.get(session_id).await;
330        assert!(second.is_some());
331
332        // Still available for take
333        let taken = state.take(session_id).await;
334        assert!(taken.is_some());
335    }
336
337    #[tokio::test]
338    async fn test_expired_session() {
339        let state = StateManager::new();
340        let pending = create_expired_pending();
341
342        let session_id = state.store(pending).await;
343
344        // Should return None because expired
345        let retrieved = state.take(session_id).await;
346        assert!(retrieved.is_none());
347    }
348
349    #[tokio::test]
350    async fn test_pending_count() {
351        let state = StateManager::new();
352
353        assert_eq!(state.pending_count().await, 0);
354
355        let session_id = state.store(create_test_pending()).await;
356        assert_eq!(state.pending_count().await, 1);
357
358        state.take(session_id).await;
359        assert_eq!(state.pending_count().await, 0);
360    }
361
362    #[tokio::test]
363    async fn test_cleanup_expired() {
364        let state = StateManager::new();
365
366        // Add valid session
367        state.store(create_test_pending()).await;
368
369        // Add expired session
370        state.store(create_expired_pending()).await;
371
372        assert_eq!(state.pending_count().await, 1); // Only valid session counts
373
374        let removed = state.cleanup_expired().await;
375        assert_eq!(removed, 1); // One expired session removed
376    }
377
378    /// Proves `StateManager` consults the clock it was constructed with (not a
379    /// hardcoded `SystemClock`): a session created and stored while the shared
380    /// clock is fresh must flip to expired across `get`/`pending_count`/
381    /// `cleanup_expired`/`take` once that same clock is moved past the TTL —
382    /// real wall-clock time barely advances during the test, so this would fail
383    /// if any of those call sites silently used `SystemClock` instead of
384    /// `self.clock`.
385    #[tokio::test]
386    async fn test_shared_clock_drives_expiry() {
387        let start = chrono::Utc::now();
388        let clock = Arc::new(TestClock::new(start));
389        let state = StateManager::with_clock(Arc::clone(&clock) as Arc<dyn Clock>);
390
391        let pending = create_test_pending_with_clock(clock.as_ref());
392        let session_id = state.store(pending).await;
393
394        // Fresh session is visible while the clock is still within the TTL window.
395        assert!(state.get(session_id).await.is_some());
396        assert_eq!(state.pending_count().await, 1);
397
398        // Jump the shared clock straight past the 30-minute boundary.
399        clock.set(
400            start
401                + chrono::Duration::minutes(PendingGeneration::DEFAULT_TIMEOUT_MINUTES)
402                + chrono::Duration::seconds(1),
403        );
404
405        assert!(
406            state.get(session_id).await.is_none(),
407            "expiry should track the injected clock, not Utc::now()"
408        );
409        assert_eq!(state.pending_count().await, 0);
410        assert_eq!(state.cleanup_expired().await, 1);
411        assert!(state.take(session_id).await.is_none());
412    }
413
414    #[tokio::test]
415    async fn test_concurrent_access() {
416        let state = Arc::new(StateManager::new());
417        let mut handles = vec![];
418
419        // Spawn 10 concurrent store operations
420        for i in 0..10 {
421            let state_clone = Arc::clone(&state);
422            handles.push(tokio::spawn(async move {
423                let mut pending = create_test_pending();
424                pending.server_id = ServerId::new(&format!("server-{i}"));
425                state_clone.store(pending).await
426            }));
427        }
428
429        // Wait for all operations to complete
430        for handle in handles {
431            handle.await.unwrap();
432        }
433
434        assert_eq!(state.pending_count().await, 10);
435    }
436
437    #[tokio::test]
438    async fn test_lazy_cleanup_on_store() {
439        let state = StateManager::new();
440
441        // Store expired session directly
442        {
443            let mut pending = state.pending.write().await;
444            pending.insert(Uuid::new_v4(), create_expired_pending());
445        }
446
447        // Store new session triggers cleanup
448        state.store(create_test_pending()).await;
449
450        // Only the new session should remain
451        assert_eq!(state.pending_count().await, 1);
452    }
453}