Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1,436 changes: 1,400 additions & 36 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ futures = "0.3"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
serde_yaml = { package = "yaml_serde", version = "0.10" }

# Error handling
anyhow = "1.0"
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"preview": "vite preview",
"tauri": "tauri",
"dev:web": "vite",
"dev:web:admin": "VITE_ADMIN_WEB=true vite",
"build:web": "tsc && vite build",
"build:web:admin": "node ../../scripts/build-web-admin.mjs",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"typecheck": "tsc --noEmit",
Expand All @@ -22,11 +24,13 @@
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-updater": "^2",
"i18next": "^26.3.1",
"immer": "^11.0.1",
"lucide-react": "^0.561.0",
"posthog-js": "^1.387.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^17.0.8",
"zustand": "^5.0.9"
},
"devDependencies": {
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,9 @@ notify-debouncer-mini = "0.5"
mcpmux-core.workspace = true
mcpmux-gateway.workspace = true
mcpmux-storage.workspace = true

[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-foundation = "0.3"
objc2-contacts = { version = "0.3", features = ["CNContactStore", "block2"] }
block2 = "0.6"
14 changes: 14 additions & 0 deletions apps/desktop/src-tauri/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSContactsUsageDescription</key>
<string>McpMux relays MCP servers that may read macOS Contacts (e.g. resolving phone numbers and emails to display names in messaging tools).</string>
<key>NSCalendarsUsageDescription</key>
<string>McpMux relays MCP servers that may read macOS Calendar events.</string>
<key>NSRemindersUsageDescription</key>
<string>McpMux relays MCP servers that may read macOS Reminders.</string>
<key>NSAppleEventsUsageDescription</key>
<string>McpMux relays MCP servers that may automate other apps via AppleScript.</string>
</dict>
</plist>
66 changes: 62 additions & 4 deletions apps/desktop/src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
use std::fs;
use std::path::Path;
use std::process::Command;

fn main() {
// Read tauri.conf.json to extract the app identifier
// This ensures a single source of truth for the identifier
let config_path = Path::new("tauri.conf.json");
if let Ok(contents) = fs::read_to_string(config_path) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) {
Expand All @@ -12,9 +11,68 @@ fn main() {
}
}
}

// Tell Cargo to re-run this script if tauri.conf.json changes
println!("cargo:rerun-if-changed=tauri.conf.json");

// Stamp git/build metadata into the binary so the admin UI can detect a stale
// SPA build (web-admin serves a pre-built bundle from `apps/desktop/dist`).
let git_sha = git_output(&["rev-parse", "--short", "HEAD"]).unwrap_or_default();
let git_branch =
git_output(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "unknown".to_string());
let commit_time =
git_output(&["log", "-1", "--format=%ci"]).unwrap_or_else(|| "unknown".to_string());
let build_time = std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(format_epoch)
.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format_epoch(d.as_secs()))
.unwrap_or_else(|_| "unknown".to_string())
});

println!("cargo:rustc-env=MCPMUX_BUILD_GIT_SHA={}", git_sha);
println!("cargo:rustc-env=MCPMUX_BUILD_GIT_BRANCH={}", git_branch);
println!("cargo:rustc-env=MCPMUX_BUILD_COMMIT_TIME={}", commit_time);
println!("cargo:rustc-env=MCPMUX_BUILD_TIME={}", build_time);
println!("cargo:rerun-if-changed=../../../.git/HEAD");
println!("cargo:rerun-if-changed=../../../.git/logs/HEAD");

tauri_build::build()
}

