diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index c86ac562f2..7dbc2da51c 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -675,7 +675,7 @@ impl LocalBackend { state, data_dir, transaction_owners: Arc::new(TransactionOwnerRegistry::default()), - transaction_owner_config: TransactionOwnerConfig::default(), + transaction_owner_config: TransactionOwnerConfig::from_env(), } } @@ -710,7 +710,7 @@ impl LocalBackend { state, data_dir, transaction_owners: Arc::new(TransactionOwnerRegistry::default()), - transaction_owner_config: TransactionOwnerConfig::default(), + transaction_owner_config: TransactionOwnerConfig::from_env(), }) } diff --git a/crates/dbx-mcp/src/server.rs b/crates/dbx-mcp/src/server.rs index 01dc92c809..93a5a1689e 100644 --- a/crates/dbx-mcp/src/server.rs +++ b/crates/dbx-mcp/src/server.rs @@ -1415,7 +1415,7 @@ impl DbxMcpServer { #[tool( name = "dbx_open_session", - description = "Open a stateful query session pinned to a single backend connection. Returns a session ID for dbx_execute_query: USE, SET CATALOG, session variables and temporary tables persist across calls within the session. Close with dbx_close_session when done; idle sessions expire after 30 minutes." + description = "Open a stateful query session pinned to a single backend connection. Returns a session ID for dbx_execute_query: USE, SET CATALOG, session variables and temporary tables persist across calls within the session. Close with dbx_close_session when done; idle sessions expire after 30 minutes by default (configurable with DBX_SESSION_IDLE_TTL_SECS)." )] async fn open_session_tool( &self, diff --git a/crates/dbx-mcp/src/session.rs b/crates/dbx-mcp/src/session.rs index 89d7acae5e..0f90806f3e 100644 --- a/crates/dbx-mcp/src/session.rs +++ b/crates/dbx-mcp/src/session.rs @@ -9,7 +9,20 @@ use crate::transaction::TransactionOwner; /// 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); +pub(crate) const SESSION_IDLE_TTL: Duration = Duration::from_secs(30 * 60); + +pub(crate) fn session_idle_ttl_from_env() -> Duration { + session_idle_ttl_from_value(std::env::var("DBX_SESSION_IDLE_TTL_SECS").ok().as_deref()) +} + +pub(crate) fn session_idle_ttl_from_value(value: Option<&str>) -> Duration { + value + .and_then(|value| value.trim().parse::().ok()) + .filter(|seconds| *seconds > 0) + .map(Duration::from_secs) + .filter(|ttl| Instant::now().checked_add(*ttl).is_some()) + .unwrap_or(SESSION_IDLE_TTL) +} /// Maximum number of concurrent MCP sessions. Bounds how many pinned backend /// connection pools an agent can hold at once. @@ -51,14 +64,20 @@ struct McpSessionState { closing: HashMap, } -#[derive(Default)] pub struct McpSessionStore { state: Mutex, + idle_ttl: Duration, +} + +impl Default for McpSessionStore { + fn default() -> Self { + Self { state: Mutex::new(McpSessionState::default()), idle_ttl: SESSION_IDLE_TTL } + } } impl McpSessionStore { pub fn new() -> Arc { - Arc::new(Self::default()) + Arc::new(Self { idle_ttl: session_idle_ttl_from_env(), ..Self::default() }) } /// Open a new session bound to `connection_id` + `database`. @@ -67,7 +86,7 @@ impl McpSessionStore { /// reached; expired sessions are swept before the cap is enforced. pub async fn open(&self, connection_id: &str, database: &str) -> McpSessionStoreResult> { let mut state = self.state.lock().await; - let expired = sweep_expired(&mut state); + let expired = sweep_expired(&mut state, self.idle_ttl); if session_count(&state) >= MAX_SESSIONS { return McpSessionStoreResult::new( Err(format!( @@ -87,7 +106,7 @@ impl McpSessionStore { database: &str, ) -> McpSessionStoreResult> { let mut state = self.state.lock().await; - let expired = sweep_expired(&mut state); + let expired = sweep_expired(&mut state, self.idle_ttl); if session_count(&state) >= MAX_SESSIONS { return McpSessionStoreResult::new( Err(format!( @@ -124,7 +143,7 @@ impl McpSessionStore { /// Resolve a session id and refresh its idle timer. pub async fn resolve(&self, session_id: &str) -> McpSessionStoreResult> { let mut state = self.state.lock().await; - let expired = sweep_expired(&mut state); + let expired = sweep_expired(&mut state, self.idle_ttl); let session = state.active.get_mut(session_id).map(|session| { session.last_used = Instant::now(); session.clone() @@ -136,7 +155,7 @@ impl McpSessionStore { /// cap until the backend pool is closed successfully. pub async fn begin_close(&self, session_id: &str) -> McpSessionStoreResult> { let mut state = self.state.lock().await; - let expired = sweep_expired(&mut state); + let expired = sweep_expired(&mut state, self.idle_ttl); let session = state.active.remove(session_id); if let Some(session) = &session { state.closing.entry(session.id.clone()).or_insert_with(|| session.clone()); @@ -159,7 +178,7 @@ impl McpSessionStore { #[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); + Instant::now() - self.idle_ttl - Duration::from_secs(1); } } @@ -182,12 +201,12 @@ fn session_count(state: &McpSessionState) -> usize { state.opening.len() + state.active.len() + state.closing.len() } -fn sweep_expired(state: &mut McpSessionState) -> Vec { +fn sweep_expired(state: &mut McpSessionState, idle_ttl: Duration) -> Vec { let now = Instant::now(); let expired_ids = state .active .iter() - .filter(|(_, session)| now.duration_since(session.last_used) >= SESSION_IDLE_TTL) + .filter(|(_, session)| now.duration_since(session.last_used) >= idle_ttl) .map(|(id, _)| id.clone()) .collect::>(); let mut expired = Vec::with_capacity(expired_ids.len()); @@ -200,6 +219,67 @@ fn sweep_expired(state: &mut McpSessionState) -> Vec { expired } +#[cfg(test)] +mod ttl_tests { + use super::*; + + #[test] + fn positive_seconds_override_the_default() { + for (value, seconds) in [("1", 1), ("43200", 43200), (" 3600 ", 3600)] { + assert_eq!(session_idle_ttl_from_value(Some(value)), Duration::from_secs(seconds)); + } + } + + fn store_with_ttl(value: &str) -> McpSessionStore { + McpSessionStore { idle_ttl: session_idle_ttl_from_value(Some(value)), ..McpSessionStore::default() } + } + + #[tokio::test] + async fn longer_ttl_preserves_sessions_beyond_thirty_minutes_and_resolve_refreshes_idle_time() { + let store = store_with_ttl("43200"); + let session = store.open("conn-1", "analytics").await.into_parts().0.unwrap(); + store.state.lock().await.active.get_mut(&session.id).unwrap().last_used = + Instant::now() - Duration::from_secs(3600); + let (resolved, expired) = store.resolve(&session.id).await.into_parts(); + assert!(expired.is_empty()); + assert!(resolved.unwrap().last_used.elapsed() < Duration::from_secs(1)); + 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); + assert!(store.state.lock().await.closing.contains_key(&session.id)); + store.finish_close(&session.id).await; + assert!(!store.state.lock().await.closing.contains_key(&session.id)); + } + + #[tokio::test] + async fn shorter_ttl_is_used_by_every_sweep_entry_point() { + for entry_point in ["open", "reserve_opening", "resolve", "begin_close"] { + let store = store_with_ttl("1"); + let session = store.open("conn-1", "").await.into_parts().0.unwrap(); + store.state.lock().await.active.get_mut(&session.id).unwrap().last_used = + Instant::now() - Duration::from_secs(2); + let expired = match entry_point { + "open" => store.open("conn-2", "").await.into_parts().1, + "reserve_opening" => store.reserve_opening("conn-2", "").await.into_parts().1, + "resolve" => store.resolve(&session.id).await.into_parts().1, + _ => store.begin_close(&session.id).await.into_parts().1, + }; + assert_eq!(expired.len(), 1, "{entry_point}"); + assert_eq!(expired[0].id, session.id); + } + } + + #[test] + fn invalid_or_unrepresentable_values_keep_thirty_minutes() { + assert_eq!(session_idle_ttl_from_value(None), SESSION_IDLE_TTL); + for value in ["", " ", "0", "-1", "1.5", "12h", "18446744073709551615", "18446744073709551616"] { + assert_eq!(session_idle_ttl_from_value(Some(value)), SESSION_IDLE_TTL, "{value:?}"); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/dbx-mcp/src/transaction.rs b/crates/dbx-mcp/src/transaction.rs index 07517983b9..df67ed010e 100644 --- a/crates/dbx-mcp/src/transaction.rs +++ b/crates/dbx-mcp/src/transaction.rs @@ -188,13 +188,19 @@ pub struct TransactionOwnerConfig { impl Default for TransactionOwnerConfig { fn default() -> Self { Self { - idle_ttl: Duration::from_secs(30 * 60), + idle_ttl: crate::session::SESSION_IDLE_TTL, operation_timeout: Duration::from_secs(300), cleanup_timeout: Duration::from_secs(5), } } } +impl TransactionOwnerConfig { + pub(crate) fn from_env() -> Self { + Self { idle_ttl: crate::session::session_idle_ttl_from_env(), ..Self::default() } + } +} + #[derive(Debug)] pub struct TransactionResult { pub result: QueryResult, diff --git a/docs/content/docs/mcp.cn.mdx b/docs/content/docs/mcp.cn.mdx index 17fb8745e0..73a341becd 100644 --- a/docs/content/docs/mcp.cn.mdx +++ b/docs/content/docs/mcp.cn.mdx @@ -162,7 +162,7 @@ DBX MCP 当前提供以下工具(具体可用工具取决于构建功能及权 - Session 变量和设置 - 需要固定连接的显式事务或多步诊断 -会话只支持 SQL 连接,并固定到一个连接和数据库。未知、已关闭或过期的 `sessionId` 会失败,不会静默退化为普通查询。完成后应调用 `dbx_close_session`;空闲 30 分钟的会话会被回收,同时最多允许 32 个并发会话。 +会话只支持 SQL 连接,并固定到一个连接和数据库。未知、已关闭或过期的 `sessionId` 会失败,不会静默退化为普通查询。完成后应调用 `dbx_close_session`;会话默认空闲 30 分钟后回收,同时最多允许 32 个并发会话。在 MCP 宿主进程设置 `DBX_SESSION_IDLE_TTL_SECS=43200` 可将空闲时间延长为 12 小时,事务专属连接也使用此配置。值必须为正整数秒;未设置、非法值、零、负数或无法表示的时长均保留默认 1800 秒。修改后需重启宿主。使用嵌入式 DBX Web MCP 时,在 Web 服务端设置;使用独立启动器时,在启动器环境中设置。 有状态会话不会放宽 SQL 策略。`USE` 只有在会话中才有意义,但写入、DDL、生产保护、连接只读和数据库权限仍按每次请求重新检查。 @@ -286,6 +286,7 @@ DBX 在 **设置 → MCP** 中保存一份权威策略,并在每次请求时 | 变量 | 用途 | | --- | --- | | `DBX_DATA_DIR` | 覆盖本地 DBX 数据目录 | +| `DBX_SESSION_IDLE_TTL_SECS` | 有状态会话空闲超时,单位为正整数秒,默认 `1800`。非法值、零、负数或无法表示的时长回退默认值;同样适用于事务专属连接。修改后需重启 MCP 宿主进程。 | | `DBX_WEB_URL` | 使用 DBX Web/Docker 后端 | | `DBX_WEB_PASSWORD` | 登录 DBX Web | | `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` | DBX Web 请求的标准系统代理变量;空值表示不走代理。认证格式 `http://user:pass@host:port` | diff --git a/docs/content/docs/mcp.mdx b/docs/content/docs/mcp.mdx index 84c8706e80..eebd12ea24 100644 --- a/docs/content/docs/mcp.mdx +++ b/docs/content/docs/mcp.mdx @@ -162,7 +162,7 @@ Regular `dbx_execute_query` calls are independent. When a workflow must preserve - Session variables and settings - Explicit transactions or multi-step diagnostics that require one connection -Sessions support SQL connections only and are bound to one connection and database. Unknown, closed, or expired IDs fail instead of silently falling back to an ordinary query. Call `dbx_close_session` when finished. Idle sessions are reclaimed after 30 minutes, and the server allows at most 32 concurrent sessions. +Sessions support SQL connections only and are bound to one connection and database. Unknown, closed, or expired IDs fail instead of silently falling back to an ordinary query. Call `dbx_close_session` when finished. Idle sessions are reclaimed after 30 minutes by default, and the server allows at most 32 concurrent sessions. Set `DBX_SESSION_IDLE_TTL_SECS=43200` in the process hosting MCP to allow 12 hours of inactivity, including transaction-owned connections. The value must be positive whole seconds; unset, invalid, zero, negative or unrepresentable values keep the 1800-second default. Restart the host after changing it. For embedded DBX Web MCP, set it on the Web server; for the standalone launcher, set it in the launcher environment. A stateful session does not widen SQL policy. `USE` is meaningful only inside a session, while writes, DDL, production protection, connection read-only state, and database privileges are rechecked for every request. @@ -286,6 +286,7 @@ Updated servers do not let `DBX_MCP_ALLOW_WRITES` or `DBX_MCP_ALLOW_DANGEROUS_SQ | Variable | Purpose | | --- | --- | | `DBX_DATA_DIR` | Override the local DBX data directory | +| `DBX_SESSION_IDLE_TTL_SECS` | Stateful session idle timeout in positive whole seconds (default: `1800`). Invalid, zero, negative or unrepresentable values use the default. Applies to transaction-owned connections too; restart the MCP host after changing it. | | `DBX_WEB_URL` | Use a DBX Web/Docker backend | | `DBX_WEB_PASSWORD` | Authenticate to DBX Web | | `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` | Standard system proxy variables for DBX Web requests; empty value means no proxy. Auth via `http://user:pass@host:port` | diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index c3b6bfe893..37ddefaa55 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -314,6 +314,7 @@ SQL text is not included in normal MCP errors or logged by default. Enable tempo | Variable | Purpose | | --- | --- | | `DBX_DATA_DIR` | Override the local DBX data directory | +| `DBX_SESSION_IDLE_TTL_SECS` | Stateful session idle timeout in positive whole seconds (default: `1800`). Invalid, zero, negative or unrepresentable values use the default. Applies to transaction-owned connections too; restart the MCP host after changing it. | | `DBX_WEB_URL` | Use a DBX Web/Docker backend | | `DBX_WEB_PASSWORD` | Authenticate to the DBX Web backend | | `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` | Standard system proxy variables for DBX Web requests; empty value means no proxy. Auth via `http://user:pass@host:port` | @@ -658,6 +659,7 @@ MongoDB 更新和删除在未启用完全访问时必须提供可验证有效的 | 变量 | 用途 | | --- | --- | | `DBX_DATA_DIR` | 覆盖本地 DBX 数据目录 | +| `DBX_SESSION_IDLE_TTL_SECS` | 有状态会话空闲超时,单位为正整数秒,默认 `1800`。非法值、零、负数或无法表示的时长回退默认值;同样适用于事务专属连接。修改后需重启 MCP 宿主进程。 | | `DBX_WEB_URL` | 使用 DBX Web/Docker 后端 | | `DBX_WEB_PASSWORD` | DBX Web 登录密码 | | `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` | DBX Web 请求的标准系统代理变量;空值表示不走代理。认证格式 `http://user:pass@host:port` |