Skip to content

Commit ecbc32f

Browse files
fix: address MCP bridge review issues
- Security: reject Elevated/Destructive capabilities on bridge (403) - Lifecycle: save_mcp_config now stops server when autoStart=false - Stale port file: liveness-test (TCP connect) before reporting running - Write port file BEFORE spawning server to avoid orphaned task - app_version: use real version from package_info() - McpConfig::load: log warning on corrupt/parse error - TOCTOU: remove port_available check, bind directly with fallback Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 9d1f2f3 commit ecbc32f

1 file changed

Lines changed: 145 additions & 79 deletions

File tree

src-tauri/src/mcp_bridge.rs

Lines changed: 145 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,14 @@
1-
//! Embedded HTTP bridge for MCP protocol.
2-
//!
3-
//! Exposes the capability system over HTTP so the external TypeScript
4-
//! MCP server (`data-studio-mcp`) can invoke tools and list capabilities.
5-
//!
6-
//! Only binds to 127.0.0.1 — not reachable from other machines.
7-
81
use std::path::{Path, PathBuf};
92
use std::sync::Arc;
3+
use std::sync::Mutex;
104

115
use axum::extract::State;
126
use axum::routing::{get, post};
137
use axum::Json;
148
use data_studio_agent::capabilities::registry;
15-
use data_studio_agent::capabilities::types::Capability;
9+
use data_studio_agent::capabilities::types::{Capability, RiskLevel};
1610
use serde::{Deserialize, Serialize};
1711
use serde_json::{json, Value};
18-
use std::sync::Mutex;
1912
use tauri::{AppHandle, Manager};
2013
use tokio::net::TcpListener;
2114
use tokio::sync::oneshot;
@@ -55,10 +48,16 @@ fn default_auto_start() -> bool {
5548
impl McpConfig {
5649
pub fn load(app_data_dir: &Path) -> Self {
5750
let path = app_data_dir.join("mcp-config.json");
58-
std::fs::read_to_string(path)
59-
.ok()
60-
.and_then(|s| serde_json::from_str(&s).ok())
61-
.unwrap_or_default()
51+
match std::fs::read_to_string(&path) {
52+
Ok(s) => match serde_json::from_str(&s) {
53+
Ok(cfg) => cfg,
54+
Err(e) => {
55+
log::warn!("Failed to parse mcp-config.json (corrupt?): {}. Using defaults.", e);
56+
McpConfig::default()
57+
}
58+
},
59+
Err(_) => McpConfig::default(),
60+
}
6261
}
6362

6463
pub fn save(&self, app_data_dir: &Path) -> Result<(), String> {
@@ -122,7 +121,6 @@ impl InvokeResponse {
122121
struct BridgeState {
123122
handle: AppHandle,
124123
app_name: &'static str,
125-
app_version: &'static str,
126124
app_data_dir: PathBuf,
127125
}
128126

@@ -147,16 +145,39 @@ async fn handle_tools(
147145
"metadata": metadata,
148146
"connections": [],
149147
});
150-
151148
Json(result)
152149
}
153150

154151
async fn handle_invoke(
155-
State(state): State<Arc<BridgeState>>,
156152
Json(payload): Json<InvokeRequest>,
157153
) -> Json<InvokeResponse> {
154+
// Reject destructive and elevated capabilities on the bridge
155+
let cap = match registry::registry().get(&payload.name) {
156+
Some(c) => c,
157+
None => {
158+
return Json(InvokeResponse::error(
159+
404,
160+
format!("Unknown capability: {}", payload.name),
161+
))
162+
}
163+
};
164+
165+
match cap.risk_level {
166+
RiskLevel::Safe => {}
167+
RiskLevel::Elevated | RiskLevel::Destructive => {
168+
let level_str = serde_json::to_string(&cap.risk_level).unwrap_or_default();
169+
return Json(InvokeResponse::error(
170+
403,
171+
format!(
172+
"Capability '{}' requires {} permission and is not allowed through the MCP bridge",
173+
payload.name, level_str
174+
),
175+
));
176+
}
177+
}
178+
158179
let config = match payload.connection_id {
159-
Some(ref id) => match resolve_connection(&state.handle, id).await {
180+
Some(ref id) => match resolve_connection(id).await {
160181
Ok(cfg) => Some(cfg),
161182
Err(e) => return Json(InvokeResponse::error(400, e)),
162183
},
@@ -178,7 +199,7 @@ async fn handle_health(
178199
Json(json!({
179200
"status": "ok",
180201
"app": state.app_name,
181-
"version": state.app_version,
202+
"version": state.handle.package_info().version.to_string(),
182203
"port": get_actual_port(&state.app_data_dir).unwrap_or(0),
183204
}))
184205
}
@@ -191,19 +212,24 @@ fn get_default_port() -> u16 {
191212
9121
192213
}
193214

194-
fn port_available(port: u16) -> bool {
195-
std::net::TcpListener::bind(std::net::SocketAddrV4::new(
196-
std::net::Ipv4Addr::LOCALHOST,
197-
port,
198-
))
199-
.is_ok()
200-
}
201-
202215
fn get_actual_port(app_data_dir: &Path) -> Option<u16> {
203216
let path = app_data_dir.join("mcp-port");
204-
std::fs::read_to_string(path)
217+
let port = std::fs::read_to_string(&path)
205218
.ok()
206-
.and_then(|s| s.trim().parse::<u16>().ok())
219+
.and_then(|s| s.trim().parse::<u16>().ok())?;
220+
221+
let addr = std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, port);
222+
if std::net::TcpStream::connect_timeout(
223+
&std::net::SocketAddr::V4(addr),
224+
std::time::Duration::from_millis(200),
225+
)
226+
.is_ok()
227+
{
228+
Some(port)
229+
} else {
230+
let _ = std::fs::remove_file(&path);
231+
None
232+
}
207233
}
208234

209235
async fn write_port_file(app_data_dir: &Path, port: u16) -> Result<(), String> {
@@ -222,55 +248,97 @@ async fn remove_port_file(app_data_dir: &Path) {
222248
let _ = tokio::fs::remove_file(path).await;
223249
}
224250

251+
async fn send_shutdown(handle: &AppHandle) {
252+
let server_handle: tauri::State<'_, McpServerHandle> = handle.state();
253+
let old_tx = {
254+
let mut tx = server_handle.shutdown_tx.lock().unwrap();
255+
tx.take()
256+
};
257+
if let Some(sender) = old_tx {
258+
let _ = sender.send(());
259+
}
260+
}
261+
225262
pub async fn start(
226263
handle: AppHandle,
227264
app_data_dir: PathBuf,
228265
preferred_port: u16,
229266
shutdown_rx: oneshot::Receiver<()>,
230267
) -> Result<u16, String> {
231-
let port = if port_available(preferred_port) {
232-
preferred_port
233-
} else {
234-
log::warn!(
235-
"MCP bridge port {} is in use, picking random port",
236-
preferred_port
237-
);
238-
portpicker::pick_unused_port().ok_or("no port available")?
268+
let port = match TcpListener::bind(format!("127.0.0.1:{}", preferred_port)).await {
269+
Ok(listener) => {
270+
let state = Arc::new(BridgeState {
271+
handle: handle.clone(),
272+
app_name: "sqlkit",
273+
app_data_dir: app_data_dir.clone(),
274+
});
275+
276+
let app = axum::Router::new()
277+
.route("/tools", post(handle_tools))
278+
.route("/invoke", post(handle_invoke))
279+
.route("/health", get(handle_health))
280+
.with_state(state);
281+
282+
write_port_file(&app_data_dir, preferred_port).await?;
283+
284+
let data_dir = app_data_dir.clone();
285+
tokio::spawn(async move {
286+
log::info!("MCP bridge listening on 127.0.0.1:{}", preferred_port);
287+
axum::serve(listener, app)
288+
.with_graceful_shutdown(async {
289+
shutdown_rx.await.ok();
290+
log::info!("MCP bridge shutting down");
291+
})
292+
.await
293+
.ok();
294+
let _ = remove_port_file(&data_dir).await;
295+
});
296+
297+
Ok(preferred_port)
298+
}
299+
Err(_) => {
300+
log::warn!(
301+
"MCP bridge port {} is in use, picking random port",
302+
preferred_port
303+
);
304+
let random_port =
305+
portpicker::pick_unused_port().ok_or("no port available on localhost")?;
306+
let listener = TcpListener::bind(format!("127.0.0.1:{}", random_port))
307+
.await
308+
.map_err(|e| format!("Failed to bind bridge: {}", e))?;
309+
310+
let state = Arc::new(BridgeState {
311+
handle: handle.clone(),
312+
app_name: "sqlkit",
313+
app_data_dir: app_data_dir.clone(),
314+
});
315+
316+
let app = axum::Router::new()
317+
.route("/tools", post(handle_tools))
318+
.route("/invoke", post(handle_invoke))
319+
.route("/health", get(handle_health))
320+
.with_state(state);
321+
322+
write_port_file(&app_data_dir, random_port).await?;
323+
324+
let data_dir = app_data_dir.clone();
325+
tokio::spawn(async move {
326+
log::info!("MCP bridge listening on 127.0.0.1:{}", random_port);
327+
axum::serve(listener, app)
328+
.with_graceful_shutdown(async {
329+
shutdown_rx.await.ok();
330+
log::info!("MCP bridge shutting down");
331+
})
332+
.await
333+
.ok();
334+
let _ = remove_port_file(&data_dir).await;
335+
});
336+
337+
Ok(random_port)
338+
}
239339
};
240340

241-
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
242-
.await
243-
.map_err(|e| format!("Failed to bind bridge: {}", e))?;
244-
245-
let state = Arc::new(BridgeState {
246-
handle: handle.clone(),
247-
app_name: "sqlkit",
248-
app_version: env!("CARGO_PKG_VERSION"),
249-
app_data_dir: app_data_dir.clone(),
250-
});
251-
252-
let app = axum::Router::new()
253-
.route("/tools", post(handle_tools))
254-
.route("/invoke", post(handle_invoke))
255-
.route("/health", get(handle_health))
256-
.with_state(state);
257-
258-
let data_dir = app_data_dir.clone();
259-
260-
tokio::spawn(async move {
261-
log::info!("MCP bridge listening on 127.0.0.1:{}", port);
262-
axum::serve(listener, app)
263-
.with_graceful_shutdown(async {
264-
shutdown_rx.await.ok();
265-
log::info!("MCP bridge shutting down");
266-
})
267-
.await
268-
.ok();
269-
let _ = remove_port_file(&data_dir).await;
270-
});
271-
272-
write_port_file(&app_data_dir, port).await?;
273-
Ok(port)
341+
port
274342
}
275343

276344
// ---------------------------------------------------------------------------
@@ -295,7 +363,10 @@ fn to_metadata(cap: &Capability) -> Value {
295363
})
296364
}
297365

298-
async fn resolve_connection(handle: &AppHandle, connection_id: &str) -> Result<Value, String> {
366+
async fn resolve_connection(connection_id: &str) -> Result<Value, String> {
367+
let handle = crate::APP_HANDLE
368+
.get()
369+
.ok_or_else(|| "AppHandle not initialized".to_string())?;
299370
use tauri::State;
300371
let state: State<'_, crate::state::AppState> = handle.state();
301372
let conns = state.connections.read().await;
@@ -344,19 +415,14 @@ pub async fn save_mcp_config(
344415
let config = McpConfig { port, auto_start };
345416
config.save(&app_data_dir)?;
346417

418+
send_shutdown(&app).await;
419+
347420
if auto_start {
348-
let server_handle: tauri::State<'_, McpServerHandle> = app.state();
349-
let old_tx = {
350-
let mut tx = server_handle.shutdown_tx.lock().unwrap();
351-
tx.take()
352-
};
353-
if let Some(sender) = old_tx {
354-
let _ = sender.send(());
355-
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
356-
}
421+
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
357422

358423
let (new_shutdown_tx, new_shutdown_rx) = oneshot::channel();
359424
{
425+
let server_handle: tauri::State<'_, McpServerHandle> = app.state();
360426
let mut tx = server_handle.shutdown_tx.lock().unwrap();
361427
*tx = Some(new_shutdown_tx);
362428
}

0 commit comments

Comments
 (0)