fn git_output(args: &[&str]) -> Option<String> {
Command::new("git")
.args(args)
.output()
.ok()
.filter(|out| out.status.success())
.and_then(|out| String::from_utf8(out.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}

/// Format a Unix timestamp as a naive UTC datetime string (no external deps).
fn format_epoch(secs: u64) -> String {
let days = secs / 86400;
let rem = secs % 86400;
let hh = rem / 3600;
let mm = (rem % 3600) / 60;
let ss = rem % 60;

let jdn = days + 2440588;
let a = jdn + 32044;
let b = (4 * a + 3) / 146097;
let c = a - (146097 * b) / 4;
let d = (4 * c + 3) / 1461;
let e = c - (1461 * d) / 4;
let m = (5 * e + 2) / 153;
let day = e - (153 * m + 2) / 5 + 1;
let month = m + 3 - 12 * (m / 10);
let year = 100 * b + d - 4800 + m / 10;

format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC",
year, month, day, hh, mm, ss
)
}
2 changes: 2 additions & 0 deletions apps/desktop/src-tauri/src/commands/feature_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ pub async fn add_feature_set_member(
member_type,
member_id: input.member_id,
mode,
surfaced: false,
};

feature_set.members.push(member);
Expand Down Expand Up @@ -515,6 +516,7 @@ pub async fn set_feature_set_members(
member_type,
member_id: input.member_id,
mode,
surfaced: false,
}
})
.collect();
Expand Down
127 changes: 103 additions & 24 deletions apps/desktop/src-tauri/src/commands/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use mcpmux_gateway::{
};
use serde::Serialize;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::sync::RwLock;
use tracing::{error, info, trace, warn};
use uuid::Uuid;
Expand Down Expand Up @@ -144,26 +144,6 @@ pub(crate) async fn attach_approval_publisher<R: tauri::Runtime>(
approval_broker: &Arc<mcpmux_gateway::services::ApprovalBroker>,
app_handle: tauri::AppHandle<R>,
) {
// Restore the persisted "require approval" switch onto the broker (which is
// recreated on every gateway start). Default ON when unset. This is the
// single chokepoint both start paths (auto-start + start_gateway command)
// funnel through, so the setting always survives a restart.
{
use tauri::Manager;
let required = match app_handle.try_state::<AppState>() {
Some(app_state) => app_state
.settings_repository
.get("meta_tools.require_approval")
.await
.ok()
.flatten()
.map(|v| v != "false")
.unwrap_or(true),
None => true,
};
approval_broker.set_require_approval(required);
}

let publisher: mcpmux_gateway::services::meta_tools::ApprovalPublisher = Arc::new(move |req| {
let app_handle = app_handle.clone();
Box::pin(async move {
Expand Down Expand Up @@ -338,9 +318,21 @@ pub fn start_domain_event_bridge(
"[Gateway] Forwarding domain event to UI"
);

if let Err(e) = app_handle_clone.emit(channel, payload) {
error!("[Gateway] Failed to emit {} event: {}", channel, e);
}
// Emit to Tauri webview and admin SSE subscribers.
let ui_event_bus = {
let admin_state: tauri::State<
'_,
Arc<tokio::sync::RwLock<crate::services::AdminServerState>>,
> = app_handle_clone.state();
let guard = admin_state.read().await;
guard.ui_event_bus.clone()
};
crate::services::ui_events::emit_ui_channel(
&app_handle_clone,
Some(&ui_event_bus),
channel,
payload,
);
}

info!("[Gateway] Domain event bridge stopped");
Expand Down Expand Up @@ -730,6 +722,36 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val
"builtin-server-config-changed",
serde_json::json!({ "space_id": space_id }),
),
DomainEvent::WorkspaceAppearanceChanged { workspace_root } => (
"workspace-appearance-changed",
serde_json::json!({ "workspace_root": workspace_root }),
),

DomainEvent::ServerVersionChecked {
space_id,
server_id,
} => (
"server-version-checked",
serde_json::json!({
"space_id": space_id,
"server_id": server_id,
}),
),

DomainEvent::ServerUpdateAvailable {
space_id,
server_id,
current_version,
latest_version,
} => (
"server-update-available",
serde_json::json!({
"space_id": space_id,
"server_id": server_id,
"current_version": current_version,
"latest_version": latest_version,
}),
),
}
}

Expand Down Expand Up @@ -988,6 +1010,24 @@ pub async fn start_gateway(
warn!("[Gateway] Failed to emit gateway-changed(started): {}", e);
}

// Sync admin server health endpoint and register SSE stream.
{
use crate::services::admin_server::{register_gateway_sse, set_gateway_running};
let admin_state: tauri::State<
'_,
Arc<tokio::sync::RwLock<crate::services::AdminServerState>>,
> = app_handle.state();
let guard = admin_state.read().await;
set_gateway_running(&guard, true);
if let Some(gw_state) = state.gateway_state.clone() {
let admin_guard_clone = admin_state.clone();
let gw_state_clone = gw_state;
drop(guard);
let guard2 = admin_guard_clone.read().await;
register_gateway_sse(&guard2, &gw_state_clone).await;
}
}

Ok(url)
}

