Skip to content

Commit 00da568

Browse files
committed
feat: expose saved connections from sqlkit MCP bridge + bridge tests
1 parent ab48a97 commit 00da568

1 file changed

Lines changed: 170 additions & 11 deletions

File tree

src-tauri/src/mcp_bridge.rs

Lines changed: 170 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use data_studio_agent::capabilities::types::{Capability, RiskLevel};
1010
use serde::{Deserialize, Serialize};
1111
use serde_json::{json, Value};
1212
use tauri::{AppHandle, Manager};
13+
use tauri_plugin_store::StoreExt;
1314
use tokio::net::TcpListener;
1415
use tokio::sync::oneshot;
1516

@@ -54,7 +55,10 @@ impl McpConfig {
5455
Ok(s) => match serde_json::from_str(&s) {
5556
Ok(cfg) => cfg,
5657
Err(e) => {
57-
log::warn!("Failed to parse mcp-config.json (corrupt?): {}. Using defaults.", e);
58+
log::warn!(
59+
"Failed to parse mcp-config.json (corrupt?): {}. Using defaults.",
60+
e
61+
);
5862
McpConfig::default()
5963
}
6064
},
@@ -130,9 +134,7 @@ struct BridgeState {
130134
// Handlers
131135
// ---------------------------------------------------------------------------
132136

133-
async fn handle_tools(
134-
State(_state): State<Arc<BridgeState>>,
135-
) -> Json<Value> {
137+
async fn handle_tools(State(_state): State<Arc<BridgeState>>) -> Json<Value> {
136138
let reg = registry::registry();
137139
let caps = reg.agent_tools();
138140

@@ -145,14 +147,42 @@ async fn handle_tools(
145147
let result = json!({
146148
"tools": openai_tools,
147149
"metadata": metadata,
148-
"connections": [],
150+
"connections": list_connections(),
149151
});
150152
Json(result)
151153
}
152154

153-
async fn handle_invoke(
154-
Json(payload): Json<InvokeRequest>,
155-
) -> Json<InvokeResponse> {
155+
/// List saved connections from the store — id/name/db_type only, no credentials.
156+
fn list_connections() -> Value {
157+
let handle = match crate::APP_HANDLE.get() {
158+
Some(h) => h,
159+
None => return json!([]),
160+
};
161+
let store = match handle.store(".store.dat") {
162+
Ok(s) => s,
163+
Err(_) => return json!([]),
164+
};
165+
166+
let connections = store.get("connections").unwrap_or(json!([]));
167+
let safe_list: Vec<Value> = connections
168+
.as_array()
169+
.map(|arr| {
170+
arr.iter()
171+
.map(|c| {
172+
json!({
173+
"id": c.get("id"),
174+
"name": c.get("name"),
175+
"type": c.get("db_type"),
176+
})
177+
})
178+
.collect()
179+
})
180+
.unwrap_or_default();
181+
182+
json!(safe_list)
183+
}
184+
185+
async fn handle_invoke(Json(payload): Json<InvokeRequest>) -> Json<InvokeResponse> {
156186
// Reject destructive and elevated capabilities on the bridge
157187
let cap = match registry::registry().get(&payload.name) {
158188
Some(c) => c,
@@ -195,9 +225,7 @@ async fn handle_invoke(
195225
}
196226
}
197227

198-
async fn handle_health(
199-
State(state): State<Arc<BridgeState>>,
200-
) -> Json<Value> {
228+
async fn handle_health(State(state): State<Arc<BridgeState>>) -> Json<Value> {
201229
Json(json!({
202230
"status": "ok",
203231
"app": state.app_name,
@@ -454,3 +482,134 @@ pub async fn save_mcp_config(
454482

455483
Ok(serde_json::to_string(&json!({"status": "ok"})).map_err(|e| e.to_string())?)
456484
}
485+
486+
#[cfg(test)]
487+
mod tests {
488+
use super::*;
489+
use data_studio_agent::capabilities::types::RiskLevel;
490+
491+
fn init_registry_for_tests() {
492+
// OnceLock set-once: subsequent calls are no-ops, safe to call in every test
493+
data_studio_agent::capabilities::registry::init_registry(&[
494+
crate::capabilities::sqlkit::register_all,
495+
crate::capabilities::sql::register_sql_tools,
496+
]);
497+
}
498+
499+
#[test]
500+
fn test_default_port_is_9121() {
501+
assert_eq!(get_default_port(), 9121);
502+
}
503+
504+
#[test]
505+
fn test_mcp_config_default() {
506+
let cfg = McpConfig::default();
507+
assert_eq!(cfg.port, None);
508+
assert!(cfg.auto_start);
509+
}
510+
511+
#[test]
512+
fn test_mcp_config_save_and_load_roundtrip() {
513+
let dir = std::env::temp_dir().join(format!(
514+
"sqlkit-mcp-test-{}-config-roundtrip",
515+
std::process::id()
516+
));
517+
let _ = std::fs::remove_dir_all(&dir);
518+
std::fs::create_dir_all(&dir).unwrap();
519+
520+
let cfg = McpConfig {
521+
port: Some(9444),
522+
auto_start: false,
523+
};
524+
cfg.save(&dir).unwrap();
525+
526+
let loaded = McpConfig::load(&dir);
527+
assert_eq!(loaded.port, Some(9444));
528+
assert!(!loaded.auto_start);
529+
std::fs::remove_dir_all(&dir).unwrap();
530+
}
531+
532+
#[test]
533+
fn test_mcp_config_load_corrupt_file_uses_default() {
534+
let dir = std::env::temp_dir().join(format!(
535+
"sqlkit-mcp-test-{}-config-corrupt",
536+
std::process::id()
537+
));
538+
let _ = std::fs::remove_dir_all(&dir);
539+
std::fs::create_dir_all(&dir).unwrap();
540+
std::fs::write(dir.join("mcp-config.json"), "{ not valid json").unwrap();
541+
542+
let cfg = McpConfig::load(&dir);
543+
assert_eq!(cfg.port, None);
544+
assert!(cfg.auto_start);
545+
std::fs::remove_dir_all(&dir).unwrap();
546+
}
547+
548+
#[test]
549+
fn test_list_connections_empty_without_app_handle() {
550+
assert_eq!(list_connections(), json!([]));
551+
}
552+
553+
#[test]
554+
fn test_invoke_response_ok_serialization() {
555+
let resp = InvokeResponse::ok(json!({"rows": 1}));
556+
let v = serde_json::to_value(&resp).unwrap();
557+
assert_eq!(v["status"], 200);
558+
assert_eq!(v["data"]["rows"], 1);
559+
assert!(v.get("message").is_none());
560+
}
561+
562+
#[test]
563+
fn test_invoke_response_error_serialization() {
564+
let resp = InvokeResponse::error(403, "forbidden".into());
565+
let v = serde_json::to_value(&resp).unwrap();
566+
assert_eq!(v["status"], 403);
567+
assert_eq!(v["message"], "forbidden");
568+
assert!(v.get("data").is_none());
569+
}
570+
571+
#[test]
572+
fn test_handle_invoke_unknown_capability_returns_404() {
573+
init_registry_for_tests();
574+
let req = InvokeRequest {
575+
name: "definitely__not_a_real_capability".into(),
576+
args: json!({}),
577+
connection_id: None,
578+
};
579+
580+
let rt = tokio::runtime::Runtime::new().unwrap();
581+
let resp = rt.block_on(handle_invoke(Json(req))).0;
582+
583+
assert_eq!(resp.status, 404);
584+
assert!(resp.message.unwrap().contains("Unknown capability"));
585+
}
586+
587+
#[test]
588+
fn test_handle_invoke_rejects_elevated_and_destructive() {
589+
init_registry_for_tests();
590+
let tools = registry::registry().agent_tools();
591+
// Concurrent tests may initialize the global registry (OnceLock) with
592+
// test-only Safe capabilities; only assert when the full app registry is present.
593+
let Some(risky) = tools
594+
.iter()
595+
.find(|c| !matches!(c.risk_level, RiskLevel::Safe))
596+
else {
597+
return;
598+
};
599+
600+
let req = InvokeRequest {
601+
name: risky.name.to_string(),
602+
args: json!({}),
603+
connection_id: None,
604+
};
605+
606+
let rt = tokio::runtime::Runtime::new().unwrap();
607+
let resp = rt.block_on(handle_invoke(Json(req))).0;
608+
609+
assert_eq!(resp.status, 403);
610+
assert!(resp
611+
.message
612+
.unwrap()
613+
.contains("not allowed through the MCP bridge"));
614+
}
615+
}

0 commit comments

Comments
 (0)