jevsnes.git / packages / mcp / src / sessions.rs
1//! MCP sessions that outlive the window.
2//!
3//! A client is told about a changed tool list over a stream it holds open, and
4//! a stream belongs to a session, so the server keeps sessions. Left at that, a
5//! restarted app would not know any of its clients, and each would have to
6//! notice and start again. rmcp restores a session it does not know from a
7//! [`SessionStore`] if it is given one, so this is one: a file of who has
8//! introduced themselves, under `$XDG_STATE_HOME/jev`.
9
10use std::collections::BTreeMap;
11use std::path::PathBuf;
12use std::sync::{Mutex, PoisonError};
13use std::time::{SystemTime, UNIX_EPOCH};
14use std::{env, fs, io};
15
16use rmcp::transport::streamable_http_server::session::{
17    SessionState, SessionStore, SessionStoreError,
18};
19
20/// How many sessions are remembered. A client that goes away without saying so
21/// leaves its entry behind, so the file is kept to the most recent few rather
22/// than left to grow; one window has a handful of clients at most.
23const REMEMBERED: usize = 32;
24
25#[derive(serde::Serialize, serde::Deserialize)]
26struct Remembered {
27    /// Seconds since the epoch, to tell which are the most recent.
28    stored: u64,
29    state: SessionState,
30}
31
32pub struct FileSessionStore {
33    path: PathBuf,
34    sessions: Mutex<BTreeMap<String, Remembered>>,
35}
36
37impl FileSessionStore {
38    /// Open the store, reading what an earlier window left. A file that cannot
39    /// be read is a store with nobody in it: those clients introduce themselves
40    /// again, which is all that would have been saved.
41    pub fn open() -> Self {
42        let state_home = env::var_os("XDG_STATE_HOME")
43            .map(PathBuf::from)
44            .filter(|path| path.is_absolute())
45            .or_else(|| Some(PathBuf::from(env::var_os("HOME")?).join(".local/state")))
46            .unwrap_or_else(env::temp_dir);
47        let path = state_home.join("jev/mcp-sessions.json");
48        let sessions = fs::read(&path)
49            .ok()
50            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
51            .unwrap_or_default();
52        Self { path, sessions: Mutex::new(sessions) }
53    }
54
55    fn change(&self, change: impl FnOnce(&mut BTreeMap<String, Remembered>)) -> io::Result<()> {
56        let mut sessions = self.sessions.lock().unwrap_or_else(PoisonError::into_inner);
57        change(&mut sessions);
58        while sessions.len() > REMEMBERED {
59            let oldest = sessions
60                .iter()
61                .min_by_key(|(_, remembered)| remembered.stored)
62                .map(|(id, _)| id.clone())
63                .expect("more than REMEMBERED entries is at least one");
64            sessions.remove(&oldest);
65        }
66        if let Some(dir) = self.path.parent() {
67            fs::create_dir_all(dir)?;
68        }
69        let partial = self.path.with_extension("partial");
70        fs::write(&partial, serde_json::to_vec(&*sessions)?)?;
71        fs::rename(partial, &self.path)
72    }
73}
74
75#[async_trait::async_trait]
76impl SessionStore for FileSessionStore {
77    async fn load(&self, session_id: &str) -> Result<Option<SessionState>, SessionStoreError> {
78        let sessions = self.sessions.lock().unwrap_or_else(PoisonError::into_inner);
79        Ok(sessions.get(session_id).map(|remembered| remembered.state.clone()))
80    }
81
82    async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError> {
83        let stored = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
84        let remembered = Remembered { stored, state: state.clone() };
85        Ok(self.change(|sessions| {
86            sessions.insert(session_id.to_owned(), remembered);
87        })?)
88    }
89
90    async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError> {
91        Ok(self.change(|sessions| {
92            sessions.remove(session_id);
93        })?)
94    }
95}