feat: add MCP bridge for AI agent integration
Localhost HTTP listener (dynamic port) allows MCP Server and other AI agents to open tables directly in DBX UI via Tauri events.
This commit is contained in:
parent
da70b8f2c1
commit
aa59c26fa2
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"dbx": {
|
||||
"command": "npx",
|
||||
"args": ["tsx", "mcp/src/index.ts"],
|
||||
"cwd": "/Users/skyler/VsCodeProjects/dbx"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
schema: Option<String>,
|
||||
table: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct McpOpenTableEvent {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub schema: Option<String>,
|
||||
pub table: String,
|
||||
}
|
||||
|
||||
pub fn start(app_handle: AppHandle, _state: Arc<AppState>) {
|
||||
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<Vec<crate::models::connection::ConnectionConfig>, 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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Arc<AppState>> = app.state();
|
||||
commands::mcp_bridge::start(app_handle, state.inner().clone());
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
|
|
|
|||
17
src/App.vue
17
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(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue