From 238cf81370d98ed07a6bd805167654f07f3ef468 Mon Sep 17 00:00:00 2001 From: BASIL K AJI Date: Sun, 4 Oct 2026 00:45:54 +0530 Subject: [PATCH] fix(mcp): report unopenable store in protocol errors --- crates/dbx-mcp/src/backend.rs | 120 ++++++++++++++++++++++++++++++++++ crates/dbx-mcp/src/lib.rs | 2 +- crates/dbx-mcp/src/main.rs | 18 +++-- 3 files changed, 133 insertions(+), 7 deletions(-) diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index 762158e445..d87b95fac4 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -214,6 +214,68 @@ pub struct DocsSnapshotOptions { pub project_name: Option, } +/// Stand-in backend for a store that could not be opened, most often because the +/// data encryption key is not reachable from this process. +/// +/// Exiting at startup closes stdout before the transport is running, so an MCP +/// client sees only EOF and the remedy stays on stderr where the host does not +/// show it. Keeping the server answerable turns that into an ordinary protocol +/// error: every request fails closed, carrying the reason the operator needs. +pub struct UnavailableBackend { + reason: String, +} + +impl UnavailableBackend { + pub fn new(reason: impl Into) -> Self { + Self { reason: reason.into() } + } +} + +#[async_trait] +impl DbxBackend for UnavailableBackend { + async fn load_mcp_global_policy(&self) -> Result { + Err(self.reason.clone()) + } + + async fn load_connections(&self) -> Result, String> { + Err(self.reason.clone()) + } + + async fn execute_agent_tool( + &self, + _connection: &ConnectionConfig, + _database: &str, + tool_name: &str, + _arguments: Value, + _permissions: AgentSqlPermissions, + ) -> ToolResult { + ToolResult { + tool_call_id: String::new(), + tool_name: tool_name.to_string(), + content: self.reason.clone(), + is_error: true, + explain_data: None, + } + } + + async fn add_connection_for_mcp(&self, _config: ConnectionConfig) -> Result { + Err(self.reason.clone()) + } + + async fn duplicate_connection_for_mcp( + &self, + _source_id: &str, + _copy_id: &str, + _copy_name: &str, + ) -> Result { + Err(self.reason.clone()) + } + + async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result { + Err(self.reason.clone()) + } +} + #[async_trait] pub trait DbxBackend: Send + Sync { async fn load_mcp_global_policy(&self) -> Result; @@ -2809,6 +2871,64 @@ pub fn new_connection_config( .map_err(|error| error.to_string()) } +#[cfg(test)] +mod unavailable_backend_tests { + use super::*; + + const REASON: &str = "SECRET_KEY_UNAVAILABLE: this process cannot read the DBX data encryption key"; + + fn sample_connection() -> ConnectionConfig { + new_connection_config( + "source".into(), + "Source".into(), + DatabaseType::Postgres, + "127.0.0.1".into(), + 5432, + "postgres".into(), + "test-password".into(), + None, + false, + None, + ) + .unwrap() + } + + // The server used to exit before the transport started, so the client saw + // EOF and this text never left stderr. Serving it means every entry point + // has to carry it rather than failing blank. + #[tokio::test] + async fn every_required_entry_point_reports_the_reason() { + let backend = UnavailableBackend::new(REASON); + + assert_eq!(backend.load_mcp_global_policy().await.unwrap_err(), REASON); + assert_eq!(backend.load_connections().await.unwrap_err(), REASON); + assert_eq!(backend.remove_connection_for_mcp("any").await.unwrap_err(), REASON); + assert_eq!(backend.duplicate_connection_for_mcp("source", "copy", "Copy").await.unwrap_err(), REASON); + assert_eq!(backend.add_connection_for_mcp(sample_connection()).await.unwrap_err(), REASON); + } + + // A tool call answers in band: is_error set, reason in the content, and the + // tool name preserved so the client can tell which call failed. + #[tokio::test] + async fn an_agent_tool_call_fails_closed_with_the_reason() { + let backend = UnavailableBackend::new(REASON); + + let result = backend + .execute_agent_tool( + &sample_connection(), + "db", + "execute_query", + Value::Null, + AgentSqlPermissions { allow_writes: false, allow_dangerous: false, confirmed_write_sql: None }, + ) + .await; + + assert!(result.is_error); + assert_eq!(result.content, REASON); + assert_eq!(result.tool_name, "execute_query"); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/dbx-mcp/src/lib.rs b/crates/dbx-mcp/src/lib.rs index 62cbd62c3a..6eb93b1265 100644 --- a/crates/dbx-mcp/src/lib.rs +++ b/crates/dbx-mcp/src/lib.rs @@ -12,7 +12,7 @@ pub mod session; pub mod transaction; pub mod transport; -pub use backend::{ConnectionSummary, DbxBackend, LocalBackend, WebBackend}; +pub use backend::{ConnectionSummary, DbxBackend, LocalBackend, UnavailableBackend, WebBackend}; pub use dbx_core::mongo_shell as mongo; pub use http::{serve_streamable_http_on_listener, serve_streamable_http_with_shutdown, streamable_http_router}; pub use http_auth::HttpAuth; diff --git a/crates/dbx-mcp/src/main.rs b/crates/dbx-mcp/src/main.rs index c483cac4ca..6ad13ef1d6 100644 --- a/crates/dbx-mcp/src/main.rs +++ b/crates/dbx-mcp/src/main.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use dbx_mcp::{ http::serve_streamable_http, with_legacy_discovery_fallback, DbxBackend, DbxMcpServer, LocalBackend, McpTransport, - RuntimeConfig, WebBackend, + RuntimeConfig, UnavailableBackend, WebBackend, }; use rmcp::ServiceExt; @@ -47,11 +47,17 @@ async fn run() -> Result<(), Box> { ) } else { let db_path = dbx_mcp::paths::storage_db_path().map_err(std::io::Error::other)?; - Arc::new( - LocalBackend::open(&db_path) - .await - .map_err(|error| std::io::Error::other(credentials::startup_error(error)))?, - ) + match LocalBackend::open(&db_path).await { + Ok(local) => Arc::new(local), + // Returning here would close stdout before the transport starts, so the + // client sees only EOF and this message stays on stderr. Serve the + // reason instead: every request fails closed and carries it. + Err(error) => { + let reason = credentials::startup_error(error); + eprintln!("Error: {reason}"); + Arc::new(UnavailableBackend::new(reason)) + } + } }; match runtime.transport { McpTransport::Stdio => {