Skip to content

Commit af601b5

Browse files
hashemixSVilgelm
andauthored
feat(session): make session store injectable with bounded default (#167)
* feat(session): bound the in-memory session store Add a max-session cap (default 10k) and optional idle TTL with lazy eviction. New sessions past the cap are rejected with 503, preventing memory exhaustion via repeated initialize requests. Assisted-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Sergey Vilgelm <sergey@vilgelm.com> * Support pluggable SessionStore in servers Add optional session_store to Actix and Axum options Default to InMemorySessionStore when None Replace Axum max_sessions/session_idle_ttl with session_store Update server init and tests to use provided store --------- Signed-off-by: Sergey Vilgelm <sergey@vilgelm.com> Co-authored-by: Sergey Vilgelm <sergey@vilgelm.com>
1 parent 352a5fd commit af601b5

7 files changed

Lines changed: 176 additions & 18 deletions

File tree

crates/rust-mcp-actix/src/options.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use rust_mcp_sdk::mcp_http::{
99
DEFAULT_STREAMABLE_HTTP_ENDPOINT,
1010
};
1111
use rust_mcp_sdk::schema::schema_utils::{ClientMessage, ServerMessage};
12+
use rust_mcp_sdk::session_store::SessionStore;
1213
use rust_mcp_sdk::task_store::{ClientTaskStore, ServerTaskStore};
1314
use rust_mcp_sdk::McpObserver;
1415
use rust_mcp_sdk::SessionId;
@@ -66,6 +67,11 @@ pub struct ActixServerOptions {
6667
/// `allowed_origins` are configured, `allowed_hosts` is auto-derived from
6768
/// `host:port` unless the bind address is a wildcard.
6869
pub dns_rebinding: DnsRebindingOptions,
70+
/// Optional session store implementation. Defaults to a bounded
71+
/// `InMemorySessionStore` (10k max sessions, no idle TTL) when `None`.
72+
/// Pass your own [`SessionStore`] implementation to use Redis, custom
73+
/// limits, or any other session backend.
74+
pub session_store: Option<Arc<dyn SessionStore>>,
6975
/// Enable TLS/SSL (requires `ssl` feature, default: false)
7076
pub enable_ssl: bool,
7177
/// Path to TLS certificate PEM file
@@ -188,6 +194,7 @@ impl Default for ActixServerOptions {
188194
message_observer: None,
189195
max_request_body_size: None,
190196
dns_rebinding: DnsRebindingOptions::default(),
197+
session_store: None,
191198
enable_ssl: false,
192199
ssl_cert_path: None,
193200
ssl_key_path: None,

crates/rust-mcp-actix/src/server.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ impl ActixServer {
3131
mut server_options: ActixServerOptions,
3232
) -> Self {
3333
let state: Arc<McpAppState> = Arc::new(McpAppState {
34-
session_store: Arc::new(InMemorySessionStore::new()),
34+
session_store: server_options
35+
.session_store
36+
.take()
37+
.unwrap_or_else(|| Arc::new(InMemorySessionStore::default())),
3538
id_generator: server_options
3639
.session_id_generator
3740
.take()

crates/rust-mcp-axum/src/server.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use rust_mcp_sdk::{
1616
mcp_http::{
1717
resolve_dns_middleware, DnsRebindingOptions, HealthHandler, McpAppState, McpHttpHandler,
1818
},
19-
session_store::InMemorySessionStore,
19+
session_store::{InMemorySessionStore, SessionStore},
2020
task_store::{ClientTaskStore, ServerTaskStore},
2121
IdGenerator, McpObserver, McpServerHandler,
2222
};
@@ -125,6 +125,11 @@ pub struct AxumServerOptions {
125125
/// than this are rejected with `413 Payload Too Large`.
126126
/// Defaults to 4 MiB when `None`.
127127
pub max_request_body_size: Option<usize>,
128+
/// Optional session store implementation. Defaults to a bounded
129+
/// [`InMemorySessionStore`] (10k max sessions, no idle TTL) when `None`.
130+
/// Pass your own [`SessionStore`] implementation to use Redis, custom
131+
/// limits, or any other session backend.
132+
pub session_store: Option<Arc<dyn SessionStore>>,
128133

129134
/// Enables SSL/TLS if set to `true`
130135
pub enable_ssl: bool,
@@ -313,6 +318,7 @@ impl Default for AxumServerOptions {
313318
custom_messages_endpoint: None,
314319
ping_interval: DEFAULT_CLIENT_PING_INTERVAL,
315320
max_request_body_size: None,
321+
session_store: None,
316322
transport_options: Default::default(),
317323
enable_ssl: false,
318324
ssl_cert_path: None,
@@ -358,7 +364,10 @@ impl AxumServer {
358364
mut server_options: AxumServerOptions,
359365
) -> Self {
360366
let state: Arc<McpAppState> = Arc::new(McpAppState {
361-
session_store: Arc::new(InMemorySessionStore::new()),
367+
session_store: server_options
368+
.session_store
369+
.take()
370+
.unwrap_or_else(|| Arc::new(InMemorySessionStore::default())),
362371
id_generator: server_options
363372
.session_id_generator
364373
.take()

crates/rust-mcp-sdk/src/mcp_http/http_utils.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,14 @@ pub(crate) async fn start_new_session(
377377
payload: &str,
378378
auth_info: Option<AuthInfo>,
379379
) -> McpHttpResult<http::Response<GenericBody>> {
380+
if state.session_store.is_full().await {
381+
return error_response(
382+
StatusCode::SERVICE_UNAVAILABLE,
383+
SdkError::internal_error()
384+
.with_message("Server is at maximum session capacity, try again later."),
385+
);
386+
}
387+
380388
let session_id: SessionId = state.id_generator.generate();
381389

382390
let h: Arc<dyn McpServerHandler> = state.handler.clone();
@@ -717,6 +725,14 @@ pub(crate) async fn handle_sse_connection(
717725
sse_message_endpoint: Option<&str>,
718726
auth_info: Option<AuthInfo>,
719727
) -> McpHttpResult<http::Response<GenericBody>> {
728+
if state.session_store.is_full().await {
729+
return error_response(
730+
StatusCode::SERVICE_UNAVAILABLE,
731+
SdkError::internal_error()
732+
.with_message("Server is at maximum session capacity, try again later."),
733+
);
734+
}
735+
720736
let session_id: SessionId = state.id_generator.generate();
721737

722738
let sse_message_endpoint = sse_message_endpoint.unwrap_or(DEFAULT_MESSAGES_ENDPOINT);

crates/rust-mcp-sdk/src/session_store.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,13 @@ pub trait SessionStore: Send + Sync {
3939

4040
/// Clears all sessions from the store
4141
async fn clear(&self);
42+
43+
/// Returns `true` when the store cannot accept a new session.
44+
///
45+
/// Callers should reject new-session creation (e.g. `initialize`) with
46+
/// `503 Service Unavailable` when this returns `true`. The default
47+
/// implementation reports unlimited capacity.
48+
async fn is_full(&self) -> bool {
49+
false
50+
}
4251
}

crates/rust-mcp-sdk/src/session_store/in_memory_session_store.rs

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,115 @@ use super::SessionId;
44
use super::SessionStore;
55
use async_trait::async_trait;
66
use std::collections::HashMap;
7+
use std::sync::atomic::{AtomicU64, Ordering};
78
use std::sync::Arc;
9+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
810
use tokio::sync::RwLock;
911

10-
/// In-memory session store implementation
12+
/// Default maximum number of concurrent sessions retained by the store.
13+
pub const DEFAULT_MAX_SESSIONS: usize = 10_000;
14+
15+
fn now_millis() -> u64 {
16+
SystemTime::now()
17+
.duration_since(UNIX_EPOCH)
18+
.map(|d| d.as_millis() as u64)
19+
.unwrap_or(0)
20+
}
21+
22+
/// A stored session together with the time it was last accessed.
23+
struct SessionEntry {
24+
runtime: Arc<ServerRuntime>,
25+
last_access_ms: AtomicU64,
26+
}
27+
28+
impl SessionEntry {
29+
fn new(runtime: Arc<ServerRuntime>) -> Self {
30+
Self {
31+
runtime,
32+
last_access_ms: AtomicU64::new(now_millis()),
33+
}
34+
}
35+
36+
/// Marks the session as accessed now.
37+
fn touch(&self) {
38+
self.last_access_ms.store(now_millis(), Ordering::Relaxed);
39+
}
40+
41+
/// Returns true if the session has been idle for longer than `ttl_ms`.
42+
fn is_idle(&self, now_ms: u64, ttl_ms: u64) -> bool {
43+
now_ms.saturating_sub(self.last_access_ms.load(Ordering::Relaxed)) > ttl_ms
44+
}
45+
}
46+
47+
/// In-memory session store with a bounded session count and optional idle TTL.
1148
///
12-
/// Stores session data in a thread-safe HashMap, using a read-write lock for
13-
#[derive(Clone, Default)]
49+
/// Idle sessions (older than the configured TTL) are evicted lazily, on access
50+
/// and whenever the store is checked for capacity. Once `max_sessions` is
51+
/// reached the server rejects new sessions with `503 Service Unavailable`,
52+
/// preventing an unauthenticated client from exhausting memory via repeated
53+
/// `initialize` requests.
54+
#[derive(Clone)]
1455
pub struct InMemorySessionStore {
15-
store: Arc<RwLock<HashMap<String, Arc<ServerRuntime>>>>,
56+
store: Arc<RwLock<HashMap<String, SessionEntry>>>,
57+
max_sessions: usize,
58+
idle_ttl: Option<Duration>,
59+
}
60+
61+
impl Default for InMemorySessionStore {
62+
fn default() -> Self {
63+
Self::with_limits(None, None)
64+
}
1665
}
1766

1867
impl InMemorySessionStore {
19-
/// Creates a new in-memory session store
20-
///
21-
/// Initializes an empty HashMap wrapped in a read-write lock for thread-safe access.
22-
///
23-
/// # Returns
24-
/// * `Self` - A new InMemorySessionStore instance
68+
/// Creates a new in-memory session store with default limits
69+
/// ([`DEFAULT_MAX_SESSIONS`], no idle TTL).
2570
pub fn new() -> Self {
71+
Self::default()
72+
}
73+
74+
/// Creates a session store with explicit limits.
75+
///
76+
/// * `max_sessions` - maximum number of concurrent sessions; `None` uses
77+
/// [`DEFAULT_MAX_SESSIONS`]. Pass `Some(usize::MAX)` for an effectively
78+
/// unbounded store.
79+
/// * `idle_ttl` - sessions idle for longer than this are evicted; `None`
80+
/// disables idle expiry.
81+
pub fn with_limits(max_sessions: Option<usize>, idle_ttl: Option<Duration>) -> Self {
2682
Self {
2783
store: Arc::new(RwLock::new(HashMap::new())),
84+
max_sessions: max_sessions.unwrap_or(DEFAULT_MAX_SESSIONS),
85+
idle_ttl,
2886
}
2987
}
88+
89+
/// Evicts sessions idle past the configured TTL and returns the resulting
90+
/// session count.
91+
async fn evict_idle(&self) -> usize {
92+
let Some(ttl) = self.idle_ttl else {
93+
return self.store.read().await.len();
94+
};
95+
let ttl_ms = ttl.as_millis() as u64;
96+
let now = now_millis();
97+
let mut store = self.store.write().await;
98+
store.retain(|_, entry| !entry.is_idle(now, ttl_ms));
99+
store.len()
100+
}
30101
}
31102

32103
/// Implementation of the SessionStore trait for InMemorySessionStore
33-
///
34-
/// Provides asynchronous methods for managing sessions in memory, ensuring
35104
#[async_trait]
36105
impl SessionStore for InMemorySessionStore {
37106
async fn get(&self, key: &SessionId) -> Option<Arc<ServerRuntime>> {
38107
let store = self.store.read().await;
39-
store.get(key).cloned()
108+
let entry = store.get(key)?;
109+
entry.touch();
110+
Some(entry.runtime.clone())
40111
}
41112

42113
async fn set(&self, key: SessionId, value: Arc<ServerRuntime>) {
43114
let mut store = self.store.write().await;
44-
store.insert(key, value);
115+
store.insert(key, SessionEntry::new(value));
45116
}
46117

47118
async fn delete(&self, key: &SessionId) {
@@ -59,10 +130,18 @@ impl SessionStore for InMemorySessionStore {
59130
}
60131
async fn values(&self) -> Vec<Arc<ServerRuntime>> {
61132
let store = self.store.read().await;
62-
store.values().cloned().collect::<Vec<_>>()
133+
store
134+
.values()
135+
.map(|entry| entry.runtime.clone())
136+
.collect::<Vec<_>>()
63137
}
64138
async fn has(&self, session: &SessionId) -> bool {
65139
let store = self.store.read().await;
66140
store.contains_key(session)
67141
}
142+
143+
async fn is_full(&self) -> bool {
144+
let count = self.evict_idle().await;
145+
count >= self.max_sessions
146+
}
68147
}

crates/rust-mcp-sdk/tests/test_streamable_http_server.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use rust_mcp_sdk::{
2525
auth::{AuthInfo, AuthMetadataBuilder, AuthProvider, RemoteAuthProvider},
2626
event_store::InMemoryEventStore,
2727
schema::ResultFromClient,
28+
session_store::InMemorySessionStore,
2829
task_store::InMemoryTaskStore,
2930
};
3031
use serde_json::{json, Map, Value};
@@ -1884,6 +1885,40 @@ async fn should_reject_oversized_request_body() {
18841885
server.axum_runtime.await_server().await.unwrap()
18851886
}
18861887

1888+
// should reject new sessions once the store reaches its capacity
1889+
#[tokio::test]
1890+
async fn should_reject_new_session_when_at_capacity() {
1891+
let server_options = AxumServerOptions {
1892+
port: random_port(),
1893+
session_store: Some(Arc::new(InMemorySessionStore::with_limits(Some(1), None))),
1894+
..Default::default()
1895+
};
1896+
1897+
let server = create_start_server(server_options).await;
1898+
tokio::time::sleep(Duration::from_millis(250)).await;
1899+
1900+
let init = ClientJsonrpcRequest::new(RequestId::Integer(0), initialize_request());
1901+
let body = serde_json::to_string(&init).unwrap();
1902+
1903+
// first session is accepted
1904+
let first = send_post_request(&server.streamable_url, &body, None, None)
1905+
.await
1906+
.expect("Request failed");
1907+
assert_eq!(first.status(), StatusCode::OK);
1908+
1909+
// second session is rejected: the store is full
1910+
let second = send_post_request(&server.streamable_url, &body, None, None)
1911+
.await
1912+
.expect("Request failed");
1913+
assert_eq!(second.status(), StatusCode::SERVICE_UNAVAILABLE);
1914+
1915+
// keep the first session's stream open until the assertions complete
1916+
drop(first);
1917+
1918+
server.axum_runtime.graceful_shutdown(ONE_MILLISECOND);
1919+
server.axum_runtime.await_server().await.unwrap()
1920+
}
1921+
18871922
// should return 400 error for invalid JSON-RPC messages
18881923
// should keep stream open after sending server notifications
18891924
// NA: should reject second initialization request

0 commit comments

Comments
 (0)