Expand Down Expand Up @@ -1020,6 +1060,18 @@ pub async fn stop_gateway(
warn!("[Gateway] Failed to emit gateway-changed(stopped): {}", e);
}

// Sync admin server health endpoint and clear SSE stream.
{
use crate::services::admin_server::{clear_gateway_sse, set_gateway_running};
let admin_state: tauri::State<
'_,
Arc<tokio::sync::RwLock<crate::services::AdminServerState>>,
> = app_handle.state();
let guard = admin_state.read().await;
set_gateway_running(&guard, false);
clear_gateway_sse(&guard).await;
}

Ok(())
}

Expand Down Expand Up @@ -1339,6 +1391,7 @@ pub async fn connect_server(
&server_definition.transport,
&installed,
Some(app_state.data_dir()),
mcpmux_gateway::pool::transport::resolution::TransportResolutionOptions::default(),
);

// Connect using pool service (manual connect from API)
Expand Down Expand Up @@ -1577,6 +1630,7 @@ pub async fn connect_all_enabled_servers(
&server_definition.transport,
&installed,
Some(app_state.data_dir()),
mcpmux_gateway::pool::transport::resolution::TransportResolutionOptions::default(),
);

servers_to_connect.push((server_info, transport, server_definition, installed));
Expand Down Expand Up @@ -1692,3 +1746,28 @@ pub struct PoolStatsResponse {
pub connected_instances: usize,
pub total_space_server_mappings: usize,
}

/// Reload (or stop-then-start) the web admin server based on current settings.
///
/// Call this after the user toggles `gateway.admin_enabled`, changes the admin
/// port, or modifies Cloudflare Access settings so the admin server picks up the
/// new configuration without requiring a full app restart.
#[tauri::command]
pub async fn reload_admin_server(
app_handle: tauri::AppHandle,
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
server_manager_state: State<'_, Arc<RwLock<ServerManagerState>>>,
) -> Result<(), String> {
let admin_state: tauri::State<'_, Arc<tokio::sync::RwLock<crate::services::AdminServerState>>> =
app_handle.state();
let event_bus = mcpmux_core::create_shared_event_bus();
crate::services::admin_server::reload_admin_server(
app_handle.clone(),
admin_state.inner().clone(),
gateway_state.inner().clone(),
server_manager_state.inner().clone(),
event_bus,
)
.await;
Ok(())
}
17 changes: 8 additions & 9 deletions apps/desktop/src-tauri/src/commands/meta_tool_approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,21 +142,20 @@ pub async fn get_meta_tools_require_approval(
pub async fn set_meta_tools_require_approval(
required: bool,
app_state: State<'_, AppState>,
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
) -> Result<bool, String> {
app_state
.settings_repository
.set(REQUIRE_APPROVAL_KEY, &required.to_string())
.await
.map_err(|e| e.to_string())?;

let broker = {
let state = gateway_state.read().await;
state.approval_broker.clone()
};
if let Some(broker) = broker {
broker.set_require_approval(required);
}
warn!(required, "[meta-tool] require-approval switch updated");
// ponytail: the split-module ApprovalBroker dropped the global bypass —
// its only write tool (`mcpmux_bind_current_workspace`) always prompts.
// The setting is still persisted for the Settings UI, but no longer toggles
// a live broker. Full removal of this toggle is deferred to the UI phase.
warn!(
required,
"[meta-tool] require-approval preference persisted"
);
Ok(required)
}
4 changes: 4 additions & 0 deletions apps/desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ pub mod logs;
pub mod meta_tool_approval;
pub mod oauth;
pub mod server;
pub mod server_clone;
pub mod server_discovery;
pub mod server_feature;
pub mod server_manager;
pub mod settings;
pub mod space;
pub mod workspace_appearance;
pub mod workspace_binding;

// Re-export commands for convenience
Expand All @@ -34,9 +36,11 @@ pub use logs::*;
pub use meta_tool_approval::*;
pub use oauth::*;
pub use server::*;
pub use server_clone::*;
pub use server_discovery::*;
pub use server_feature::*;
pub use server_manager::*;
pub use settings::*;
pub use space::*;
pub use workspace_appearance::*;
pub use workspace_binding::*;
Loading