From a21880ea133319b26d6d2dba7a095be4e7263b1e Mon Sep 17 00:00:00 2001 From: KeaneQy <34180997+KeaneQy@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:11:32 +0800 Subject: [PATCH] fix(mcp): serve HTTP transport statelessly for all protocol versions --- crates/dbx-mcp/src/http.rs | 286 +++++++++++++++-------------------- docs/content/docs/mcp.cn.mdx | 10 +- docs/content/docs/mcp.mdx | 10 +- 3 files changed, 132 insertions(+), 174 deletions(-) diff --git a/crates/dbx-mcp/src/http.rs b/crates/dbx-mcp/src/http.rs index 6c7923d4a9..ab516f1250 100644 --- a/crates/dbx-mcp/src/http.rs +++ b/crates/dbx-mcp/src/http.rs @@ -2,10 +2,7 @@ use std::{io, sync::Arc}; use axum::{http::Method, middleware, routing::get, Router}; use rmcp::transport::{ - streamable_http_server::{ - session::{local::LocalSessionManager, SessionManager}, - tower::StreamableHttpService, - }, + streamable_http_server::{session::never::NeverSessionManager, tower::StreamableHttpService}, StreamableHttpServerConfig, }; use tokio_util::sync::CancellationToken; @@ -49,7 +46,7 @@ fn build_streamable_http_router( allowed_hosts: Vec, web_mode: bool, cancellation: Option, - session_manager: Arc, + session_manager: Arc, sessions: Arc, pending_salesforce_writes: Arc, ) -> Result { @@ -64,15 +61,25 @@ fn build_streamable_http_router( if let Some(cancellation) = cancellation { rmcp_config = rmcp_config.with_cancellation_token(cancellation); } + // The transport keeps no session, for any protocol generation. rmcp would + // otherwise hand a client an `Mcp-Session-Id` on `initialize` and answer + // `404 Not Found: Session not found` once its idle window dropped that + // session, which a client that reconnects after a longer pause reports as + // `Streamable HTTP error: Error POSTing to endpoint: Not Found: Session not + // found` and never recovers from (see #11640). A client that declares the + // version per request is already exempt; this extends the same stateless + // treatment to pre-`2026-07-28` clients. DBX's own stateful sessions are + // unaffected: they are explicit `dbx_open_session` handles carried in tool + // arguments, not transport sessions. + rmcp_config = rmcp_config.with_legacy_session_mode(false); let server_backend = backend.clone(); let scope = McpScope::from_env(); let plugin_tools_mode = PluginToolsMode::from_env(); - // Legacy (`< 2026-07-28`) conversations are driven through one long-lived - // service, but stateless `2026-07-28` requests, tool-schema discovery, and - // session restoration each call the factory again. Session and - // pending-write state belongs to the endpoint, not to one instance, so a - // stateless request can still find the session an earlier request opened. - let service: StreamableHttpService = StreamableHttpService::new( + // Stateless requests, tool-schema discovery, and session restoration each + // call the factory again. Session and pending-write state belongs to the + // endpoint, not to one instance, so a request can still find the DBX session + // an earlier request opened. + let service: StreamableHttpService = StreamableHttpService::new( move || { Ok(DbxMcpServer::with_shared_state( server_backend.clone(), @@ -99,7 +106,9 @@ fn build_streamable_http_router( .allow_origin(AllowOrigin::predicate(move |origin, _| { origin.to_str().is_ok_and(|origin| cors_auth.origin_is_allowed(origin)) })) - .allow_methods([Method::GET, Method::POST, Method::DELETE]) + // Every request is served statelessly, so `GET` and `DELETE` are + // answered with 405 and must not be advertised to browser clients. + .allow_methods([Method::POST]) .allow_headers(Any), )) } @@ -137,7 +146,7 @@ pub async fn serve_streamable_http_on_listener( cancellation: CancellationToken, listener: tokio::net::TcpListener, ) -> io::Result<()> { - let session_manager = Arc::new(LocalSessionManager::default()); + let session_manager = Arc::new(NeverSessionManager::default()); let sessions = McpSessionStore::new(); let pending_salesforce_writes = PendingSalesforceWrites::new(SALESFORCE_WRITE_CONFIRM_TTL); let mcp_router = build_streamable_http_router( @@ -161,24 +170,18 @@ pub async fn serve_streamable_http_on_listener( cancellation.cancelled().await; }) .await; - close_http_sessions_bounded(&backend, &session_manager, &sessions).await; + close_http_sessions_bounded(&backend, &sessions).await; result } -/// Drain protocol sessions, then the stateful sessions they opened. +/// Roll back the DBX sessions the endpoint still holds. /// -/// Sessions live on the endpoint-wide store, not on the dropped per-session -/// service, so they have to be rolled back explicitly here. -async fn close_http_sessions_bounded( - backend: &Arc, - session_manager: &Arc, - sessions: &Arc, -) { - let session_ids = session_manager.sessions.read().await.keys().cloned().collect::>(); +/// There are no transport sessions to drain: the endpoint serves every request +/// statelessly. A DBX session outlives the request that opened it because it +/// lives on the endpoint-wide store, so shutdown releases it explicitly rather +/// than leaving it to the idle TTL. +async fn close_http_sessions_bounded(backend: &Arc, sessions: &Arc) { let cleanup = async { - for session_id in session_ids { - let _ = session_manager.close_session(&session_id).await; - } let leftover = sessions.take_all_active().await; if !leftover.is_empty() { let server = DbxMcpServer::with_runtime_options(backend.clone(), McpScope::from_env(), false); @@ -186,7 +189,7 @@ async fn close_http_sessions_bounded( } }; if tokio::time::timeout(std::time::Duration::from_secs(10), cleanup).await.is_err() { - log::warn!("Timed out draining MCP HTTP protocol sessions during shutdown"); + log::warn!("Timed out draining MCP HTTP sessions during shutdown"); } } @@ -202,14 +205,10 @@ mod tests { use rmcp::{ model::{CallToolRequestParams, ProtocolVersion}, service::ServiceExt, - transport::{ - streamable_http_client::StreamableHttpClientTransportConfig, - streamable_http_server::session::local::SessionConfig, StreamableHttpClientTransport, - }, + transport::{streamable_http_client::StreamableHttpClientTransportConfig, StreamableHttpClientTransport}, ClientLifecycleMode, ClientServiceExt, }; use serde_json::{json, Value}; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; use super::*; use crate::{ @@ -367,8 +366,7 @@ mod tests { async fn start_http_test_server( backend: Arc, - keep_alive: std::time::Duration, - ) -> (String, Arc, Arc, CancellationToken, tokio::task::JoinHandle<()>) { + ) -> (String, Arc, CancellationToken, tokio::task::JoinHandle<()>) { // A single default provider avoids the "No rustls crypto provider is // configured" panic when tests build reqwest clients in workspace // builds where multiple rustls crypto features are present; the @@ -376,11 +374,6 @@ mod tests { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); let address = listener.local_addr().unwrap(); - let mut session_config = SessionConfig::default(); - session_config.keep_alive = Some(keep_alive); - let mut local_manager = LocalSessionManager::default(); - local_manager.session_config = session_config; - let manager = Arc::new(local_manager); let cancellation = CancellationToken::new(); let sessions = McpSessionStore::new(); let router = build_streamable_http_router( @@ -390,13 +383,12 @@ mod tests { vec![address.to_string()], false, Some(cancellation.child_token()), - manager.clone(), + Arc::new(NeverSessionManager::default()), sessions.clone(), PendingSalesforceWrites::new(SALESFORCE_WRITE_CONFIRM_TTL), ) .unwrap(); let shutdown = cancellation.clone(); - let shutdown_manager = manager.clone(); let shutdown_sessions = sessions.clone(); let shutdown_backend: Arc = backend; let task = tokio::spawn(async move { @@ -404,9 +396,9 @@ mod tests { .with_graceful_shutdown(async move { shutdown.cancelled().await }) .await .unwrap(); - close_http_sessions_bounded(&shutdown_backend, &shutdown_manager, &shutdown_sessions).await; + close_http_sessions_bounded(&shutdown_backend, &shutdown_sessions).await; }); - (format!("http://{address}/mcp"), manager, sessions, cancellation, task) + (format!("http://{address}/mcp"), sessions, cancellation, task) } async fn open_active_transaction(url: &str) -> (rmcp::service::RunningService, String) { @@ -470,108 +462,36 @@ mod tests { assert_ne!(closed.is_error, Some(true), "close session failed: {closed:?}"); } - async fn open_and_drop_raw_sse(url: &str, outer_session_id: &str) { - let parsed = url::Url::parse(url).unwrap(); - let host = parsed.host_str().unwrap(); - let port = parsed.port_or_known_default().unwrap(); - let mut stream = tokio::net::TcpStream::connect((host, port)).await.unwrap(); - let path = match parsed.query() { - Some(query) => format!("{}?{query}", parsed.path()), - None => parsed.path().to_string(), - }; - let request = format!( - "GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nAuthorization: Bearer http-test-token\r\nAccept: text/event-stream\r\nMcp-Session-Id: {outer_session_id}\r\nMcp-Protocol-Version: 2025-06-18\r\nConnection: keep-alive\r\n\r\n" - ); - stream.write_all(request.as_bytes()).await.unwrap(); - - let headers = tokio::time::timeout(std::time::Duration::from_secs(1), async { - let mut response = Vec::new(); - let mut buffer = [0_u8; 1024]; - loop { - let read = stream.read(&mut buffer).await.unwrap(); - assert!(read > 0, "raw SSE connection closed before HTTP headers"); - response.extend_from_slice(&buffer[..read]); - if response.windows(4).any(|window| window == b"\r\n\r\n") { - break response; - } - assert!(response.len() <= 16 * 1024, "raw SSE response headers exceeded 16 KiB"); - } - }) - .await - .expect("raw SSE GET must return HTTP headers"); - let headers = std::str::from_utf8(&headers).unwrap(); - assert!(headers.starts_with("HTTP/1.1 200 "), "raw SSE GET did not return HTTP 200: {headers}"); - assert!(headers.to_ascii_lowercase().contains("content-type: text/event-stream")); - - drop(stream); - } - - /// Ending the HTTP transport session must still be possible without - /// touching the stateful session the agent opened on it: HTTP DELETE is a - /// transport-level operation, and a stateless `2026-07-28` agent has no - /// transport session at all. + /// The endpoint offers no transport session to end, so it does not accept + /// the legacy session-management verbs at all. A client that used to send + /// `DELETE` now sees `405` with an explicit `Allow`, instead of silently + /// having a session closed behind its back. #[tokio::test] - async fn authenticated_http_delete_keeps_the_stateful_session_until_it_is_closed() { + async fn transport_session_management_verbs_are_not_accepted() { let backend = Arc::new(HttpTestBackend::new()); - let (url, manager, _sessions, cancellation, server_task) = - start_http_test_server(backend.clone(), std::time::Duration::from_secs(30)).await; - let (client, inner_session_id) = open_active_transaction(&url).await; - let outer_session_id = manager.sessions.read().await.keys().next().unwrap().to_string(); - - let response = reqwest::Client::new() - .delete(&url) - .bearer_auth("http-test-token") - .header("mcp-session-id", outer_session_id) - .send() - .await - .unwrap(); - assert!(response.status().is_success(), "DELETE returned {}", response.status()); - assert_eq!(backend.disconnects.load(Ordering::SeqCst), 0); - - close_inner_session(&client, &inner_session_id).await; - wait_for_disposal(&backend).await; - - drop(client); - cancellation.cancel(); - server_task.await.unwrap(); - } - - /// The transport idle timeout reclaims the protocol session, not the - /// stateful session an agent opened through it. - #[tokio::test] - async fn http_inactivity_expiry_reclaims_the_transport_session() { - let backend = Arc::new(HttpTestBackend::new()); - let (url, manager, _sessions, cancellation, server_task) = - start_http_test_server(backend.clone(), std::time::Duration::from_millis(500)).await; + let (url, _sessions, cancellation, server_task) = start_http_test_server(backend.clone()).await; let (client, inner_session_id) = open_active_transaction(&url).await; - tokio::time::timeout(std::time::Duration::from_secs(2), async { - while !manager.sessions.read().await.is_empty() { - tokio::task::yield_now().await; - } - }) - .await - .expect("expired outer HTTP session must be removed"); - assert_eq!(backend.disconnects.load(Ordering::SeqCst), 0); - - close_inner_session(&client, &inner_session_id).await; - wait_for_disposal(&backend).await; - - drop(client); - cancellation.cancel(); - server_task.await.unwrap(); - } - - #[tokio::test] - async fn transient_http_connections_preserve_one_outer_session() { - let backend = Arc::new(HttpTestBackend::new()); - let (url, manager, _sessions, cancellation, server_task) = - start_http_test_server(backend.clone(), std::time::Duration::from_secs(30)).await; - let (client, inner_session_id) = open_active_transaction(&url).await; - let outer_session_id = manager.sessions.read().await.keys().next().unwrap().to_string(); - - open_and_drop_raw_sse(&url, &outer_session_id).await; + for method in [reqwest::Method::GET, reqwest::Method::DELETE] { + let response = reqwest::Client::new() + .request(method.clone(), &url) + .bearer_auth("http-test-token") + .header("mcp-session-id", "00000000-dead-beef-0000-000000000000") + .send() + .await + .unwrap(); + assert_eq!( + response.status(), + reqwest::StatusCode::METHOD_NOT_ALLOWED, + "{method} must be rejected on a stateless endpoint" + ); + let allow = + response.headers().get("allow").and_then(|value| value.to_str().ok()).unwrap_or_default().to_string(); + assert!(allow.contains("POST"), "{method} Allow header was {allow:?}"); + } + // The DBX session the agent opened is independent of the transport and + // keeps working after those rejected verbs. let query = client .call_tool( CallToolRequestParams::new("dbx_execute_query").with_arguments( @@ -588,19 +508,6 @@ mod tests { .unwrap(); assert_ne!(query.is_error, Some(true)); assert_eq!(query.structured_content.as_ref().unwrap()["transaction_state"], "active"); - assert_eq!(manager.sessions.read().await.len(), 1); - assert_eq!(backend.disconnects.load(Ordering::SeqCst), 0); - assert!(!backend.sql.lock().unwrap().iter().any(|sql| sql == "ROLLBACK")); - - let response = reqwest::Client::new() - .delete(&url) - .bearer_auth("http-test-token") - .header("mcp-session-id", outer_session_id) - .send() - .await - .unwrap(); - assert!(response.status().is_success()); - assert_eq!(backend.disconnects.load(Ordering::SeqCst), 0); close_inner_session(&client, &inner_session_id).await; wait_for_disposal(&backend).await; @@ -609,14 +516,66 @@ mod tests { server_task.await.unwrap(); } - /// The same endpoint must serve both protocol generations: a legacy agent - /// gets the stateful `initialize` handshake and a session id, while a modern - /// agent gets stateless discovery on `2026-07-28` with no session at all. + /// The endpoint never hands out a session, for any protocol generation, so + /// there is no server-side session that a client could later find missing. #[tokio::test] - async fn http_serves_stateful_legacy_and_stateless_2026_agents() { + async fn no_protocol_generation_receives_a_session_id() { let backend = Arc::new(HttpTestBackend::new()); - let (url, manager, _sessions, cancellation, server_task) = - start_http_test_server(backend.clone(), std::time::Duration::from_secs(30)).await; + let (url, _sessions, cancellation, server_task) = start_http_test_server(backend.clone()).await; + + let initialize = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "legacy", "version": "0" } + } + }); + let response = reqwest::Client::new() + .post(&url) + .bearer_auth("http-test-token") + .header("accept", "application/json, text/event-stream") + .json(&initialize) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert!(!response.headers().contains_key("mcp-session-id"), "a stateless endpoint must not issue a session id"); + + // Reusing a stale session id on a later request is ignored rather than + // answered with `404 Session not found`. + let list_tools = serde_json::json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }); + let response = reqwest::Client::new() + .post(&url) + .bearer_auth("http-test-token") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", "00000000-dead-beef-0000-000000000000") + .json(&list_tools) + .send() + .await + .unwrap(); + assert_eq!( + response.status(), + reqwest::StatusCode::OK, + "a stale session id must not turn into 404 Session not found" + ); + let body = response.text().await.unwrap(); + assert!(body.contains("dbx_list_connections"), "tools/list did not answer: {body}"); + + cancellation.cancel(); + server_task.await.unwrap(); + } + + /// The same endpoint must serve both protocol generations. They differ only + /// in how they hand over the protocol version: a legacy agent runs the + /// `initialize` handshake, a modern one carries `2026-07-28` metadata per + /// request. Neither gets a transport session. + #[tokio::test] + async fn http_serves_both_protocol_generations_without_sessions() { + let backend = Arc::new(HttpTestBackend::new()); + let (url, _sessions, cancellation, server_task) = start_http_test_server(backend.clone()).await; let legacy = () .serve(StreamableHttpClientTransport::from_config( @@ -628,7 +587,6 @@ mod tests { assert_eq!(legacy_info.protocol_version, ProtocolVersion::V_2025_11_25); let listed = legacy.list_all_tools().await.expect("legacy tools/list"); assert!(!listed.is_empty()); - assert_eq!(manager.sessions.read().await.len(), 1, "legacy agents keep a stateful session"); // `Discover` mode never sends `initialize`, so this only succeeds if the // server answers `server/discover` for the modern lifecycle. @@ -645,7 +603,6 @@ mod tests { assert_eq!(modern_info.protocol_version, ProtocolVersion::V_2026_07_28); let modern_tools = modern.list_all_tools().await.expect("modern tools/list"); assert!(!modern_tools.is_empty()); - assert_eq!(manager.sessions.read().await.len(), 1, "2026-07-28 discovery stays stateless"); for (client, is_modern) in [(&legacy, false), (&modern, true)] { for (method, result) in [ @@ -672,7 +629,6 @@ mod tests { let _ = modern.cancel().await; cancellation.cancel(); server_task.await.unwrap(); - assert_eq!(manager.sessions.read().await.len(), 0, "both agents released their transport state"); } /// `2026-07-28` has no protocol-level session, so every request arrives at a @@ -682,8 +638,7 @@ mod tests { #[tokio::test] async fn stateless_2026_requests_share_db_session_state() { let backend = Arc::new(HttpTestBackend::new()); - let (url, _manager, _sessions, cancellation, server_task) = - start_http_test_server(backend.clone(), std::time::Duration::from_secs(30)).await; + let (url, _sessions, cancellation, server_task) = start_http_test_server(backend.clone()).await; let modern = ClientServiceExt::serve_with_lifecycle( (), @@ -747,8 +702,7 @@ mod tests { #[tokio::test] async fn http_service_shutdown_rolls_back_and_disconnects_inner_owner() { let backend = Arc::new(HttpTestBackend::new()); - let (url, _manager, _sessions, cancellation, server_task) = - start_http_test_server(backend.clone(), std::time::Duration::from_secs(30)).await; + let (url, _sessions, cancellation, server_task) = start_http_test_server(backend.clone()).await; let (client, _) = open_active_transaction(&url).await; cancellation.cancel(); diff --git a/docs/content/docs/mcp.cn.mdx b/docs/content/docs/mcp.cn.mdx index 0ed87a8f45..87916e5ed0 100644 --- a/docs/content/docs/mcp.cn.mdx +++ b/docs/content/docs/mcp.cn.mdx @@ -235,7 +235,7 @@ Resource 的发现和读取会复用对应 Tool 的白名单,以及连接、 该 opt-in 模式仅支持本地原生 MySQL 连接。DBX Web/`DBX_WEB_URL`、外部驱动 profile、兼容引擎及其他数据库类型会返回 `TRANSACTION_UNSUPPORTED`,不会静默退化为自动提交。事务会话每次操作只接受一条经过解析的 `SELECT`(含锁定读)、`INSERT`、`UPDATE`、`DELETE` 或 `REPLACE`;原始事务控制、DDL/临时 DDL、`CALL`、`LOAD`、表锁、`SET`、`USE`、XA、文件/输出子句、可执行注释、优化器 hint 及多语句均会被拒绝。完整批次持有同一个操作槽位,并为每条语句返回执行后的事务状态。 -响应包含 `transaction_state`(`idle`、`active` 或 `unknown`),并在适用时包含 `transaction_outcome`(`committed`、`rolled_back` 或 `unknown`)。普通 MySQL 语句错误可能仍让事务保持 active;DBX 会在同一连接上重新探测服务器状态后再返回。超时、取消、传输故障或丢失 `COMMIT` 确认会把结果永久标为 `unknown`,禁止该会话继续执行 SQL,并在不重放语句或 `COMMIT` 的前提下销毁连接。显式正值的连接查询超时同样用于事务操作;当连接配置为“无限制”(`0`)时,事务 owner 仍使用默认 300 秒操作上限,确保 busy 操作保持有界。关闭、过期和服务退出会对状态已知的 active 事务执行有界回滚,然后销毁连接。会话的回收途径是 `dbx_close_session`、空闲超时或 MCP 服务退出;用 `DELETE` 结束 HTTP 传输会话不会回收 Agent 通过它打开的有状态会话,而无状态的 `2026-07-28` Agent 本身就没有传输会话可结束。 +响应包含 `transaction_state`(`idle`、`active` 或 `unknown`),并在适用时包含 `transaction_outcome`(`committed`、`rolled_back` 或 `unknown`)。普通 MySQL 语句错误可能仍让事务保持 active;DBX 会在同一连接上重新探测服务器状态后再返回。超时、取消、传输故障或丢失 `COMMIT` 确认会把结果永久标为 `unknown`,禁止该会话继续执行 SQL,并在不重放语句或 `COMMIT` 的前提下销毁连接。显式正值的连接查询超时同样用于事务操作;当连接配置为“无限制”(`0`)时,事务 owner 仍使用默认 300 秒操作上限,确保 busy 操作保持有界。关闭、过期和服务退出会对状态已知的 active 事务执行有界回滚,然后销毁连接。会话的回收途径是 `dbx_close_session`、空闲超时或 MCP 服务退出。HTTP 传输层不持有任何会话,因此没有传输会话可供 `DELETE` 结束、也不会因空闲而丢失;承载状态的只有 `session_id` 句柄本身。 回滚保证依赖 InnoDB 等事务型存储。触发器、存储函数、非事务表及外部副作用可能产生 MySQL 无法回滚的效果。DBX 会在排队完成后重新检查当前连接/数据库范围、工具 allowlist、只读与生产保护以及已确认 SQL。即使写权限被撤销,仍允许原会话执行回滚和关闭以完成清理。 @@ -294,10 +294,12 @@ Desktop 可以选择直接托管 Streamable HTTP MCP 服务。该服务**默认 | Agent | 握手方式 | 传输状态 | | --- | --- | --- | -| 旧版客户端 | `initialize`(协议 `2025-11-25` 及更早) | 有状态:响应返回 `Mcp-Session-Id`,GET/DELETE 管理流 | -| 新版客户端 | `server/discover`(协议 `2026-07-28`) | 无状态:每个请求在 `_meta` 中自带协议版本与能力,不使用会话头 | +| 旧版客户端 | `initialize`(协议 `2025-11-25` 及更早) | 无状态:响应不返回 `Mcp-Session-Id`,每个请求独立处理 | +| 新版客户端 | `server/discover`(协议 `2026-07-28`) | 无状态:每个请求在 `_meta` 中自带协议版本与能力 | -协商到 `2026-07-28` 的新版 Agent 以无状态方式服务,同一监听端口上的旧版 Agent 仍保有会话。DBX 的有状态会话(`dbx_open_session`)在两种情况下都可用,因为 `session_id` 是由 Agent 在后续调用中显式回传的句柄。 +两代客户端都以无状态方式服务,因此端点只接受 `POST`:`GET` 与 `DELETE` 返回 `405 Method Not Allowed`。DBX 不依赖任何 HTTP 层会话,所以客户端无论空闲多久再连接都能正常工作,不会撞上已被服务端回收的会话。 + +DBX 的有状态会话在两种情况下都可用,因为它是**显式句柄**:`dbx_open_session` 返回的 `session_id` 由 Agent 在后续调用中作为普通工具参数回传。该句柄独立于传输层、有自己的空闲 TTL,负责让固定连接、`USE`/`SET` 状态、临时表或已开启的事务跨请求保持。 ### 本地连接 diff --git a/docs/content/docs/mcp.mdx b/docs/content/docs/mcp.mdx index ae77cab727..cebc2700be 100644 --- a/docs/content/docs/mcp.mdx +++ b/docs/content/docs/mcp.mdx @@ -235,7 +235,7 @@ Set `enable_transactions: true` when calling `dbx_open_session` to reserve one p This opt-in mode is available only for local native MySQL connections. DBX Web/`DBX_WEB_URL`, external driver profiles, compatibility engines, and other database types return `TRANSACTION_UNSUPPORTED` instead of falling back to auto-commit. Transaction-session SQL accepts one parsed `SELECT` (including locking reads), `INSERT`, `UPDATE`, `DELETE`, or `REPLACE` statement per operation. It rejects raw transaction control, DDL and temporary DDL, `CALL`, `LOAD`, table locks, `SET`, `USE`, XA, file/output clauses, executable comments, optimizer hints, and multi-statements. Complete batches hold one operation slot and report the resulting transaction state for every statement. -Responses include `transaction_state` (`idle`, `active`, or `unknown`) and, when known, `transaction_outcome` (`committed`, `rolled_back`, or `unknown`). A normal MySQL statement error can leave the transaction active; DBX probes fresh state on the same connection before reporting it. A timeout, cancellation, transport failure, or lost `COMMIT` acknowledgement makes the outcome `unknown`, permanently blocks more SQL on that session, and disposes the connection without replaying the statement or `COMMIT`. A positive connection query timeout applies to transaction operations; when the connection is configured as unlimited (`0`), transaction owners still apply their default 300-second operation cap so busy work remains bounded. Closing, expiry, and server shutdown attempt bounded rollback for a known active transaction and then dispose the connection. Sessions are reclaimed by `dbx_close_session`, by the idle timeout, or by MCP server shutdown; ending an HTTP transport session with `DELETE` does not reclaim the stateful session an agent opened through it, and a stateless `2026-07-28` agent has no transport session to end. +Responses include `transaction_state` (`idle`, `active`, or `unknown`) and, when known, `transaction_outcome` (`committed`, `rolled_back`, or `unknown`). A normal MySQL statement error can leave the transaction active; DBX probes fresh state on the same connection before reporting it. A timeout, cancellation, transport failure, or lost `COMMIT` acknowledgement makes the outcome `unknown`, permanently blocks more SQL on that session, and disposes the connection without replaying the statement or `COMMIT`. A positive connection query timeout applies to transaction operations; when the connection is configured as unlimited (`0`), transaction owners still apply their default 300-second operation cap so busy work remains bounded. Closing, expiry, and server shutdown attempt bounded rollback for a known active transaction and then dispose the connection. Sessions are reclaimed by `dbx_close_session`, by the idle timeout, or by MCP server shutdown. The HTTP transport keeps no session of its own, so there is no transport session for `DELETE` to end or for an idle client to lose; the `session_id` handle is the only thing that carries state. Rollback guarantees require transactional storage such as InnoDB. Triggers, stored functions, nontransactional tables, and external side effects can have effects that MySQL cannot roll back. DBX rechecks the current connection/database scope, tool allowlist, read-only and production protections, and confirmed SQL after queue waiting. Rollback and close remain available for cleanup after write permission is revoked. @@ -294,10 +294,12 @@ DBX answers both MCP protocol generations on the same endpoint, so any agent can | Agent | Handshake | Transport state | | --- | --- | --- | -| Older clients | `initialize` (protocol `2025-11-25` and earlier) | Stateful: the response carries `Mcp-Session-Id`, and GET/DELETE manage the stream | -| Modern clients | `server/discover` (protocol `2026-07-28`) | Stateless: every request carries its own protocol version and capabilities in `_meta`, with no session header | +| Older clients | `initialize` (protocol `2025-11-25` and earlier) | Stateless: the response carries no `Mcp-Session-Id`, and every request is served on its own | +| Modern clients | `server/discover` (protocol `2026-07-28`) | Stateless: every request carries its own protocol version and capabilities in `_meta` | -A modern agent negotiating `2026-07-28` is served statelessly while an older agent keeps its session on the same listener. Stateful DBX sessions (`dbx_open_session`) work in both cases because the session id is an explicit handle that the agent passes back on later calls. +Both generations are served statelessly, so the endpoint accepts `POST` only: `GET` and `DELETE` return `405 Method Not Allowed`. Nothing in DBX depends on an HTTP-level session, which is why a client that reconnects after any idle period simply works instead of running into a session the server already dropped. + +Stateful DBX sessions still work in both cases, because they are explicit handles: `dbx_open_session` returns a `session_id` that the agent passes back as an ordinary tool argument on later calls. That handle is independent of the transport, has its own idle TTL, and is what keeps a pinned connection, `USE`/`SET` state, temporary tables, or an open transaction alive across requests. ### Local connections