feat(mcp): duplicate complete connection configs

Closes #4192
This commit is contained in:
t8y2 committed 2026-08-12 21:52:54 +00:00
1 parent 5cfb457903
commit 396ebf8393
7 files changed
+535 -6

No files matched your search

+110
View File
@@ -2288,6 +2288,60 @@ fn persist_connection_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionC
persist_nacos_auth_secrets_in_tx(tx, &config) persist_nacos_auth_secrets_in_tx(tx, &config)
} }
fn insert_connection_copy_next_to_source(entries: &mut Vec<serde_json::Value>, 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( fn preserve_unreadable_connections_for_replacement(
tx: &rusqlite::Transaction<'_>, tx: &rusqlite::Transaction<'_>,
replacement_ids: &HashSet<String>, replacement_ids: &HashSet<String>,
@@ -2410,6 +2464,62 @@ impl Storage {
.await .await
} }
pub async fn duplicate_connection_for_mcp(
&self,
source_id: &str,
copy_id: &str,
copy_name: &str,
) -> Result<ConnectionConfig, String> {
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::<ConnectionConfig>(&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(&copy).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, &copy.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<bool, String> { pub async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String> {
let connection_id = connection_id.to_string(); let connection_id = connection_id.to_string();
self.with_conn(move |conn| { self.with_conn(move |conn| {
+42
View File
@@ -184,6 +184,12 @@ pub trait DbxBackend: Send + Sync {
Err("SQL queries are not supported by this backend.".to_string()) Err("SQL queries are not supported by this backend.".to_string())
} }
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String>; async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String>;
async fn duplicate_connection_for_mcp(
&self,
source_id: &str,
copy_id: &str,
copy_name: &str,
) -> Result<ConnectionConfig, String>;
async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String>; async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String>;
async fn list_tables( async fn list_tables(
&self, &self,
@@ -532,6 +538,17 @@ impl DbxBackend for LocalBackend {
Ok(config) Ok(config)
} }
async fn duplicate_connection_for_mcp(
&self,
source_id: &str,
copy_id: &str,
copy_name: &str,
) -> Result<ConnectionConfig, String> {
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<bool, String> { async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String> {
let removed = self.state.storage.remove_connection_for_mcp(connection_id).await?; let removed = self.state.storage.remove_connection_for_mcp(connection_id).await?;
if removed { if removed {
@@ -784,6 +801,23 @@ impl DbxBackend for WebBackend {
.map_err(|error| format!("Invalid MCP connection response: {error}")) .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<ConnectionConfig, String> {
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<bool, String> { async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String> {
let removed = self let removed = self
.request( .request(
@@ -2018,6 +2052,14 @@ mod tests {
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> { async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
Ok(config) Ok(config)
} }
async fn duplicate_connection_for_mcp(
&self,
_source_id: &str,
_copy_id: &str,
_copy_name: &str,
) -> Result<ConnectionConfig, String> {
Err("unused".to_string())
}
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> { async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
Ok(false) Ok(false)
} }
+90 -1
View File
@@ -111,6 +111,14 @@ pub struct AddConnectionRequest {
pub driver_profile: Option<String>, pub driver_profile: Option<String>,
} }
#[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)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RemoveConnectionRequest { pub struct RemoveConnectionRequest {
pub connection_name: String, pub connection_name: String,
@@ -218,6 +226,7 @@ impl DbxMcpServer {
let mut tool_router = Self::tool_router(); let mut tool_router = Self::tool_router();
if scope.enabled() { if scope.enabled() {
tool_router.disable_route("dbx_add_connection"); tool_router.disable_route("dbx_add_connection");
tool_router.disable_route("dbx_duplicate_connection");
tool_router.disable_route("dbx_remove_connection"); tool_router.disable_route("dbx_remove_connection");
} }
// Desktop UI bridge operations are intentionally unavailable remotely and in scoped AI sessions. // 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<DuplicateConnectionRequest>,
) -> 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::<Vec<_>>();
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::<Vec<_>>();
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")] #[tool(name = "dbx_remove_connection", description = "Remove a database connection from DBX")]
async fn remove_connection(&self, Parameters(request): Parameters<RemoveConnectionRequest>) -> CallToolResult { async fn remove_connection(&self, Parameters(request): Parameters<RemoveConnectionRequest>) -> CallToolResult {
let policy = match self.load_policy().await { let policy = match self.load_policy().await {
@@ -1348,6 +1418,23 @@ mod tests {
Ok(config) Ok(config)
} }
async fn duplicate_connection_for_mcp(
&self,
source_id: &str,
copy_id: &str,
copy_name: &str,
) -> Result<ConnectionConfig, String> {
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<bool, String> { async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
Ok(true) Ok(true)
} }
@@ -1374,12 +1461,13 @@ mod tests {
let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false); let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false);
let tools = server.tool_router.list_all(); let tools = server.tool_router.list_all();
let names = tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>(); let names = tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
assert_eq!(tools.len(), 12); assert_eq!(tools.len(), 13);
assert!(names.contains(&"dbx_list_connections")); assert!(names.contains(&"dbx_list_connections"));
assert!(names.contains(&"dbx_list_tables")); assert!(names.contains(&"dbx_list_tables"));
assert!(names.contains(&"dbx_describe_table")); assert!(names.contains(&"dbx_describe_table"));
assert!(names.contains(&"dbx_execute_query")); assert!(names.contains(&"dbx_execute_query"));
assert!(names.contains(&"dbx_add_connection")); assert!(names.contains(&"dbx_add_connection"));
assert!(names.contains(&"dbx_duplicate_connection"));
assert!(names.contains(&"dbx_remove_connection")); assert!(names.contains(&"dbx_remove_connection"));
assert!(names.contains(&"dbx_execute_redis_command")); assert!(names.contains(&"dbx_execute_redis_command"));
assert!(names.contains(&"dbx_get_schema_context")); 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::<Vec<_>>(); let names = server.tool_router.list_all().into_iter().map(|tool| tool.name).collect::<Vec<_>>();
assert_eq!(names.len(), 8); assert_eq!(names.len(), 8);
assert!(!names.iter().any(|name| name == "dbx_add_connection")); 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_remove_connection"));
assert!(!names.iter().any(|name| name == "dbx_open_table")); assert!(!names.iter().any(|name| name == "dbx_open_table"));
assert!(!names.iter().any(|name| name == "dbx_execute_and_show")); assert!(!names.iter().any(|name| name == "dbx_execute_and_show"));
+187 -1
View File
@@ -84,6 +84,192 @@ async fn local_backend_reads_dbx_storage_without_desktop_process() {
server_task.abort(); 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] #[tokio::test]
async fn local_backend_picks_up_connections_added_after_startup_without_reload() { 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 // 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] #[tokio::test]
async fn legacy_read_only_config_applies_before_settings_are_opened() { 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 directory = tempdir().expect("temporary data directory");
let db_path = directory.path().join("dbx.db"); let db_path = directory.path().join("dbx.db");
let storage = Storage::open(&db_path).await.expect("open storage"); 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"); .expect("minimal connection config");
storage.save_connections(&[connection]).await.expect("save connection"); 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 backend = Arc::new(LocalBackend::open(&db_path).await.expect("open local backend"));
let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false); let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false);
+44 -1
View File
@@ -42,6 +42,15 @@ impl DbxBackend for EmptyBackend {
Ok(config) Ok(config)
} }
async fn duplicate_connection_for_mcp(
&self,
_source_id: &str,
_copy_id: &str,
_copy_name: &str,
) -> Result<ConnectionConfig, String> {
Err("not exercised".to_string())
}
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> { async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
Ok(true) Ok(true)
} }
@@ -88,6 +97,15 @@ impl DbxBackend for PolicyBackend {
Ok(config) Ok(config)
} }
async fn duplicate_connection_for_mcp(
&self,
_source_id: &str,
_copy_id: &str,
_copy_name: &str,
) -> Result<ConnectionConfig, String> {
Err("not exercised".to_string())
}
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> { async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
Ok(true) 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 tools = client.peer().list_tools(None).await.expect("list tools");
let names = tools.tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>(); let names = tools.tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
assert_eq!(names.len(), 12); assert_eq!(names.len(), 13);
assert!(names.contains(&"dbx_list_connections")); assert!(names.contains(&"dbx_list_connections"));
assert!(names.contains(&"dbx_duplicate_connection"));
assert!(names.contains(&"dbx_execute_redis_command")); assert!(names.contains(&"dbx_execute_redis_command"));
assert!(names.contains(&"dbx_execute_and_show")); assert!(names.contains(&"dbx_execute_and_show"));
assert!(names.contains(&"dbx_open_session")); assert!(names.contains(&"dbx_open_session"));
@@ -194,6 +213,30 @@ async fn enforces_global_connection_scope_and_read_only_policy() {
server_task.abort(); 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] #[tokio::test]
async fn connection_group_path_failure_preserves_connection_listing() { async fn connection_group_path_failure_preserves_connection_listing() {
let backend = PolicyBackend { let backend = PolicyBackend {
+1
View File
@@ -338,6 +338,7 @@ async fn main() {
.route("/connection/save", post(routes::connection::save_connections)) .route("/connection/save", post(routes::connection::save_connections))
.route("/connection/list", get(routes::connection::load_connections)) .route("/connection/list", get(routes::connection::load_connections))
.route("/connection/mcp/add", post(routes::connection::mcp_add_connection)) .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("/connection/mcp/remove", post(routes::connection::mcp_remove_connection))
.route("/plugins", get(routes::plugins::list_plugins)) .route("/plugins", get(routes::plugins::list_plugins))
// JDBC // JDBC
+61 -3
View File
@@ -60,6 +60,14 @@ pub struct McpAddConnectionRequest {
pub config: ConnectionConfig, 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)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct McpRemoveConnectionRequest { pub struct McpRemoveConnectionRequest {
@@ -412,6 +420,20 @@ pub async fn mcp_add_connection(
Ok(Json(saved)) Ok(Json(saved))
} }
pub async fn mcp_duplicate_connection(
State(state): State<Arc<WebState>>,
Json(body): Json<McpDuplicateConnectionRequest>,
) -> Result<Json<ConnectionConfig>, 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( pub async fn mcp_remove_connection(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
Json(body): Json<McpRemoveConnectionRequest>, Json(body): Json<McpRemoveConnectionRequest>,
@@ -519,9 +541,10 @@ async fn remove_connection_pools_for_connection_ids(state: &WebState, connection
mod tests { mod tests {
use super::{ use super::{
apply_mongo_legacy_driver_profile, connect_db, connection_final_proxy_port, disconnect_db, load_connections, 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, mark_mongo_legacy_driver, mcp_add_connection, mcp_duplicate_connection, mcp_remove_connection,
save_connection_database_info, save_connections, test_connection, test_connection_with_info, ConnectRequest, run_temporary_connection_test, save_connection_database_info, save_connections, test_connection,
DisconnectRequest, McpAddConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest, test_connection_with_info, ConnectRequest, DisconnectRequest, McpAddConnectionRequest,
McpDuplicateConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest,
SaveConnectionsRequest, SaveConnectionsRequest,
}; };
use crate::state::WebState; use crate::state::WebState;
@@ -930,6 +953,41 @@ mod tests {
assert!(persisted.iter().any(|config| config.id == added.id)); assert!(persisted.iter().any(|config| config.id == added.id));
assert!(state.app.configs.read().await.contains_key(&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); let _ = std::fs::remove_dir_all(dir);
} }