From 396ebf8393a34eb3e2e9701040ce8dc594d68d2c Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 12 Aug 2026 21:49:52 +0000 Subject: [PATCH] feat(mcp): duplicate complete connection configs Closes #4192 --- crates/dbx-core/src/storage.rs | 110 ++++++++++++++ crates/dbx-mcp/src/backend.rs | 42 ++++++ crates/dbx-mcp/src/server.rs | 91 +++++++++++- crates/dbx-mcp/tests/local.rs | 188 +++++++++++++++++++++++- crates/dbx-mcp/tests/protocol.rs | 45 +++++- crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/connection.rs | 64 +++++++- 7 files changed, 535 insertions(+), 6 deletions(-) diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 415261561c..47ee9de46b 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -2288,6 +2288,60 @@ fn persist_connection_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionC persist_nacos_auth_secrets_in_tx(tx, &config) } +fn insert_connection_copy_next_to_source(entries: &mut Vec, source_id: &str, copy_id: &str) -> bool { + let mut index = 0; + while index < entries.len() { + let entry_type = entries[index].get("type").and_then(serde_json::Value::as_str); + if entry_type == Some("connection") + && entries[index].get("id").and_then(serde_json::Value::as_str) == Some(source_id) + { + entries.insert(index + 1, serde_json::json!({ "type": "connection", "id": copy_id })); + return true; + } + if entry_type == Some("group") { + if let Some(children) = entries[index].get_mut("children").and_then(serde_json::Value::as_array_mut) { + if insert_connection_copy_next_to_source(children, source_id, copy_id) { + return true; + } + } else if let Some(connection_ids) = + entries[index].get_mut("connectionIds").and_then(serde_json::Value::as_array_mut) + { + if let Some(source_index) = connection_ids.iter().position(|id| id.as_str() == Some(source_id)) { + connection_ids.insert(source_index + 1, serde_json::Value::String(copy_id.to_string())); + return true; + } + } + } + index += 1; + } + false +} + +fn copy_sidebar_layout_entry_in_tx( + tx: &rusqlite::Transaction<'_>, + source_id: &str, + copy_id: &str, +) -> Result<(), String> { + let Some(layout_json) = tx + .query_row("SELECT layout_json FROM sidebar_layout WHERE id = 1", [], |row| row.get::<_, String>(0)) + .optional() + .map_err(|error| error.to_string())? + else { + return Ok(()); + }; + let mut layout: serde_json::Value = serde_json::from_str(&layout_json).map_err(|error| error.to_string())?; + let Some(order) = layout.get_mut("order").and_then(serde_json::Value::as_array_mut) else { + return Err("INVALID_SIDEBAR_LAYOUT: sidebar order is not an array".to_string()); + }; + if !insert_connection_copy_next_to_source(order, source_id, copy_id) { + order.push(serde_json::json!({ "type": "connection", "id": copy_id })); + } + let updated = serde_json::to_string(&layout).map_err(|error| error.to_string())?; + tx.execute("UPDATE sidebar_layout SET layout_json = ?1 WHERE id = 1", [updated]) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + fn preserve_unreadable_connections_for_replacement( tx: &rusqlite::Transaction<'_>, replacement_ids: &HashSet, @@ -2410,6 +2464,62 @@ impl Storage { .await } + pub async fn duplicate_connection_for_mcp( + &self, + source_id: &str, + copy_id: &str, + copy_name: &str, + ) -> Result { + let source_id = source_id.to_string(); + let copy_id = copy_id.to_string(); + let copied_id = copy_id.clone(); + let copy_name = copy_name.to_string(); + self.with_conn(move |conn| { + let tx = conn.transaction().map_err(|error| error.to_string())?; + ensure_mcp_connection_change_allowed_in_tx(&tx, Some(&source_id))?; + let copy_name_lower = copy_name.to_lowercase(); + let mut names = tx.prepare("SELECT config_json FROM connections").map_err(|error| error.to_string())?; + let duplicate_name = names + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| error.to_string())? + .filter_map(Result::ok) + .filter_map(|json| serde_json::from_str::(&json).ok()) + .any(|connection| connection.name.to_lowercase() == copy_name_lower); + drop(names); + if duplicate_name { + return Err(format!("CONNECTION_ALREADY_EXISTS: connection '{copy_name}' already exists")); + } + let source_json = tx + .query_row("SELECT config_json FROM connections WHERE id = ?1", [&source_id], |row| { + row.get::<_, String>(0) + }) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("CONNECTION_NOT_FOUND: connection '{source_id}' was not found"))?; + let mut copy: ConnectionConfig = serde_json::from_str(&source_json).map_err(|error| error.to_string())?; + copy.id = copy_id.clone(); + copy.name = copy_name; + let copy_json = serde_json::to_string(©).map_err(|error| error.to_string())?; + tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![copy_id, copy_json]) + .map_err(|error| error.to_string())?; + tx.execute( + "INSERT INTO connection_secrets (connection_id, key, secret) \ + SELECT ?1, key, secret FROM connection_secrets WHERE connection_id = ?2", + params![copy.id, source_id], + ) + .map_err(|error| error.to_string())?; + copy_sidebar_layout_entry_in_tx(&tx, &source_id, ©.id)?; + tx.commit().map_err(|error| error.to_string())?; + Ok(copy) + }) + .await?; + self.load_connections() + .await? + .into_iter() + .find(|connection| connection.id == copied_id) + .ok_or_else(|| "CONNECTION_SAVE_ERROR: copied connection could not be reloaded".to_string()) + } + pub async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result { let connection_id = connection_id.to_string(); self.with_conn(move |conn| { diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index 84e980c12d..a1e65232cd 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -184,6 +184,12 @@ pub trait DbxBackend: Send + Sync { Err("SQL queries are not supported by this backend.".to_string()) } async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result; + async fn duplicate_connection_for_mcp( + &self, + source_id: &str, + copy_id: &str, + copy_name: &str, + ) -> Result; async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result; async fn list_tables( &self, @@ -532,6 +538,17 @@ impl DbxBackend for LocalBackend { Ok(config) } + async fn duplicate_connection_for_mcp( + &self, + source_id: &str, + copy_id: &str, + copy_name: &str, + ) -> Result { + let config = self.state.storage.duplicate_connection_for_mcp(source_id, copy_id, copy_name).await?; + self.state.configs.write().await.insert(config.id.clone(), config.clone()); + Ok(config) + } + async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result { let removed = self.state.storage.remove_connection_for_mcp(connection_id).await?; if removed { @@ -784,6 +801,23 @@ impl DbxBackend for WebBackend { .map_err(|error| format!("Invalid MCP connection response: {error}")) } + async fn duplicate_connection_for_mcp( + &self, + source_id: &str, + copy_id: &str, + copy_name: &str, + ) -> Result { + self.request( + reqwest::Method::POST, + "/api/connection/mcp/duplicate", + Some(json!({ "sourceId": source_id, "copyId": copy_id, "copyName": copy_name })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid MCP connection response: {error}")) + } + async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result { let removed = self .request( @@ -2018,6 +2052,14 @@ mod tests { async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result { Ok(config) } + async fn duplicate_connection_for_mcp( + &self, + _source_id: &str, + _copy_id: &str, + _copy_name: &str, + ) -> Result { + Err("unused".to_string()) + } async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result { Ok(false) } diff --git a/crates/dbx-mcp/src/server.rs b/crates/dbx-mcp/src/server.rs index 3fdfe370be..d44398560e 100644 --- a/crates/dbx-mcp/src/server.rs +++ b/crates/dbx-mcp/src/server.rs @@ -111,6 +111,14 @@ pub struct AddConnectionRequest { pub driver_profile: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DuplicateConnectionRequest { + #[serde(flatten)] + pub selector: ConnectionSelector, + #[schemars(description = "Name for the copied connection")] + pub new_name: String, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct RemoveConnectionRequest { pub connection_name: String, @@ -218,6 +226,7 @@ impl DbxMcpServer { let mut tool_router = Self::tool_router(); if scope.enabled() { tool_router.disable_route("dbx_add_connection"); + tool_router.disable_route("dbx_duplicate_connection"); tool_router.disable_route("dbx_remove_connection"); } // Desktop UI bridge operations are intentionally unavailable remotely and in scoped AI sessions. @@ -648,6 +657,67 @@ impl DbxMcpServer { } } + #[tool( + name = "dbx_duplicate_connection", + description = "Duplicate a DBX connection with its complete settings, credentials, tunnels, and sidebar group" + )] + async fn duplicate_connection( + &self, + Parameters(request): Parameters, + ) -> CallToolResult { + let policy = match self.load_policy().await { + Ok(policy) => policy, + Err(error) => return error, + }; + if policy.read_only { + return tool_error( + "MCP_READ_ONLY", + "DBX global MCP read-only mode is enabled. Connection management is not allowed.", + ); + } + let connections = match self.backend.load_connections().await { + Ok(connections) => connections, + Err(error) => return tool_error("CONNECTION_LOAD_ERROR", error), + }; + let allowed = connections + .iter() + .filter(|connection| policy_allows_connection(&policy, connection)) + .cloned() + .collect::>(); + let source = + if let Some(id) = request.selector.connection_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) { + allowed.iter().find(|connection| connection.id == id).cloned() + } else if let Some(name) = + request.selector.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) + { + let matching = allowed + .iter() + .filter(|connection| connection.name.eq_ignore_ascii_case(name)) + .cloned() + .collect::>(); + if matching.len() > 1 { + return tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(name, &matching)); + } + matching.into_iter().next() + } else { + return tool_error("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required."); + }; + let Some(source) = source else { + return tool_error("CONNECTION_NOT_FOUND", "The source connection was not found or is outside MCP scope."); + }; + let new_name = request.new_name.trim(); + if new_name.is_empty() { + return tool_error("INVALID_CONNECTION", "The copied connection name must not be empty."); + } + if connections.iter().any(|connection| connection.name.eq_ignore_ascii_case(new_name)) { + return tool_error("CONNECTION_ALREADY_EXISTS", format!("Connection \"{new_name}\" already exists.")); + } + match self.backend.duplicate_connection_for_mcp(&source.id, &Uuid::new_v4().to_string(), new_name).await { + Ok(copy) => text(format!("Connection \"{}\" duplicated (id: {}).", copy.name, copy.id)), + Err(error) => backend_tool_error("CONNECTION_SAVE_ERROR", error), + } + } + #[tool(name = "dbx_remove_connection", description = "Remove a database connection from DBX")] async fn remove_connection(&self, Parameters(request): Parameters) -> CallToolResult { let policy = match self.load_policy().await { @@ -1348,6 +1418,23 @@ mod tests { Ok(config) } + async fn duplicate_connection_for_mcp( + &self, + source_id: &str, + copy_id: &str, + copy_name: &str, + ) -> Result { + let mut copy = self + .connections + .iter() + .find(|connection| connection.id == source_id) + .cloned() + .ok_or_else(|| "source not found".to_string())?; + copy.id = copy_id.to_string(); + copy.name = copy_name.to_string(); + Ok(copy) + } + async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result { Ok(true) } @@ -1374,12 +1461,13 @@ mod tests { let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false); let tools = server.tool_router.list_all(); let names = tools.iter().map(|tool| tool.name.as_ref()).collect::>(); - assert_eq!(tools.len(), 12); + assert_eq!(tools.len(), 13); assert!(names.contains(&"dbx_list_connections")); assert!(names.contains(&"dbx_list_tables")); assert!(names.contains(&"dbx_describe_table")); assert!(names.contains(&"dbx_execute_query")); assert!(names.contains(&"dbx_add_connection")); + assert!(names.contains(&"dbx_duplicate_connection")); assert!(names.contains(&"dbx_remove_connection")); assert!(names.contains(&"dbx_execute_redis_command")); assert!(names.contains(&"dbx_get_schema_context")); @@ -1399,6 +1487,7 @@ mod tests { let names = server.tool_router.list_all().into_iter().map(|tool| tool.name).collect::>(); assert_eq!(names.len(), 8); assert!(!names.iter().any(|name| name == "dbx_add_connection")); + assert!(!names.iter().any(|name| name == "dbx_duplicate_connection")); assert!(!names.iter().any(|name| name == "dbx_remove_connection")); assert!(!names.iter().any(|name| name == "dbx_open_table")); assert!(!names.iter().any(|name| name == "dbx_execute_and_show")); diff --git a/crates/dbx-mcp/tests/local.rs b/crates/dbx-mcp/tests/local.rs index 0a6f0ab506..3c4da6b6a0 100644 --- a/crates/dbx-mcp/tests/local.rs +++ b/crates/dbx-mcp/tests/local.rs @@ -84,6 +84,192 @@ async fn local_backend_reads_dbx_storage_without_desktop_process() { server_task.abort(); } +#[tokio::test] +async fn duplicate_connection_preserves_secrets_ssh_and_sidebar_group() { + let directory = tempdir().expect("temporary data directory"); + let db_path = directory.path().join("dbx.db"); + let storage = Storage::open(&db_path).await.expect("open storage"); + storage + .save_mcp_global_policy(&dbx_core::storage::McpGlobalPolicy { + read_only: false, + allow_dangerous_sql: false, + allowed_connection_ids: None, + }) + .await + .expect("enable MCP connection management"); + let source: ConnectionConfig = serde_json::from_value(json!({ + "id": "source", + "name": "production-through-bastion", + "note": "full configuration must survive", + "db_type": "postgres", + "driver_profile": "postgres-42.7", + "host": "db.internal", + "port": 5432, + "username": "app", + "password": "database-secret", + "database": "app", + "default_schema": "private", + "url_params": "application_name=dbx", + "connection_string": "postgres://app:database-secret@db.internal/app", + "init_script": "SET application_name = 'dbx-secret-script'", + "save_password": true, + "ssl": true, + "transport_layers": [{ + "type": "ssh", + "id": "bastion", + "name": "Bastion", + "enabled": true, + "host": "bastion.internal", + "port": 22, + "user": "deploy", + "password": "ssh-secret", + "key_path": "/keys/deploy", + "key_passphrase": "key-secret", + "auth_method": "key+password" + }] + })) + .expect("full source connection"); + let unrelated: ConnectionConfig = serde_json::from_value(json!({ + "id": "unrelated", + "name": "unrelated", + "db_type": "sqlite", + "host": ":memory:", + "port": 0, + "username": "", + "password": "", + "ssl": false + })) + .expect("unrelated connection"); + storage.save_connections(&[source.clone(), unrelated]).await.expect("save source connections"); + storage + .save_sidebar_layout(&json!({ + "groups": [ + { "id": "project", "name": "Project", "collapsed": false }, + { "id": "production", "name": "Production", "collapsed": false } + ], + "order": [{ + "type": "group", + "id": "project", + "children": [{ + "type": "group", + "id": "production", + "children": [{ "type": "connection", "id": "source" }] + }] + }, { "type": "connection", "id": "unrelated" }], + "futureField": { "preserved": true } + })) + .await + .expect("save sidebar layout"); + + let backend = Arc::new(LocalBackend::open(&db_path).await.expect("open local backend")); + let policy = backend.load_mcp_global_policy().await.expect("load configured policy"); + assert!(!policy.read_only); + let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false); + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + let server_task = tokio::spawn(async move { server.serve(server_transport).await }); + let client = ().serve(client_transport).await.expect("initialize client"); + let arguments = json!({ + "connection_id": "source", + "new_name": "production-through-bastion-copy" + }) + .as_object() + .cloned() + .unwrap_or_else(Map::new); + let result = client + .peer() + .call_tool(CallToolRequestParams::new("dbx_duplicate_connection").with_arguments(arguments.clone())) + .await + .expect("duplicate connection"); + assert_ne!(result.is_error, Some(true), "unexpected duplicate error: {:?}", result.content); + + let duplicate_again = client + .peer() + .call_tool(CallToolRequestParams::new("dbx_duplicate_connection").with_arguments(arguments)) + .await + .expect("reject duplicate target name"); + assert_eq!(duplicate_again.is_error, Some(true)); + assert!(duplicate_again.content[0] + .as_text() + .expect("duplicate error text") + .text + .contains("CONNECTION_ALREADY_EXISTS")); + + let missing_arguments = json!({ + "connection_id": "missing", + "new_name": "must-not-be-created" + }) + .as_object() + .cloned() + .unwrap_or_else(Map::new); + let missing = client + .peer() + .call_tool(CallToolRequestParams::new("dbx_duplicate_connection").with_arguments(missing_arguments)) + .await + .expect("reject missing source"); + assert_eq!(missing.is_error, Some(true)); + assert!(missing.content[0].as_text().expect("missing error text").text.contains("CONNECTION_NOT_FOUND")); + + let root_arguments = json!({ + "connection_id": "unrelated", + "new_name": "unrelated-copy" + }) + .as_object() + .cloned() + .unwrap_or_else(Map::new); + let root_copy = client + .peer() + .call_tool(CallToolRequestParams::new("dbx_duplicate_connection").with_arguments(root_arguments)) + .await + .expect("duplicate root connection"); + assert_ne!(root_copy.is_error, Some(true)); + + storage + .save_mcp_global_policy(&dbx_core::storage::McpGlobalPolicy { + read_only: true, + allow_dangerous_sql: false, + allowed_connection_ids: None, + }) + .await + .expect("enable MCP read-only policy"); + let read_only_arguments = json!({ + "connection_id": "source", + "new_name": "must-not-pass-read-only" + }) + .as_object() + .cloned() + .unwrap_or_else(Map::new); + let read_only = client + .peer() + .call_tool(CallToolRequestParams::new("dbx_duplicate_connection").with_arguments(read_only_arguments)) + .await + .expect("reject read-only duplicate"); + assert_eq!(read_only.is_error, Some(true)); + assert!(read_only.content[0].as_text().expect("read-only error text").text.contains("MCP_READ_ONLY")); + client.cancel().await.expect("close client"); + server_task.abort(); + + let reopened = Storage::open(&db_path).await.expect("reopen storage"); + let connections = reopened.load_connections().await.expect("reload connections"); + assert_eq!(connections.len(), 4, "failed duplicates must not add connections"); + let copied = connections + .iter() + .find(|connection| connection.name == "production-through-bastion-copy") + .expect("persisted copied connection"); + let mut expected = source.clone(); + expected.id = copied.id.clone(); + expected.name = copied.name.clone(); + assert_ne!(copied.id, source.id); + assert_eq!(copied, &expected); + let root_copy = connections.iter().find(|connection| connection.name == "unrelated-copy").expect("root copy"); + + let layout = reopened.load_sidebar_layout().await.expect("load copied layout").expect("sidebar layout"); + assert_eq!(layout["futureField"]["preserved"], true); + assert_eq!(layout["order"][0]["children"][0]["children"][0]["id"], "source"); + assert_eq!(layout["order"][0]["children"][0]["children"][1]["id"], copied.id); + assert_eq!(layout["order"][1]["id"], "unrelated"); + assert_eq!(layout["order"][2]["id"], root_copy.id); +} + #[tokio::test] async fn local_backend_picks_up_connections_added_after_startup_without_reload() { // Regression for issue #5428: after an agent connects to MCP, a connection created in the DBX @@ -192,7 +378,6 @@ async fn local_backend_uses_the_installed_duckdb_sidecar() { #[tokio::test] async fn legacy_read_only_config_applies_before_settings_are_opened() { - let _allow_writes = EnvVarGuard::set("DBX_MCP_ALLOW_WRITES", "0"); let directory = tempdir().expect("temporary data directory"); let db_path = directory.path().join("dbx.db"); let storage = Storage::open(&db_path).await.expect("open storage"); @@ -210,6 +395,7 @@ async fn legacy_read_only_config_applies_before_settings_are_opened() { })) .expect("minimal connection config"); storage.save_connections(&[connection]).await.expect("save connection"); + let _allow_writes = EnvVarGuard::set("DBX_MCP_ALLOW_WRITES", "0"); let backend = Arc::new(LocalBackend::open(&db_path).await.expect("open local backend")); let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false); diff --git a/crates/dbx-mcp/tests/protocol.rs b/crates/dbx-mcp/tests/protocol.rs index 4d91ad58bf..c04b67dedc 100644 --- a/crates/dbx-mcp/tests/protocol.rs +++ b/crates/dbx-mcp/tests/protocol.rs @@ -42,6 +42,15 @@ impl DbxBackend for EmptyBackend { Ok(config) } + async fn duplicate_connection_for_mcp( + &self, + _source_id: &str, + _copy_id: &str, + _copy_name: &str, + ) -> Result { + Err("not exercised".to_string()) + } + async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result { Ok(true) } @@ -88,6 +97,15 @@ impl DbxBackend for PolicyBackend { Ok(config) } + async fn duplicate_connection_for_mcp( + &self, + _source_id: &str, + _copy_id: &str, + _copy_name: &str, + ) -> Result { + Err("not exercised".to_string()) + } + async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result { Ok(true) } @@ -117,8 +135,9 @@ async fn initializes_lists_tools_and_calls_a_tool() { let tools = client.peer().list_tools(None).await.expect("list tools"); let names = tools.tools.iter().map(|tool| tool.name.as_ref()).collect::>(); - assert_eq!(names.len(), 12); + assert_eq!(names.len(), 13); assert!(names.contains(&"dbx_list_connections")); + assert!(names.contains(&"dbx_duplicate_connection")); assert!(names.contains(&"dbx_execute_redis_command")); assert!(names.contains(&"dbx_execute_and_show")); assert!(names.contains(&"dbx_open_session")); @@ -194,6 +213,30 @@ async fn enforces_global_connection_scope_and_read_only_policy() { server_task.abort(); } +#[tokio::test] +async fn duplicate_connection_rejects_ambiguous_source_names() { + let backend = PolicyBackend { + policy: McpGlobalPolicy::default(), + connections: vec![test_connection("first", "shared"), test_connection("second", "shared")], + group_paths: Ok(HashMap::new()), + }; + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + let server = DbxMcpServer::with_runtime_options(Arc::new(backend), McpScope::default(), false); + let server_task = tokio::spawn(async move { server.serve(server_transport).await }); + let client = ().serve(client_transport).await.expect("initialize MCP client"); + let result = client + .peer() + .call_tool(CallToolRequestParams::new("dbx_duplicate_connection").with_arguments( + json!({ "connection_name": "shared", "new_name": "copy" }).as_object().cloned().unwrap_or_else(Map::new), + )) + .await + .expect("call duplicate connection"); + assert_eq!(result.is_error, Some(true)); + assert!(result.content[0].as_text().expect("ambiguous result").text.contains("AMBIGUOUS_CONNECTION")); + client.cancel().await.expect("close MCP client"); + server_task.abort(); +} + #[tokio::test] async fn connection_group_path_failure_preserves_connection_listing() { let backend = PolicyBackend { diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index beb2543921..c4e40f316b 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -338,6 +338,7 @@ async fn main() { .route("/connection/save", post(routes::connection::save_connections)) .route("/connection/list", get(routes::connection::load_connections)) .route("/connection/mcp/add", post(routes::connection::mcp_add_connection)) + .route("/connection/mcp/duplicate", post(routes::connection::mcp_duplicate_connection)) .route("/connection/mcp/remove", post(routes::connection::mcp_remove_connection)) .route("/plugins", get(routes::plugins::list_plugins)) // JDBC diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index f5a5831a22..9c55c6a7d2 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -60,6 +60,14 @@ pub struct McpAddConnectionRequest { pub config: ConnectionConfig, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpDuplicateConnectionRequest { + pub source_id: String, + pub copy_id: String, + pub copy_name: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpRemoveConnectionRequest { @@ -412,6 +420,20 @@ pub async fn mcp_add_connection( Ok(Json(saved)) } +pub async fn mcp_duplicate_connection( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + let saved = state + .app + .storage + .duplicate_connection_for_mcp(&body.source_id, &body.copy_id, &body.copy_name) + .await + .map_err(AppError::from)?; + state.app.configs.write().await.insert(saved.id.clone(), saved.clone()); + Ok(Json(saved)) +} + pub async fn mcp_remove_connection( State(state): State>, Json(body): Json, @@ -519,9 +541,10 @@ async fn remove_connection_pools_for_connection_ids(state: &WebState, connection mod tests { use super::{ apply_mongo_legacy_driver_profile, connect_db, connection_final_proxy_port, disconnect_db, load_connections, - mark_mongo_legacy_driver, mcp_add_connection, mcp_remove_connection, run_temporary_connection_test, - save_connection_database_info, save_connections, test_connection, test_connection_with_info, ConnectRequest, - DisconnectRequest, McpAddConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest, + mark_mongo_legacy_driver, mcp_add_connection, mcp_duplicate_connection, mcp_remove_connection, + run_temporary_connection_test, save_connection_database_info, save_connections, test_connection, + test_connection_with_info, ConnectRequest, DisconnectRequest, McpAddConnectionRequest, + McpDuplicateConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest, SaveConnectionsRequest, }; use crate::state::WebState; @@ -930,6 +953,41 @@ mod tests { assert!(persisted.iter().any(|config| config.id == added.id)); assert!(state.app.configs.read().await.contains_key(&added.id)); + state + .app + .storage + .save_sidebar_layout(&serde_json::json!({ + "groups": [{ "id": "group", "name": "Group" }], + "order": [{ + "type": "group", + "id": "group", + "children": [{ "type": "connection", "id": "existing" }] + }, { "type": "connection", "id": "added" }], + "concurrentField": "keep" + })) + .await + .unwrap(); + let copied = mcp_duplicate_connection( + State(state.clone()), + Json(McpDuplicateConnectionRequest { + source_id: existing.id.clone(), + copy_id: "copy".to_string(), + copy_name: "Existing Copy".to_string(), + }), + ) + .await + .unwrap_or_else(|error| panic!("{}", error.message)) + .0; + assert_eq!(state.app.configs.read().await.get(&copied.id), Some(&copied)); + let persisted = state.app.storage.load_connections().await.unwrap(); + assert_eq!(persisted.iter().find(|config| config.id == existing.id).unwrap().host, existing.host); + assert!(persisted.iter().any(|config| config.id == added.id)); + let layout = state.app.storage.load_sidebar_layout().await.unwrap().unwrap(); + assert_eq!(layout["concurrentField"], "keep"); + assert_eq!(layout["order"][0]["children"][0]["id"], existing.id); + assert_eq!(layout["order"][0]["children"][1]["id"], copied.id); + assert_eq!(layout["order"][1]["id"], added.id); + let _ = std::fs::remove_dir_all(dir); }