diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..87330bc84 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "dbx": { + "command": "npx", + "args": ["tsx", "mcp/src/index.ts"], + "cwd": "/Users/skyler/VsCodeProjects/dbx" + } + } +} diff --git a/src-tauri/src/commands/mcp_bridge.rs b/src-tauri/src/commands/mcp_bridge.rs new file mode 100644 index 000000000..5e77267c0 --- /dev/null +++ b/src-tauri/src/commands/mcp_bridge.rs @@ -0,0 +1,106 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +use super::connection::AppState; +use super::connection_secrets::{create_secret_store, load_connections_from_file}; + +const BIND_ADDR: &str = "127.0.0.1:0"; + +#[derive(Deserialize)] +struct OpenTableRequest { + connection_name: String, + database: Option, + schema: Option, + table: String, +} + +#[derive(Clone, Serialize)] +pub struct McpOpenTableEvent { + pub connection_id: String, + pub database: String, + pub schema: Option, + pub table: String, +} + +pub fn start(app_handle: AppHandle, _state: Arc) { + tauri::async_runtime::spawn(async move { + let listener = match TcpListener::bind(BIND_ADDR).await { + Ok(l) => l, + Err(e) => { + log::warn!("MCP bridge failed to bind {BIND_ADDR}: {e}"); + return; + } + }; + log::info!("MCP bridge listening on {BIND_ADDR}"); + let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(0); + log::info!("MCP bridge assigned port {actual_port}"); + if let Ok(dir) = app_handle.path().app_data_dir() { + let _ = std::fs::write(dir.join("mcp-bridge-port"), actual_port.to_string()); + } + loop { + let (mut stream, _) = match listener.accept().await { + Ok(s) => s, + Err(_) => continue, + }; + let app = app_handle.clone(); + tokio::spawn(async move { + let mut buf = vec![0u8; 8192]; + let n = match stream.read(&mut buf).await { + Ok(n) if n > 0 => n, + _ => return, + }; + let request = String::from_utf8_lossy(&buf[..n]); + let body = request.split("\r\n\r\n").nth(1).unwrap_or(""); + let first_line = request.lines().next().unwrap_or(""); + + if !first_line.starts_with("POST /open-table") { + let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n").await; + return; + } + + let req: OpenTableRequest = match serde_json::from_str(body) { + Ok(r) => r, + Err(_) => { + let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n").await; + return; + } + }; + + let configs = match load_configs_from_disk(&app) { + Ok(c) => c, + Err(_) => { + let _ = stream.write_all(b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n").await; + return; + } + }; + + let found = configs.iter().find(|c| c.name.eq_ignore_ascii_case(&req.connection_name)); + let Some(config) = found else { + let resp = b"HTTP/1.1 404 Not Found\r\nContent-Length: 20\r\n\r\nConnection not found"; + let _ = stream.write_all(resp).await; + return; + }; + + let event = McpOpenTableEvent { + connection_id: config.id.clone(), + database: req.database.unwrap_or_else(|| config.database.clone().unwrap_or_default()), + schema: req.schema, + table: req.table, + }; + + let _ = app.emit("mcp-open-table", &event); + let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok").await; + }); + } + }); +} + +fn load_configs_from_disk(app: &AppHandle) -> Result, String> { + let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = dir.join("connections.json"); + let store = create_secret_store(app); + load_connections_from_file(&path, &*store) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d0ff39c18..e9a8bd2da 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod ai; pub mod connection; mod connection_secrets; pub mod history; +pub mod mcp_bridge; pub mod mongo_cmd; pub mod query; pub mod query_cancel; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 71db055a3..56a75c56e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod models; use commands::connection::AppState; use std::sync::Arc; +use tauri::Manager; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -26,6 +27,9 @@ pub fn run() { .build(), )?; } + let app_handle = app.handle().clone(); + let state: tauri::State> = app.state(); + commands::mcp_bridge::start(app_handle, state.inner().clone()); Ok(()) }) .on_window_event(|window, event| { diff --git a/src/App.vue b/src/App.vue index af77b6dc5..6c5481195 100644 --- a/src/App.vue +++ b/src/App.vue @@ -833,6 +833,23 @@ onMounted(() => { setupFileDrop().catch(() => {}); checkUpdates({ silent: true }); getVersion().then((v) => { appVersion.value = v; }).catch(() => {}); + import("@tauri-apps/api/event").then(({ listen }) => { + listen<{ connection_id: string; database: string; schema?: string; table: string }>("mcp-open-table", async (event) => { + const { connection_id, database, schema, table } = event.payload; + const config = connectionStore.getConfig(connection_id); + if (!config) return; + connectionStore.activeConnectionId = connection_id; + await connectionStore.ensureConnected(connection_id); + + if (config.db_type === "redis") { + queryStore.createTab(connection_id, database || "0", `db${database || "0"}`, "redis"); + } else if (config.db_type === "mongodb") { + queryStore.createTab(connection_id, database, table, "mongo"); + } else { + openLineageTarget({ connectionId: connection_id, database, schema, tableName: table }); + } + }); + }).catch(() => {}); } });