209 lines
7.6 KiB
Rust
209 lines
7.6 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use tokio::sync::Mutex;
|
|
use uuid::Uuid;
|
|
|
|
/// Idle time after which an MCP session is considered expired and its pinned
|
|
/// backend connection pool may be reclaimed.
|
|
const SESSION_IDLE_TTL: Duration = Duration::from_secs(30 * 60);
|
|
|
|
/// Maximum number of concurrent MCP sessions. Bounds how many pinned backend
|
|
/// connection pools an agent can hold at once.
|
|
const MAX_SESSIONS: usize = 32;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct McpSession {
|
|
/// Opaque handle returned to the MCP client (`dbx_open_session` result).
|
|
pub id: String,
|
|
pub connection_id: String,
|
|
pub database: String,
|
|
/// Value forwarded to the backend as `client_session_id`, pinning all
|
|
/// queries in this session to the same connection pool.
|
|
pub client_session_id: String,
|
|
last_used: Instant,
|
|
}
|
|
|
|
#[must_use = "expired MCP sessions must be closed by the caller"]
|
|
pub struct McpSessionStoreResult<T> {
|
|
value: T,
|
|
expired: Vec<McpSession>,
|
|
}
|
|
|
|
impl<T> McpSessionStoreResult<T> {
|
|
fn new(value: T, expired: Vec<McpSession>) -> Self {
|
|
Self { value, expired }
|
|
}
|
|
|
|
pub fn into_parts(self) -> (T, Vec<McpSession>) {
|
|
(self.value, self.expired)
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct McpSessionState {
|
|
active: HashMap<String, McpSession>,
|
|
closing: HashMap<String, McpSession>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct McpSessionStore {
|
|
state: Mutex<McpSessionState>,
|
|
}
|
|
|
|
impl McpSessionStore {
|
|
pub fn new() -> Arc<Self> {
|
|
Arc::new(Self::default())
|
|
}
|
|
|
|
/// Open a new session bound to `connection_id` + `database`.
|
|
///
|
|
/// Returns `Err` with a human-readable message when the session cap is
|
|
/// reached; expired sessions are swept before the cap is enforced.
|
|
pub async fn open(&self, connection_id: &str, database: &str) -> McpSessionStoreResult<Result<McpSession, String>> {
|
|
let mut state = self.state.lock().await;
|
|
let expired = sweep_expired(&mut state.active);
|
|
if state.active.len() + state.closing.len() >= MAX_SESSIONS {
|
|
return McpSessionStoreResult::new(
|
|
Err(format!(
|
|
"Too many open MCP sessions (max {MAX_SESSIONS}). Close unused sessions with dbx_close_session."
|
|
)),
|
|
expired,
|
|
);
|
|
}
|
|
let id = format!("mcp-session-{}", Uuid::new_v4());
|
|
let session = McpSession {
|
|
id: id.clone(),
|
|
connection_id: connection_id.to_string(),
|
|
database: database.to_string(),
|
|
// Prefixed so these pools are easy to distinguish from desktop UI
|
|
// sessions in backend diagnostics. `:` is normalized away by the
|
|
// backend pool key sanitizer.
|
|
client_session_id: format!("mcp:{id}"),
|
|
last_used: Instant::now(),
|
|
};
|
|
state.active.insert(id, session.clone());
|
|
McpSessionStoreResult::new(Ok(session), expired)
|
|
}
|
|
|
|
/// Resolve a session id and refresh its idle timer.
|
|
pub async fn resolve(&self, session_id: &str) -> McpSessionStoreResult<Option<McpSession>> {
|
|
let mut state = self.state.lock().await;
|
|
let expired = sweep_expired(&mut state.active);
|
|
let session = state.active.get_mut(session_id).map(|session| {
|
|
session.last_used = Instant::now();
|
|
session.clone()
|
|
});
|
|
McpSessionStoreResult::new(session, expired)
|
|
}
|
|
|
|
/// Reserve a session for closing. It remains counted against the session
|
|
/// cap until the backend pool is closed successfully.
|
|
pub async fn begin_close(&self, session_id: &str) -> McpSessionStoreResult<Option<McpSession>> {
|
|
let mut state = self.state.lock().await;
|
|
let expired = sweep_expired(&mut state.active);
|
|
let session = state.active.remove(session_id);
|
|
if let Some(session) = &session {
|
|
state.closing.insert(session.id.clone(), session.clone());
|
|
}
|
|
McpSessionStoreResult::new(session, expired)
|
|
}
|
|
|
|
pub async fn finish_close(&self, session_id: &str) {
|
|
self.state.lock().await.closing.remove(session_id);
|
|
}
|
|
|
|
pub async fn restore_after_failed_close(&self, mut session: McpSession) {
|
|
let mut state = self.state.lock().await;
|
|
if state.closing.remove(&session.id).is_some() {
|
|
session.last_used = Instant::now();
|
|
state.active.insert(session.id.clone(), session);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn expire_for_test(&self, session_id: &str) {
|
|
self.state.lock().await.active.get_mut(session_id).unwrap().last_used =
|
|
Instant::now() - SESSION_IDLE_TTL - Duration::from_secs(1);
|
|
}
|
|
}
|
|
|
|
fn sweep_expired(sessions: &mut HashMap<String, McpSession>) -> Vec<McpSession> {
|
|
let now = Instant::now();
|
|
let mut expired = Vec::new();
|
|
sessions.retain(|_, session| {
|
|
let active = now.duration_since(session.last_used) < SESSION_IDLE_TTL;
|
|
if !active {
|
|
expired.push(session.clone());
|
|
}
|
|
active
|
|
});
|
|
expired
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn open_resolve_and_remove_roundtrip() {
|
|
let store = McpSessionStore::new();
|
|
let (session, expired) = store.open("conn-1", "analytics").await.into_parts();
|
|
assert!(expired.is_empty());
|
|
let session = session.unwrap();
|
|
assert!(session.id.starts_with("mcp-session-"));
|
|
assert!(session.client_session_id.contains(&session.id));
|
|
|
|
let (resolved, expired) = store.resolve(&session.id).await.into_parts();
|
|
assert!(expired.is_empty());
|
|
let resolved = resolved.unwrap();
|
|
assert_eq!(resolved.connection_id, "conn-1");
|
|
assert_eq!(resolved.database, "analytics");
|
|
|
|
let (closing, expired) = store.begin_close(&session.id).await.into_parts();
|
|
assert!(expired.is_empty());
|
|
assert_eq!(closing.unwrap().id, session.id);
|
|
assert!(store.resolve(&session.id).await.into_parts().0.is_none());
|
|
store.finish_close(&session.id).await;
|
|
assert!(store.begin_close(&session.id).await.into_parts().0.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_cap_is_enforced() {
|
|
let store = McpSessionStore::new();
|
|
for _ in 0..MAX_SESSIONS {
|
|
store.open("conn-1", "").await.into_parts().0.unwrap();
|
|
}
|
|
let error = store.open("conn-1", "").await.into_parts().0.unwrap_err();
|
|
assert!(error.contains("Too many open MCP sessions"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn expired_sessions_are_swept_and_free_capacity() {
|
|
let store = McpSessionStore::new();
|
|
let mut session_ids = Vec::new();
|
|
for _ in 0..MAX_SESSIONS {
|
|
let session = store.open("conn-1", "").await.into_parts().0.unwrap();
|
|
session_ids.push(session.id);
|
|
}
|
|
for session_id in &session_ids {
|
|
store.expire_for_test(session_id).await;
|
|
}
|
|
// All sessions are expired: the sweep must reclaim them before the cap
|
|
// check, return them for backend cleanup, and allow a new session.
|
|
let (opened, expired) = store.open("conn-1", "").await.into_parts();
|
|
opened.unwrap();
|
|
assert_eq!(expired.len(), MAX_SESSIONS);
|
|
|
|
// Resolving an expired session must fail instead of silently pinning a
|
|
// fresh backend connection.
|
|
let session = store.open("conn-1", "").await.into_parts().0.unwrap();
|
|
store.expire_for_test(&session.id).await;
|
|
let (resolved, expired) = store.resolve(&session.id).await.into_parts();
|
|
assert!(resolved.is_none());
|
|
assert_eq!(expired.len(), 1);
|
|
assert_eq!(expired[0].id, session.id);
|
|
}
|
|
}
|