Skip to content

Commit 8f696e6

Browse files
authored
feat: optional session store (resumabillity support) (#775)
* feat: optional session store * fix: docs * fix: pr review comments * fix: add non_exhaustive * fix: support for non_exhaustive StreamableHttpServerConfig * fix: add SessionState::new
1 parent f6893a7 commit 8f696e6

7 files changed

Lines changed: 912 additions & 44 deletions

File tree

crates/rmcp/Cargo.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,16 @@ required-features = [
339339
]
340340
path = "tests/test_streamable_http_stale_session.rs"
341341

342+
[[test]]
343+
name = "test_streamable_http_session_store"
344+
required-features = [
345+
"client",
346+
"server",
347+
"transport-streamable-http-client-reqwest",
348+
"transport-streamable-http-server",
349+
]
350+
path = "tests/test_streamable_http_session_store.rs"
351+
342352
[[test]]
343353
name = "test_streamable_http_connection_reuse"
344354
required-features = [
@@ -351,4 +361,3 @@ required-features = [
351361
"transport-streamable-http-client-reqwest",
352362
]
353363
path = "tests/test_streamable_http_connection_reuse.rs"
354-
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
pub mod session;
22
#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))]
33
pub mod tower;
4-
pub use session::{SessionId, SessionManager};
4+
pub use session::{RestoreOutcome, SessionId, SessionManager, SessionRestoreMarker};
55
#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))]
66
pub use tower::{StreamableHttpServerConfig, StreamableHttpService};

crates/rmcp/src/transport/streamable_http_server/session.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,41 @@ use crate::{
3030

3131
pub mod local;
3232
pub mod never;
33+
pub mod store;
34+
35+
pub use store::{SessionState, SessionStore, SessionStoreError};
36+
37+
/// Extension marker inserted into the `initialize` request extensions during a
38+
/// session restore replay. Handlers can check for its presence to distinguish a
39+
/// cross-instance restore from a genuine client-initiated `initialize` request.
40+
///
41+
/// ```rust,ignore
42+
/// if req.extensions().get::<SessionRestoreMarker>().is_some() {
43+
/// // this is a restore replay, not a fresh client connection
44+
/// }
45+
/// ```
46+
#[non_exhaustive]
47+
#[derive(Debug, Clone)]
48+
pub struct SessionRestoreMarker {
49+
pub id: SessionId,
50+
}
51+
52+
/// The outcome of a [`SessionManager::restore_session`] call.
53+
#[non_exhaustive]
54+
#[derive(Debug)]
55+
pub enum RestoreOutcome<T> {
56+
/// The session was just re-created from external state; the caller must
57+
/// spawn an MCP handler against the returned transport and replay the
58+
/// `initialize` handshake.
59+
Restored(T),
60+
/// The session was already present in memory (e.g. a concurrent request
61+
/// already restored it). The caller should proceed as if `has_session`
62+
/// had returned `true` — no further action is required.
63+
AlreadyPresent,
64+
/// This session manager does not support external-store restore.
65+
/// The caller should fall through to the normal 404 response.
66+
NotSupported,
67+
}
3368

3469
/// Controls how MCP sessions are created, validated, and closed.
3570
///
@@ -98,4 +133,22 @@ pub trait SessionManager: Send + Sync + 'static {
98133
) -> impl Future<
99134
Output = Result<impl Stream<Item = ServerSseMessage> + Send + Sync + 'static, Self::Error>,
100135
> + Send;
136+
137+
/// Attempt to restore a previously-known session from external state,
138+
/// creating a fresh in-memory session worker with the given `id`.
139+
///
140+
/// See [`RestoreOutcome`] for the three possible results:
141+
/// - [`RestoreOutcome::Restored`] — session re-created; caller must spawn
142+
/// an MCP handler and replay the `initialize` handshake.
143+
/// - [`RestoreOutcome::AlreadyPresent`] — session is already in memory
144+
/// (e.g. a concurrent request restored it first); caller proceeds
145+
/// normally.
146+
/// - [`RestoreOutcome::NotSupported`] (default) — this session manager
147+
/// does not support external-store restore; caller returns 404.
148+
fn restore_session(
149+
&self,
150+
_id: SessionId,
151+
) -> impl Future<Output = Result<RestoreOutcome<Self::Transport>, Self::Error>> + Send {
152+
futures::future::ready(Ok(RestoreOutcome::NotSupported))
153+
}
101154
}

crates/rmcp/src/transport/streamable_http_server/session/local.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,20 @@ impl SessionManager for LocalSessionManager {
136136
handle.push_message(message, None).await?;
137137
Ok(())
138138
}
139+
140+
async fn restore_session(
141+
&self,
142+
id: SessionId,
143+
) -> Result<RestoreOutcome<Self::Transport>, Self::Error> {
144+
let mut sessions = self.sessions.write().await;
145+
if sessions.contains_key(&id) {
146+
// A concurrent request already restored this session.
147+
return Ok(RestoreOutcome::AlreadyPresent);
148+
}
149+
let (handle, worker) = create_local_session(id.clone(), self.session_config.clone());
150+
sessions.insert(id, handle);
151+
Ok(RestoreOutcome::Restored(WorkerTransport::spawn(worker)))
152+
}
139153
}
140154

