fix(mcp): report unopenable store in protocol errors

This commit is contained in:
BASIL K AJI
2026-10-04 03:15:54 +08:00
committed by GitHub
parent 56b887d346
commit 238cf81370
3 changed files with 133 additions and 7 deletions
+120
View File
@@ -214,6 +214,68 @@ pub struct DocsSnapshotOptions {
pub project_name: Option<String>,
}
/// 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<String>) -> Self {
Self { reason: reason.into() }
}
}
#[async_trait]
impl DbxBackend for UnavailableBackend {
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
Err(self.reason.clone())
}
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, 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<ConnectionConfig, String> {
Err(self.reason.clone())
}
async fn duplicate_connection_for_mcp(
&self,
_source_id: &str,
_copy_id: &str,
_copy_name: &str,
) -> Result<ConnectionConfig, String> {
Err(self.reason.clone())
}
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
Err(self.reason.clone())
}
}
#[async_trait]
pub trait DbxBackend: Send + Sync {
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String>;
@@ -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::*;
+1 -1
View File
@@ -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;
+12 -6
View File
@@ -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<dyn std::error::Error>> {
)
} 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 => {