1 parent
5cfb457903
commit
396ebf8393
7 files changed
+535
-6
No files matched your search
@@ -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(©).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<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| {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user