feat(mcp): add streamable http server and fine-grained authorization

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-31 22:56:16 +08:00
1 parent f725c50b2c
commit f075caef21
47 files changed
+4960 -509

No files matched your search

+90 -8
View File
@@ -7,7 +7,7 @@ use tokio::net::TcpListener;
use super::connection::AppState;
use dbx_core::storage::McpGlobalPolicy;
use dbx_core::storage::{McpDatabaseScope, McpGlobalPolicy};
const BIND_ADDR: &str = "127.0.0.1:0";
const MCP_BRIDGE_PORT_FILE: &str = "mcp-bridge-port";
@@ -424,7 +424,7 @@ mod tests {
read_only: false,
allow_dangerous_sql: false,
allowed_connection_ids: None,
query_timeout_secs: None,
..Default::default()
};
assert!(ensure_connection_in_mcp_scope(&all, "conn-1").is_ok());
@@ -432,7 +432,7 @@ mod tests {
read_only: false,
allow_dangerous_sql: false,
allowed_connection_ids: Some(vec!["conn-1".to_string()]),
query_timeout_secs: None,
..Default::default()
};
assert!(ensure_connection_in_mcp_scope(&subset, "conn-1").is_ok());
assert!(ensure_connection_in_mcp_scope(&subset, "conn-2").unwrap_err().starts_with("CONNECTION_OUT_OF_SCOPE:"));
@@ -441,7 +441,7 @@ mod tests {
read_only: false,
allow_dangerous_sql: false,
allowed_connection_ids: Some(Vec::new()),
query_timeout_secs: None,
..Default::default()
};
assert!(ensure_connection_in_mcp_scope(&none, "conn-1").is_err());
}
@@ -568,6 +568,35 @@ fn ensure_connection_in_mcp_scope(policy: &McpGlobalPolicy, connection_id: &str)
Ok(())
}
fn ensure_database_in_mcp_scope(policy: &McpGlobalPolicy, connection_id: &str, database: &str) -> Result<(), String> {
let Some(rule) = policy.connection_policies.iter().find(|rule| rule.connection_id == connection_id) else {
return Ok(());
};
let allowed = match rule.database_scope {
McpDatabaseScope::All => true,
McpDatabaseScope::Selected => rule.allowed_databases.iter().any(|allowed| allowed == database),
McpDatabaseScope::None => false,
};
if allowed {
Ok(())
} else {
Err(format!(
"DATABASE_OUT_OF_SCOPE: database '{database}' is not allowed by DBX MCP settings for connection '{connection_id}'"
))
}
}
fn effective_connection_execution_policy(policy: &McpGlobalPolicy, connection_id: &str) -> (bool, bool) {
let Some(rule) = policy
.connection_policies
.iter()
.find(|rule| rule.connection_id == connection_id && rule.execution_mode_configured)
else {
return (policy.read_only, policy.allow_dangerous_sql);
};
(policy.read_only || rule.read_only, policy.allow_dangerous_sql && !rule.read_only && rule.allow_dangerous_sql)
}
async fn ensure_mcp_write_allowed(
state: &Arc<AppState>,
config: &crate::models::connection::ConnectionConfig,
@@ -586,10 +615,12 @@ async fn ensure_mcp_write_allowed_with_risk(
) -> Result<(), String> {
let policy = load_mcp_policy(state).await?;
ensure_connection_in_mcp_scope(&policy, &config.id)?;
if policy.read_only {
ensure_database_in_mcp_scope(&policy, &config.id, database)?;
let (read_only, allow_dangerous_sql) = effective_connection_execution_policy(&policy, &config.id);
if read_only {
return Err(format!("MCP_READ_ONLY: DBX MCP read-only mode is enabled. {action} blocked."));
}
if dangerous && !policy.allow_dangerous_sql {
if dangerous && !allow_dangerous_sql {
return Err(format!("SQL_BLOCKED: High-risk operation '{action}' is disabled in DBX MCP settings."));
}
if config.read_only {
@@ -611,6 +642,7 @@ pub(crate) async fn ensure_mcp_read_allowed_by_id(
) -> Result<(), String> {
let policy = load_mcp_policy(state).await?;
ensure_connection_in_mcp_scope(&policy, connection_id)?;
ensure_database_in_mcp_scope(&policy, connection_id, database)?;
let configs = state.storage.load_connections().await.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
let config = configs
.iter()
@@ -643,6 +675,11 @@ async fn ensure_mcp_mongo_pipeline_target_allowed_by_id(
database: &str,
pipeline_json: &str,
) -> Result<(), String> {
let policy = load_mcp_policy(state).await?;
ensure_connection_in_mcp_scope(&policy, connection_id)?;
for target_database in mongo_pipeline_output_databases(pipeline_json, database)? {
ensure_database_in_mcp_scope(&policy, connection_id, &target_database)?;
}
let configs = state.storage.load_connections().await.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
let config = configs
.iter()
@@ -656,6 +693,34 @@ async fn ensure_mcp_mongo_pipeline_target_allowed_by_id(
Ok(())
}
fn mongo_pipeline_output_databases(pipeline_json: &str, active_database: &str) -> Result<Vec<String>, String> {
let stages = serde_json::from_str::<serde_json::Value>(pipeline_json)
.ok()
.and_then(|value| value.as_array().cloned())
.ok_or_else(|| "QUERY_ERROR: MongoDB aggregate pipeline must be a JSON array.".to_string())?;
let mut databases = Vec::new();
for stage in stages {
let Some(stage) = stage.as_object() else { continue };
for key in ["$out", "$merge"] {
let Some(target) = stage.get(key) else { continue };
let database = match target {
serde_json::Value::String(_) => active_database.to_string(),
serde_json::Value::Object(target) => target
.get("db")
.or_else(|| {
target.get("into").and_then(serde_json::Value::as_object).and_then(|into| into.get("db"))
})
.and_then(serde_json::Value::as_str)
.unwrap_or(active_database)
.to_string(),
_ => return Err("QUERY_ERROR: MongoDB aggregate output target must be a string or object.".to_string()),
};
databases.push(database);
}
}
Ok(databases)
}
async fn ensure_mcp_write_allowed_by_id_with_risk(
state: &Arc<AppState>,
connection_id: &str,
@@ -920,12 +985,29 @@ async fn ensure_mcp_sql_allowed(
) -> Result<(), String> {
let policy = load_mcp_policy(state).await?;
ensure_connection_in_mcp_scope(&policy, &config.id)?;
ensure_database_in_mcp_scope(&policy, &config.id, database)?;
if let Some(rule) = policy.connection_policies.iter().find(|rule| rule.connection_id == config.id) {
if rule.database_scope == McpDatabaseScope::Selected
&& dbx_core::production_safety::sql_references_disallowed_database(
sql,
&config.db_type,
database,
&rule.allowed_databases,
)
{
return Err(
"DATABASE_OUT_OF_SCOPE: SQL references a database that is not allowed by DBX MCP settings for this connection."
.to_string(),
);
}
}
ensure_mcp_sql_database_switch_allowed(config.db_type, sql)?;
let is_write = dbx_core::query_execution_sql::is_write_sql_for_database(sql, config.db_type);
if policy.read_only && is_write {
let (read_only, allow_dangerous_sql) = effective_connection_execution_policy(&policy, &config.id);
if read_only && is_write {
return Err("MCP_READ_ONLY: DBX MCP read-only mode is enabled. SQL write blocked.".to_string());
}
if !policy.allow_dangerous_sql && dbx_core::sql_risk::is_dangerous_sql_for_database(sql, config.db_type) {
if !allow_dangerous_sql && dbx_core::sql_risk::is_dangerous_sql_for_database(sql, config.db_type) {
return Err("SQL_BLOCKED: High-risk SQL is disabled in DBX MCP settings.".to_string());
}
ensure_mcp_connection_sql_write_allowed(config, is_write)?;
+502
View File
@@ -0,0 +1,502 @@
use std::{
collections::VecDeque,
fs,
net::{IpAddr, SocketAddr},
path::PathBuf,
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
};
use dbx_core::{connection::AppState, storage::McpHttpServerSettings};
use dbx_mcp::{serve_streamable_http_on_listener, DbxBackend, HttpAuth, HttpRuntimeConfig, LocalBackend};
use serde::Serialize;
use tauri::State;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
const TOKEN_FILE_NAME: &str = "mcp-http-token";
/// Owns the optional in-process Streamable HTTP server. It is intentionally
/// separate from `AppState`: this is desktop runtime state, not database data.
pub struct McpHttpServerState {
data_dir: PathBuf,
/// Serializes lifecycle operations. In particular, a settings save must
/// finish shutting down the old listener before it binds the replacement.
operation_lock: tokio::sync::Mutex<()>,
server: Mutex<Option<RunningServer>>,
diagnostics: Arc<Mutex<McpHttpServerDiagnostics>>,
supervisor: Mutex<Option<HealthSupervisor>>,
next_supervisor_id: AtomicU64,
}
struct RunningServer {
settings: McpHttpServerSettings,
cancellation: CancellationToken,
task: tauri::async_runtime::JoinHandle<()>,
}
struct HealthSupervisor {
id: u64,
cancellation: CancellationToken,
}
#[derive(Default)]
struct McpHttpServerDiagnostics {
last_error: Option<String>,
logs: VecDeque<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McpHttpServerStatus {
pub enabled: bool,
pub running: bool,
pub endpoint: Option<String>,
/// Returned only to the local desktop renderer so the user can copy the
/// credential into an MCP client. It is never persisted in app settings.
pub access_token: Option<String>,
pub last_error: Option<String>,
pub recent_logs: Vec<String>,
}
impl McpHttpServerState {
pub fn new(data_dir: PathBuf) -> Self {
Self {
data_dir,
operation_lock: tokio::sync::Mutex::new(()),
server: Mutex::new(None),
diagnostics: Arc::new(Mutex::new(McpHttpServerDiagnostics::default())),
supervisor: Mutex::new(None),
next_supervisor_id: AtomicU64::new(1),
}
}
fn token_path(&self) -> PathBuf {
self.data_dir.join(TOKEN_FILE_NAME)
}
fn rotate_token(&self) -> Result<(), String> {
let path = self.token_path();
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("failed to rotate MCP HTTP token: {error}")),
}
}
fn load_or_create_token(&self) -> Result<String, String> {
let path = self.token_path();
if let Ok(token) = fs::read_to_string(&path) {
let token = token.trim().to_string();
if !token.is_empty() {
return Ok(token);
}
}
fs::create_dir_all(&self.data_dir).map_err(|error| format!("failed to create MCP token directory: {error}"))?;
let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
fs::write(&path, &token).map_err(|error| format!("failed to save MCP HTTP token: {error}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
.map_err(|error| format!("failed to protect MCP HTTP token: {error}"))?;
}
Ok(token)
}
fn status(&self, settings: &McpHttpServerSettings) -> McpHttpServerStatus {
let mut server = self.server.lock().unwrap_or_else(|error| error.into_inner());
let running = server.as_ref().is_some_and(|server| !server.task.inner().is_finished());
if !running {
*server = None;
}
let endpoint = settings.enabled.then(|| endpoint_for_settings(settings)).flatten();
let access_token = settings.enabled.then(|| self.load_or_create_token()).transpose().ok().flatten();
let diagnostics = self.diagnostics.lock().unwrap_or_else(|error| error.into_inner());
McpHttpServerStatus {
enabled: settings.enabled,
running,
endpoint,
access_token,
last_error: diagnostics.last_error.clone(),
recent_logs: diagnostics.logs.iter().cloned().collect(),
}
}
fn record_error(&self, error: String) {
let mut diagnostics = self.diagnostics.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
diagnostics.last_error = Some(error.clone());
push_log(&mut diagnostics, format!("ERROR {error}"));
}
fn cancel_health_supervisor(&self) {
let supervisor = self.supervisor.lock().unwrap_or_else(|error| error.into_inner()).take();
if let Some(supervisor) = supervisor {
supervisor.cancellation.cancel();
}
}
fn claim_health_supervisor(&self) -> Option<(u64, CancellationToken)> {
let mut supervisor = self.supervisor.lock().unwrap_or_else(|error| error.into_inner());
if supervisor.is_some() {
return None;
}
let id = self.next_supervisor_id.fetch_add(1, Ordering::Relaxed);
let cancellation = CancellationToken::new();
*supervisor = Some(HealthSupervisor { id, cancellation: cancellation.clone() });
Some((id, cancellation))
}
fn release_health_supervisor(&self, id: u64) {
let mut supervisor = self.supervisor.lock().unwrap_or_else(|error| error.into_inner());
if supervisor.as_ref().is_some_and(|supervisor| supervisor.id == id) {
*supervisor = None;
}
}
async fn stop_locked(&self) {
let running_server = {
let mut server = self.server.lock().unwrap_or_else(|error| error.into_inner());
server.take()
};
if let Some(server) = running_server {
server.cancellation.cancel();
// The listener lives inside the Axum task. Cancelling and waiting
// for that task ensures the socket is actually released before a
// replacement listener tries to bind the same address and port.
let mut task = server.task;
if tokio::time::timeout(std::time::Duration::from_secs(3), &mut task).await.is_err() {
task.abort();
let _ = task.await;
}
}
}
async fn stop(&self) {
let _operation = self.operation_lock.lock().await;
self.stop_locked().await;
}
async fn start(&self, app_state: Arc<AppState>, settings: &McpHttpServerSettings) -> Result<(), String> {
let _operation = self.operation_lock.lock().await;
let config = self.runtime_config(settings)?;
let previous_settings = self
.server
.lock()
.unwrap_or_else(|error| error.into_inner())
.as_ref()
.map(|server| server.settings.clone());
match self.start_with_config_locked(app_state.clone(), settings, config).await {
Ok(()) => Ok(()),
Err(error) => {
if let Some(previous_settings) = previous_settings {
match self.runtime_config(&previous_settings).map(|config| (previous_settings, config)) {
Ok((previous_settings, config)) => {
if let Err(restore_error) =
self.start_with_config_locked(app_state, &previous_settings, config).await
{
return Err(format!("{error}; additionally failed to restore the previous MCP HTTP service: {restore_error}"));
}
}
Err(restore_error) => {
return Err(format!("{error}; additionally failed to restore the previous MCP HTTP service: {restore_error}"));
}
}
}
Err(error)
}
}
}
fn runtime_config(&self, settings: &McpHttpServerSettings) -> Result<HttpRuntimeConfig, String> {
validate_settings(settings)?;
let token = self.load_or_create_token()?;
let ip = settings.host.parse::<IpAddr>().map_err(|_| "MCP HTTP host must be an IP address".to_string())?;
let bind_addr = SocketAddr::new(ip, settings.port);
let allowed_hosts = if ip.is_loopback() {
vec!["localhost".to_string(), "127.0.0.1".to_string(), "::1".to_string()]
} else {
settings.allowed_hosts.clone()
};
let auth = HttpAuth::new(token, settings.allowed_origins.clone(), ip.is_loopback())?;
Ok(HttpRuntimeConfig::new(bind_addr, settings.path.clone(), auth, allowed_hosts))
}
async fn start_with_config_locked(
&self,
app_state: Arc<AppState>,
settings: &McpHttpServerSettings,
config: HttpRuntimeConfig,
) -> Result<(), String> {
self.stop_locked().await;
// Bind before we claim success in the UI. This makes an occupied port
// or an invalid socket setup a synchronous settings error instead of
// a misleading short-lived "running" status.
let bind_addr = config.bind_addr();
let listener = tokio::net::TcpListener::bind(bind_addr)
.await
.map_err(|error| format!("failed to bind MCP HTTP service at {bind_addr}: {error}"))?;
let cancellation = CancellationToken::new();
let diagnostics = self.diagnostics.clone();
let backend: Arc<dyn DbxBackend> = Arc::new(LocalBackend::from_app_state(app_state, self.data_dir.clone()));
let shutdown = cancellation.clone();
let task = tauri::async_runtime::spawn(async move {
let result = serve_streamable_http_on_listener(backend, config, shutdown.clone(), listener).await;
if let Err(error) = result {
let mut diagnostics = diagnostics.lock().unwrap_or_else(|error| error.into_inner());
let message = format!("MCP HTTP service stopped unexpectedly: {error}");
diagnostics.last_error = Some(message.clone());
push_log(&mut diagnostics, format!("ERROR {message}"));
} else if !shutdown.is_cancelled() {
let mut diagnostics = diagnostics.lock().unwrap_or_else(|error| error.into_inner());
let message = "MCP HTTP service stopped unexpectedly".to_string();
diagnostics.last_error = Some(message.clone());
push_log(&mut diagnostics, format!("ERROR {message}"));
}
});
*self.server.lock().unwrap_or_else(|error| error.into_inner()) =
Some(RunningServer { settings: settings.clone(), cancellation, task });
{
let mut diagnostics = self.diagnostics.lock().unwrap_or_else(|error| error.into_inner());
diagnostics.last_error = None;
push_log(&mut diagnostics, "MCP HTTP service started".to_string());
}
Ok(())
}
}
fn endpoint_for_settings(settings: &McpHttpServerSettings) -> Option<String> {
let host = settings.host.trim();
let authority = match host {
"0.0.0.0" | "::" => format_client_authority(
settings.allowed_hosts.first().map(|host| host.trim()).filter(|host| !host.is_empty())?,
settings.port,
),
host if host.contains(':') => format!("[{host}]:{}", settings.port),
host => format!("{host}:{}", settings.port),
};
Some(format!("http://{authority}{}", settings.path))
}
fn format_client_authority(authority: &str, port: u16) -> String {
if authority.starts_with('[') {
return if authority.rsplit_once(':').is_some_and(|(_, port)| port.parse::<u16>().is_ok()) {
authority.to_string()
} else {
format!("{authority}:{port}")
};
}
if let Ok(ip) = authority.parse::<IpAddr>() {
return if ip.is_ipv6() { format!("[{authority}]:{port}") } else { format!("{authority}:{port}") };
}
if authority.rsplit_once(':').is_some_and(|(_, port)| port.parse::<u16>().is_ok()) {
authority.to_string()
} else {
format!("{authority}:{port}")
}
}
fn push_log(diagnostics: &mut McpHttpServerDiagnostics, line: String) {
const MAX_LOG_LINES: usize = 100;
diagnostics.logs.push_back(line);
while diagnostics.logs.len() > MAX_LOG_LINES {
diagnostics.logs.pop_front();
}
}
impl Drop for McpHttpServerState {
fn drop(&mut self) {
if let Some(supervisor) = self.supervisor.get_mut().unwrap_or_else(|error| error.into_inner()).take() {
supervisor.cancellation.cancel();
}
if let Some(server) = self.server.get_mut().unwrap_or_else(|error| error.into_inner()).take() {
server.cancellation.cancel();
server.task.abort();
}
}
}
fn validate_settings(settings: &McpHttpServerSettings) -> Result<(), String> {
let ip = settings.host.parse::<IpAddr>().map_err(|_| "MCP HTTP host must be an IP address".to_string())?;
if settings.port == 0 {
return Err("MCP HTTP port must be between 1 and 65535".to_string());
}
if settings.path == "/"
|| !settings.path.starts_with('/')
|| settings.path.ends_with('/')
|| settings.path.contains('?')
|| settings.path.contains('#')
{
return Err("MCP HTTP path must be an absolute path such as /mcp".to_string());
}
if !ip.is_loopback() {
if !settings.allow_remote {
return Err("non-loopback MCP HTTP binding requires remote access to be explicitly enabled".to_string());
}
if settings.allowed_hosts.is_empty() || settings.allowed_origins.is_empty() {
return Err("remote MCP HTTP binding requires allowed hosts and allowed origins".to_string());
}
}
Ok(())
}
#[tauri::command]
pub async fn load_mcp_http_server_settings(state: State<'_, Arc<AppState>>) -> Result<McpHttpServerSettings, String> {
state.storage.load_mcp_http_server_settings().await
}
#[tauri::command]
pub async fn save_mcp_http_server_settings(
settings: McpHttpServerSettings,
state: State<'_, Arc<AppState>>,
service: State<'_, Arc<McpHttpServerState>>,
) -> Result<McpHttpServerStatus, String> {
let previous_settings = state.storage.load_mcp_http_server_settings().await?;
if settings.enabled {
service.start(state.inner().clone(), &settings).await?;
} else {
service.stop().await;
}
if let Err(error) = state.storage.save_mcp_http_server_settings(&settings).await {
let restore_result = if previous_settings.enabled {
service.start(state.inner().clone(), &previous_settings).await
} else {
service.stop().await;
Ok(())
};
if let Err(restore_error) = restore_result {
return Err(format!(
"{error}; additionally failed to restore the previous MCP HTTP service: {restore_error}"
));
}
return Err(error);
}
if settings.enabled {
spawn_health_supervisor(state.inner().clone(), service.inner().clone());
} else {
service.cancel_health_supervisor();
}
Ok(service.status(&settings))
}
#[tauri::command]
pub async fn mcp_http_server_status(
state: State<'_, Arc<AppState>>,
service: State<'_, Arc<McpHttpServerState>>,
) -> Result<McpHttpServerStatus, String> {
let settings = state.storage.load_mcp_http_server_settings().await?;
let status = service.status(&settings);
if settings.enabled && !status.running {
if let Err(error) = service.start(state.inner().clone(), &settings).await {
service.record_error(format!("automatic restart failed: {error}"));
}
}
Ok(service.status(&settings))
}
#[tauri::command]
pub async fn rotate_mcp_http_server_token(
state: State<'_, Arc<AppState>>,
service: State<'_, Arc<McpHttpServerState>>,
) -> Result<McpHttpServerStatus, String> {
let settings = state.storage.load_mcp_http_server_settings().await?;
service.rotate_token()?;
if settings.enabled {
service.start(state.inner().clone(), &settings).await?;
}
Ok(service.status(&settings))
}
pub async fn start_if_enabled(state: Arc<AppState>, service: Arc<McpHttpServerState>) {
match state.storage.load_mcp_http_server_settings().await {
Ok(settings) if settings.enabled => {
if let Err(error) = service.start(state.clone(), &settings).await {
log::warn!("Failed to restore MCP HTTP service: {error}");
service.record_error(format!("failed to restore MCP HTTP service: {error}"));
}
spawn_health_supervisor(state, service);
}
Ok(_) => {}
Err(error) => log::warn!("Failed to load MCP HTTP settings: {error}"),
}
}
/// The service is optional, but once a user explicitly enables it it should
/// not require opening Settings to recover from a transient child-process
/// failure. The supervisor deliberately lives only in the desktop process:
/// Web deployments are managed by their container/process supervisor instead.
fn spawn_health_supervisor(state: Arc<AppState>, service: Arc<McpHttpServerState>) {
let Some((supervisor_id, cancellation)) = service.claim_health_supervisor() else {
return;
};
tauri::async_runtime::spawn(async move {
loop {
tokio::select! {
_ = cancellation.cancelled() => break,
_ = tokio::time::sleep(std::time::Duration::from_secs(15)) => {}
}
let settings = match state.storage.load_mcp_http_server_settings().await {
Ok(settings) => settings,
Err(error) => {
service.record_error(format!("MCP HTTP health supervisor could not load settings: {error}"));
continue;
}
};
if !settings.enabled {
break;
}
if !service.status(&settings).running {
if let Err(error) = service.start(state.clone(), &settings).await {
service.record_error(format!("automatic restart failed: {error}"));
}
}
}
service.release_health_supervisor(supervisor_id);
});
}
#[cfg(test)]
mod tests {
use super::{endpoint_for_settings, McpHttpServerState};
use dbx_core::storage::McpHttpServerSettings;
#[test]
fn endpoint_uses_a_client_reachable_authority() {
let settings = McpHttpServerSettings {
enabled: true,
host: "0.0.0.0".to_string(),
port: 5225,
path: "/mcp".to_string(),
allow_remote: true,
allowed_hosts: vec!["mcp.example.test:5225".to_string()],
allowed_origins: vec!["https://client.example.test".to_string()],
};
assert_eq!(endpoint_for_settings(&settings).as_deref(), Some("http://mcp.example.test:5225/mcp"));
let hostname_without_port =
McpHttpServerSettings { allowed_hosts: vec!["mcp.example.test".to_string()], ..settings.clone() };
assert_eq!(endpoint_for_settings(&hostname_without_port).as_deref(), Some("http://mcp.example.test:5225/mcp"));
let remote_ipv6 = McpHttpServerSettings { allowed_hosts: vec!["2001:db8::1".to_string()], ..settings.clone() };
assert_eq!(endpoint_for_settings(&remote_ipv6).as_deref(), Some("http://[2001:db8::1]:5225/mcp"));
let ipv6 =
McpHttpServerSettings { host: "::1".to_string(), allow_remote: false, allowed_hosts: vec![], ..settings };
assert_eq!(endpoint_for_settings(&ipv6).as_deref(), Some("http://[::1]:5225/mcp"));
}
#[test]
fn cancelled_supervisor_cannot_clear_a_replacement() {
let service = McpHttpServerState::new(std::env::temp_dir());
let (first_id, _) = service.claim_health_supervisor().expect("first supervisor");
service.cancel_health_supervisor();
let (second_id, _) = service.claim_health_supervisor().expect("replacement supervisor");
service.release_health_supervisor(first_id);
assert_ne!(first_id, second_id);
assert!(service.claim_health_supervisor().is_none());
}
}
+1
View File
@@ -25,6 +25,7 @@ pub mod keychain;
pub mod list_sql_files;
pub mod mcp;
pub mod mcp_bridge;
pub mod mcp_http_server;
pub mod mongo_cmd;
#[cfg(feature = "mq-admin")]
pub mod mq_cmd;
+10
View File
@@ -1617,6 +1617,12 @@ pub fn run() {
}));
let state = Arc::new(state);
app.manage(state.clone());
let mcp_http_server = Arc::new(commands::mcp_http_server::McpHttpServerState::new(data_dir.clone()));
app.manage(mcp_http_server.clone());
let mcp_http_state = state.clone();
tauri::async_runtime::spawn(async move {
commands::mcp_http_server::start_if_enabled(mcp_http_state, mcp_http_server).await;
});
app.manage(commands::redis_pubsub_server::start_pubsub_server(state.clone()));
app.manage(commands::saved_sql::SavedSqlStorageState { data_dir: data_dir.clone() });
app.manage(commands::external_sql::ExternalSqlOpenState::default());
@@ -1735,6 +1741,10 @@ pub fn run() {
commands::app_settings::save_pinned_tree_node_ids,
commands::app_settings::load_mcp_global_policy,
commands::app_settings::save_mcp_global_policy,
commands::mcp_http_server::load_mcp_http_server_settings,
commands::mcp_http_server::save_mcp_http_server_settings,
commands::mcp_http_server::mcp_http_server_status,
commands::mcp_http_server::rotate_mcp_http_server_token,
commands::app_settings::load_editor_settings,
commands::app_settings::save_editor_settings,
commands::app_settings::load_open_tabs_state,