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:
t8y2 2026-05-03 14:35:48 +08:00
parent da70b8f2c1
commit aa59c26fa2
5 changed files with 137 additions and 0 deletions

9
.mcp.json Normal file
View File

@ -0,0 +1,9 @@
{
"mcpServers": {
"dbx": {
"command": "npx",
"args": ["tsx", "mcp/src/index.ts"],
"cwd": "/Users/skyler/VsCodeProjects/dbx"
}
}
}

View File

@ -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)
}

View File

@ -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;

View File

@ -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| {

View File

@ -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(() => {});
}
});