141155
/// `<index>/request_id>`
@@ -188,7 +202,7 @@ impl std::str::FromStr for EventId {
188202
}
189203
}
190204

191-
use super::{ServerSseMessage, SessionManager};
205+
use super::{RestoreOutcome, ServerSseMessage, SessionManager};
192206

193207
struct CachedTx {
194208
tx: Sender<ServerSseMessage>,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use crate::model::InitializeRequestParams;
2+
3+
/// State persisted to an external store for cross-instance session recovery.
4+
///
5+
/// When a client reconnects to a different server instance, the new instance
6+
/// loads this state to transparently replay the `initialize` handshake without
7+
/// the client needing to re-initialize.
8+
#[non_exhaustive]
9+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10+
pub struct SessionState {
11+
/// Parameters from the client's original `initialize` request.
12+
pub initialize_params: InitializeRequestParams,
13+
}
14+
15+
impl SessionState {
16+
pub fn new(initialize_params: InitializeRequestParams) -> Self {
17+
Self { initialize_params }
18+
}
19+
}
20+
21+
/// Type alias for boxed session store errors.
22+
pub type SessionStoreError = Box<dyn std::error::Error + Send + Sync + 'static>;
23+
24+
/// Pluggable external session store for cross-instance recovery.
25+
///
26+
/// Implement this trait to back sessions with Redis, a database, or any
27+
/// key-value store. The simplest usage is to set
28+
/// `StreamableHttpServerConfig::session_store` to an `Arc<impl SessionStore>`.
29+
///
30+
/// # Example (in-memory, for testing)
31+
///
32+
/// ```rust,ignore
33+
/// use std::{collections::HashMap, sync::Arc};
34+
/// use tokio::sync::RwLock;
35+
/// use rmcp::transport::streamable_http_server::session::store::{
36+
/// SessionState, SessionStore, SessionStoreError,
37+
/// };
38+
///
39+
/// #[derive(Default)]
40+
/// struct InMemoryStore(Arc<RwLock<HashMap<String, SessionState>>>);
41+
///
42+
/// #[async_trait::async_trait]
43+
/// impl SessionStore for InMemoryStore {
44+
/// async fn load(&self, id: &str) -> Result<Option<SessionState>, SessionStoreError> {
45+
/// Ok(self.0.read().await.get(id).cloned())
46+
/// }
47+
/// async fn store(&self, id: &str, state: &SessionState) -> Result<(), SessionStoreError> {
48+
/// self.0.write().await.insert(id.to_owned(), state.clone());
49+
/// Ok(())
50+
/// }
51+
/// async fn delete(&self, id: &str) -> Result<(), SessionStoreError> {
52+
/// self.0.write().await.remove(id);
53+
/// Ok(())
54+
/// }
55+
/// }
56+
/// ```
57+
#[async_trait::async_trait]
58+
pub trait SessionStore: Send + Sync + 'static {
59+
/// Load session state for the given `session_id`.
60+
///
61+
/// Returns `Ok(None)` when no entry exists (i.e. session is unknown to the store).
62+
async fn load(&self, session_id: &str) -> Result<Option<SessionState>, SessionStoreError>;
63+
64+
/// Persist session state for the given `session_id`.
65+
async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError>;
66+
67+
/// Remove session state for the given `session_id`.
68+
async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError>;
69+
}

0 commit comments

Comments
 (0)