diff --git a/Cargo.lock b/Cargo.lock index ab6ad1f7..a0df64f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2629,7 +2629,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mcpmux" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "async-trait", @@ -2668,7 +2668,7 @@ dependencies = [ [[package]] name = "mcpmux-core" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "async-trait", @@ -2693,7 +2693,7 @@ dependencies = [ [[package]] name = "mcpmux-gateway" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "async-stream", @@ -2733,7 +2733,7 @@ dependencies = [ [[package]] name = "mcpmux-mcp" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "async-trait", @@ -2752,7 +2752,7 @@ dependencies = [ [[package]] name = "mcpmux-storage" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "async-trait", diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index bd1adc74..6cf0d8f3 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -920,6 +920,23 @@ pub async fn start_gateway( let grant_service = server.grant_service(); let session_roots = server.session_roots(); + // Seed the system-wide inbound-auth toggle into the running gateway from + // persisted settings (default: auth required). Live changes go through + // `set_gateway_auth_disabled`. + { + let disabled = app_state + .settings_repository + .get(GATEWAY_AUTH_DISABLED_KEY) + .await + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(false); + if disabled { + gw_state.write().await.set_auth_disabled(true); + } + } + // Subscribe to OAuth completions BEFORE spawn so we don't miss early // events emitted during initial auto-connect. let oauth_completion_rx = pool_service.oauth_manager().subscribe(); @@ -1105,6 +1122,47 @@ pub async fn reset_gateway_port(app_state: State<'_, AppState>) -> Result<(), St Ok(()) } +/// App-settings key for the system-wide inbound-auth toggle. Stored as +/// `"true"`/`"false"`; missing means auth is required (the secure default). +pub const GATEWAY_AUTH_DISABLED_KEY: &str = "gateway.auth_disabled"; + +/// Whether inbound MCP authentication is disabled — connections are accepted +/// without an access key (localhost-only convenience). Default **false** (auth +/// required). +#[tauri::command] +pub async fn get_gateway_auth_disabled(app_state: State<'_, AppState>) -> Result { + let stored = app_state + .settings_repository + .get(GATEWAY_AUTH_DISABLED_KEY) + .await + .map_err(|e| e.to_string())?; + Ok(stored.map(|v| v == "true").unwrap_or(false)) +} + +/// Enable/disable system-wide inbound auth. Persists the setting AND mirrors it +/// into the running gateway so the change takes effect immediately (no +/// restart). When the gateway isn't running it's a no-op beyond persistence — +/// `start_gateway` seeds the value on launch. +#[tauri::command] +pub async fn set_gateway_auth_disabled( + disabled: bool, + app_state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result { + app_state + .settings_repository + .set(GATEWAY_AUTH_DISABLED_KEY, &disabled.to_string()) + .await + .map_err(|e| e.to_string())?; + + let state = gateway_state.read().await; + if let Some(ref gw) = state.gateway_state { + gw.write().await.set_auth_disabled(disabled); + } + info!("[Gateway] Inbound auth disabled set to {}", disabled); + Ok(disabled) +} + /// Which port source a startup attempt would use. /// /// Kept as a string-valued enum for clean JSON serialization to the UI. diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index d6df8c7b..3aaa5bc0 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod server_manager; pub mod settings; pub mod space; pub mod workspace_binding; +pub mod workspace_install; // Re-export commands for convenience pub use builtin_servers::*; @@ -40,3 +41,4 @@ pub use server_manager::*; pub use settings::*; pub use space::*; pub use workspace_binding::*; +pub use workspace_install::*; diff --git a/apps/desktop/src-tauri/src/commands/workspace_install.rs b/apps/desktop/src-tauri/src/commands/workspace_install.rs new file mode 100644 index 00000000..b37ceca5 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/workspace_install.rs @@ -0,0 +1,529 @@ +//! Per-workspace MCP client config installer. +//! +//! Registers the McpMux gateway endpoint in a *project-local* MCP client config +//! (e.g. `.cursor/mcp.json`, `.vscode/mcp.json`) inside a chosen workspace +//! folder, injecting an `X-Mcpmux-Workspace` header whose value is that folder's +//! path. The gateway pins that header and routes the connection to the folder's +//! workspace binding deterministically — even for clients that don't report MCP +//! `roots` reliably (notably Cursor). This is the "less manual work" path: pick +//! a folder, pick clients, and McpMux writes (or extends) each client's config. +//! +//! Distinct from `config_export` (which exports the *upstream server list* to a +//! client): here we register the single gateway entry with a per-workspace +//! header. +//! +//! Only clients with a true **project-local** config scope are supported — a +//! global config can hold only one header value and so can't be per-workspace. +//! Windsurf/Cline (global-only) and Claude Desktop (stdio, no static headers) +//! are intentionally excluded. + +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::json; +use tracing::info; + +/// The server name McpMux registers itself under in every client config. +const SERVER_NAME: &str = "mcpmux"; + +/// The per-workspace routing header. Its value is the workspace folder path. +const WORKSPACE_HEADER: &str = "X-Mcpmux-Workspace"; + +/// Static description of one client's project-local MCP config shape. The +/// research-backed differences between clients live here and nowhere else, so +/// the writer and the UI share a single source of truth. +#[derive(Debug, Clone, Copy)] +struct ClientSpec { + /// Stable id used by the API and UI (e.g. "cursor"). + id: &'static str, + /// Human label for the UI. + label: &'static str, + /// Path of the config file relative to the workspace folder, as segments + /// (e.g. `[".cursor", "mcp.json"]`). + rel_path: &'static [&'static str], + /// Top-level object the server entry nests under. Differs across clients: + /// `mcpServers` (Cursor/Claude Code), `servers` (VS Code), `mcp` + /// (opencode), `context_servers` (Zed). + servers_key: &'static str, + /// The key the endpoint URL goes under (always `url` for the project-local + /// clients we support; Windsurf's `serverUrl` is global-only and excluded). + url_key: &'static str, + /// The transport `type` value, when the client requires one. `http` for + /// Claude Code / VS Code, `remote` for opencode; Cursor and Zed infer it + /// from the presence of `url`, so they get `None`. + type_value: Option<&'static str>, +} + +/// The supported project-local clients. Adding a client is a one-line table +/// entry plus a test. +const CLIENTS: &[ClientSpec] = &[ + ClientSpec { + id: "cursor", + label: "Cursor", + rel_path: &[".cursor", "mcp.json"], + servers_key: "mcpServers", + url_key: "url", + type_value: None, + }, + ClientSpec { + id: "claude-code", + label: "Claude Code", + rel_path: &[".mcp.json"], + servers_key: "mcpServers", + url_key: "url", + type_value: Some("http"), + }, + ClientSpec { + id: "vscode", + label: "VS Code / Copilot", + rel_path: &[".vscode", "mcp.json"], + servers_key: "servers", + url_key: "url", + type_value: Some("http"), + }, + ClientSpec { + id: "opencode", + label: "opencode", + rel_path: &["opencode.json"], + servers_key: "mcp", + url_key: "url", + type_value: Some("remote"), + }, + ClientSpec { + id: "zed", + label: "Zed", + rel_path: &[".zed", "settings.json"], + servers_key: "context_servers", + url_key: "url", + type_value: None, + }, +]; + +fn find_client(id: &str) -> Option<&'static ClientSpec> { + CLIENTS.iter().find(|c| c.id == id) +} + +/// The config file path for a client inside a workspace folder. +fn config_path(spec: &ClientSpec, workspace_dir: &Path) -> PathBuf { + let mut p = workspace_dir.to_path_buf(); + for seg in spec.rel_path { + p.push(seg); + } + p +} + +/// Build the McpMux server entry for a client. The header value is the +/// workspace folder path; an optional bearer token is added as `Authorization` +/// when inbound auth is enabled. +fn build_entry( + spec: &ClientSpec, + mcp_url: &str, + header_value: &str, + bearer: Option<&str>, +) -> serde_json::Value { + let mut headers = serde_json::Map::new(); + headers.insert(WORKSPACE_HEADER.to_string(), json!(header_value)); + if let Some(token) = bearer { + headers.insert( + "Authorization".to_string(), + json!(format!("Bearer {token}")), + ); + } + + let mut entry = serde_json::Map::new(); + // `type` first when present, then url, then headers — cosmetic but stable. + if let Some(t) = spec.type_value { + entry.insert("type".to_string(), json!(t)); + } + entry.insert(spec.url_key.to_string(), json!(mcp_url)); + entry.insert("headers".to_string(), serde_json::Value::Object(headers)); + serde_json::Value::Object(entry) +} + +/// Merge the McpMux entry into an existing config (or a fresh `{}` when there's +/// none), preserving every other server already configured. Returns the +/// pretty-printed file content. +/// +/// Refuses to touch a file that isn't plain JSON (e.g. JSONC with comments) or +/// whose root / servers key isn't an object — the caller surfaces that as an +/// error rather than clobbering the user's file. +fn merge_entry( + existing: Option<&str>, + spec: &ClientSpec, + entry: serde_json::Value, +) -> Result { + let mut root: serde_json::Value = match existing { + Some(s) if !s.trim().is_empty() => serde_json::from_str(s).map_err(|e| { + format!("existing config is not plain JSON ({e}); edit it by hand to add McpMux") + })?, + _ => json!({}), + }; + + let obj = root + .as_object_mut() + .ok_or_else(|| "existing config root is not a JSON object".to_string())?; + + let servers = obj.entry(spec.servers_key).or_insert_with(|| json!({})); + let servers = servers.as_object_mut().ok_or_else(|| { + format!( + "'{}' in the existing config is not an object", + spec.servers_key + ) + })?; + + servers.insert(SERVER_NAME.to_string(), entry); + + let mut out = serde_json::to_string_pretty(&root).map_err(|e| e.to_string())?; + out.push('\n'); + Ok(out) +} + +/// Result of installing into one client's config. +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceInstallResult { + pub client: String, + pub label: String, + /// Absolute path of the config file written (or that failed). + pub path: String, + /// "created" | "updated" | "error". + pub action: String, + /// Path of the backup written when an existing file was modified. + pub backed_up: Option, + /// Error message when `action == "error"`. + pub error: Option, +} + +fn error_result(spec: &ClientSpec, path: &Path, msg: String) -> WorkspaceInstallResult { + WorkspaceInstallResult { + client: spec.id.to_string(), + label: spec.label.to_string(), + path: path.to_string_lossy().to_string(), + action: "error".to_string(), + backed_up: None, + error: Some(msg), + } +} + +/// Write (or extend) one client's config. Backs up an existing file before +/// modifying it, and creates parent directories as needed. +fn install_one( + spec: &ClientSpec, + workspace_dir: &Path, + mcp_url: &str, + header_value: &str, + bearer: Option<&str>, +) -> WorkspaceInstallResult { + let path = config_path(spec, workspace_dir); + let existed = path.exists(); + + let existing = if existed { + match std::fs::read_to_string(&path) { + Ok(s) => Some(s), + Err(e) => { + return error_result(spec, &path, format!("failed to read existing config: {e}")) + } + } + } else { + None + }; + + let entry = build_entry(spec, mcp_url, header_value, bearer); + let merged = match merge_entry(existing.as_deref(), spec, entry) { + Ok(m) => m, + Err(e) => return error_result(spec, &path, e), + }; + + // Back up an existing file before overwriting. + let mut backed_up = None; + if existed { + let bak = PathBuf::from(format!("{}.mcpmux-bak", path.display())); + if let Err(e) = std::fs::copy(&path, &bak) { + return error_result( + spec, + &path, + format!("failed to back up existing config: {e}"), + ); + } + backed_up = Some(bak.to_string_lossy().to_string()); + } + + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + return error_result( + spec, + &path, + format!("failed to create config directory: {e}"), + ); + } + } + if let Err(e) = std::fs::write(&path, merged) { + return error_result(spec, &path, format!("failed to write config: {e}")); + } + + WorkspaceInstallResult { + client: spec.id.to_string(), + label: spec.label.to_string(), + path: path.to_string_lossy().to_string(), + action: if existed { "updated" } else { "created" }.to_string(), + backed_up, + error: None, + } +} + +/// One supported client, for the UI checklist. +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceInstallClient { + pub id: String, + pub label: String, + /// The project-local config path, shown to the user (e.g. ".cursor/mcp.json"). + pub config_path: String, +} + +/// List the clients the per-workspace installer supports. +#[tauri::command] +pub fn list_workspace_install_clients() -> Vec { + CLIENTS + .iter() + .map(|c| WorkspaceInstallClient { + id: c.id.to_string(), + label: c.label.to_string(), + config_path: c.rel_path.join("/"), + }) + .collect() +} + +/// A copy-paste config snippet for one client. +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceConfigSnippet { + pub client: String, + pub label: String, + /// Where this would be written, relative to the workspace folder. + pub config_path: String, + /// Full file content (top-level key + the McpMux entry), ready to paste + /// into a fresh file. + pub content: String, +} + +/// Generate a copy-paste config snippet for one client without writing anything. +#[tauri::command] +pub fn generate_workspace_config_snippet( + client: String, + server_url: String, + workspace_root: String, + bearer: Option, +) -> Result { + let spec = find_client(&client).ok_or_else(|| format!("unknown client '{client}'"))?; + let entry = build_entry(spec, &server_url, &workspace_root, bearer.as_deref()); + // A full-file snippet (top-level key included) so it pastes cleanly into an + // empty project config; merging into an existing file is what the install + // command is for. + let content = merge_entry(None, spec, entry)?; + Ok(WorkspaceConfigSnippet { + client: spec.id.to_string(), + label: spec.label.to_string(), + config_path: spec.rel_path.join("/"), + content, + }) +} + +/// Install (create or extend) the McpMux gateway entry into the chosen clients' +/// project-local configs inside `workspace_root`, injecting the +/// `X-Mcpmux-Workspace` header set to `workspace_root`. +/// +/// `server_url` is the gateway MCP endpoint (e.g. +/// `http://localhost:45818/mcp`). `bearer` is an optional access token to embed +/// as `Authorization` when inbound auth is enabled; omit it when auth is +/// disabled. +#[tauri::command] +pub fn install_workspace_mcp_config( + workspace_root: String, + server_url: String, + clients: Vec, + bearer: Option, +) -> Result, String> { + let dir = PathBuf::from(&workspace_root); + if !dir.is_dir() { + return Err(format!("workspace folder does not exist: {workspace_root}")); + } + if server_url.trim().is_empty() { + return Err("server URL is empty".to_string()); + } + if clients.is_empty() { + return Err("no clients selected".to_string()); + } + + let mut results = Vec::with_capacity(clients.len()); + for id in &clients { + match find_client(id) { + Some(spec) => { + results.push(install_one( + spec, + &dir, + &server_url, + &workspace_root, + bearer.as_deref(), + )); + } + None => { + results.push(WorkspaceInstallResult { + client: id.clone(), + label: id.clone(), + path: String::new(), + action: "error".to_string(), + backed_up: None, + error: Some(format!("unknown client '{id}'")), + }); + } + } + } + + let ok = results.iter().filter(|r| r.action != "error").count(); + info!( + "[WorkspaceInstall] {} of {} client config(s) written for {}", + ok, + results.len(), + workspace_root + ); + Ok(results) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn spec(id: &str) -> &'static ClientSpec { + find_client(id).unwrap() + } + + #[test] + fn builds_entry_with_header_and_optional_type() { + // Cursor: no type, url + headers. + let cursor = build_entry( + spec("cursor"), + "http://localhost:45818/mcp", + "d:\\proj", + None, + ); + assert_eq!(cursor["url"], "http://localhost:45818/mcp"); + assert_eq!(cursor["headers"][WORKSPACE_HEADER], "d:\\proj"); + assert!(cursor.get("type").is_none()); + + // VS Code: type=http. + let vscode = build_entry(spec("vscode"), "http://x/mcp", "/p", None); + assert_eq!(vscode["type"], "http"); + + // opencode: type=remote. + let oc = build_entry(spec("opencode"), "http://x/mcp", "/p", None); + assert_eq!(oc["type"], "remote"); + } + + #[test] + fn bearer_token_becomes_authorization_header() { + let e = build_entry(spec("cursor"), "http://x/mcp", "/p", Some("abc123")); + assert_eq!(e["headers"]["Authorization"], "Bearer abc123"); + } + + #[test] + fn merge_into_empty_creates_top_level_key() { + let entry = build_entry(spec("cursor"), "http://x/mcp", "/p", None); + let out = merge_entry(None, spec("cursor"), entry).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["mcpServers"]["mcpmux"]["url"], "http://x/mcp"); + } + + #[test] + fn vscode_uses_servers_key() { + let entry = build_entry(spec("vscode"), "http://x/mcp", "/p", None); + let out = merge_entry(None, spec("vscode"), entry).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["servers"]["mcpmux"]["type"], "http"); + assert!(v.get("mcpServers").is_none()); + } + + #[test] + fn merge_preserves_other_servers() { + let existing = r#"{ + "mcpServers": { + "other": { "url": "http://other/mcp" } + }, + "someOtherTopLevel": 42 + }"#; + let entry = build_entry(spec("cursor"), "http://x/mcp", "/p", None); + let out = merge_entry(Some(existing), spec("cursor"), entry).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + // Our entry is added... + assert_eq!(v["mcpServers"]["mcpmux"]["url"], "http://x/mcp"); + // ...the sibling server is preserved... + assert_eq!(v["mcpServers"]["other"]["url"], "http://other/mcp"); + // ...and unrelated top-level keys are untouched. + assert_eq!(v["someOtherTopLevel"], 42); + } + + #[test] + fn merge_replaces_an_existing_mcpmux_entry() { + let existing = r#"{ "mcpServers": { "mcpmux": { "url": "http://old/mcp" } } }"#; + let entry = build_entry(spec("cursor"), "http://new/mcp", "/p", None); + let out = merge_entry(Some(existing), spec("cursor"), entry).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["mcpServers"]["mcpmux"]["url"], "http://new/mcp"); + } + + #[test] + fn merge_rejects_non_json_existing() { + // JSONC with a comment is not plain JSON — refuse rather than clobber. + let existing = "{ // a comment\n \"servers\": {} }"; + let entry = build_entry(spec("vscode"), "http://x/mcp", "/p", None); + assert!(merge_entry(Some(existing), spec("vscode"), entry).is_err()); + } + + #[test] + fn merge_rejects_non_object_servers_key() { + let existing = r#"{ "mcpServers": "oops" }"#; + let entry = build_entry(spec("cursor"), "http://x/mcp", "/p", None); + assert!(merge_entry(Some(existing), spec("cursor"), entry).is_err()); + } + + #[test] + fn install_creates_then_updates_with_backup() { + let tmp = std::env::temp_dir().join(format!("mcpmux-wsinstall-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + // First install → created, no backup. + let r1 = install_one( + spec("cursor"), + &tmp, + "http://x/mcp", + &tmp.to_string_lossy(), + None, + ); + assert_eq!(r1.action, "created", "{:?}", r1.error); + assert!(r1.backed_up.is_none()); + let written = std::fs::read_to_string(config_path(spec("cursor"), &tmp)).unwrap(); + assert!(written.contains("mcpmux")); + assert!(written.contains(WORKSPACE_HEADER)); + + // Second install → updated, with backup. + let r2 = install_one( + spec("cursor"), + &tmp, + "http://y/mcp", + &tmp.to_string_lossy(), + None, + ); + assert_eq!(r2.action, "updated"); + assert!(r2.backed_up.is_some()); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn snippet_lists_all_clients() { + let clients = list_workspace_install_clients(); + let ids: Vec<&str> = clients.iter().map(|c| c.id.as_str()).collect(); + for expected in ["cursor", "claude-code", "vscode", "opencode", "zed"] { + assert!(ids.contains(&expected), "missing {expected}"); + } + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6cc6b1a7..3182543b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -921,6 +921,10 @@ pub fn run() { commands::delete_workspace_binding, commands::validate_workspace_root, commands::get_workspace_effective_features, + // Per-workspace MCP client config install (X-Mcpmux-Workspace header) + commands::list_workspace_install_clients, + commands::generate_workspace_config_snippet, + commands::install_workspace_mcp_config, // Meta-tool approval (self-management mcpmux_* tools) commands::respond_to_meta_tool_approval, commands::list_meta_tool_grants, @@ -945,6 +949,8 @@ pub fn run() { commands::get_gateway_port_settings, commands::set_gateway_port, commands::reset_gateway_port, + commands::get_gateway_auth_disabled, + commands::set_gateway_auth_disabled, commands::probe_gateway_start, commands::take_pending_port_conflict, commands::start_gateway, diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index 138b528f..6c6d64f2 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -565,7 +565,10 @@ function SidePanel({

Routing is workspace-driven

When this client reports a folder as an MCP root, mcpmux uses the matching Workspace - binding to pick the Space and FeatureSet. + binding to pick the Space and FeatureSet. If it doesn't report the folder + reliably (e.g. Cursor), open the folder in Workspaces and{' '} + Connect apps to this folder{' '} + to auto-write its config with a workspace header.

+ ); +} + export function HomePage() { const [stats, setStats] = useState({ installedServers: 0, @@ -233,6 +275,9 @@ export function HomePage() { pending-approval nudge. */} + {/* Per-folder setup — opens the Workspaces walkthrough. */} + + {/* Stat tiles — each is a shortcut into the page that manages it. */}
(null); + const [flashSecurity, setFlashSecurity] = useState(false); + + useEffect(() => { + if (pendingSection !== 'security' || !securityRef.current) return; + securityRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setFlashSecurity(true); + clearPendingSection(null); + const t = setTimeout(() => setFlashSecurity(false), 2200); + return () => clearTimeout(t); + }, [pendingSection, clearPendingSection]); + // Startup settings state const [startupSettings, setStartupSettings] = useState({ autoLaunch: false, @@ -77,6 +100,11 @@ export function SettingsPage() { const [mappingPromptEnabled, setMappingPromptEnabled] = useState(true); const [savingMappingPrompt, setSavingMappingPrompt] = useState(false); + // System-wide inbound auth toggle. When disabled, local apps connect to the + // gateway with no access key — used by the one-click per-workspace install. + const [authDisabled, setAuthDisabled] = useState(false); + const [savingAuthDisabled, setSavingAuthDisabled] = useState(false); + // Meta-tools master switch — gates the entire `mcpmux_*` namespace. // Gateway port — persisted user override, the default the app ships @@ -245,6 +273,34 @@ export function SettingsPage() { } }; + // Load the system-wide inbound-auth toggle on mount. + useEffect(() => { + invoke('get_gateway_auth_disabled') + .then(setAuthDisabled) + .catch((err) => console.error('Failed to load auth setting:', err)); + }, []); + + const updateAuthDisabled = async (disabled: boolean) => { + const prev = authDisabled; + setAuthDisabled(disabled); + setSavingAuthDisabled(true); + try { + await invoke('set_gateway_auth_disabled', { disabled }); + success( + 'Settings saved', + disabled + ? 'Authentication is off — local apps can connect with no access key.' + : 'Authentication is required again for inbound connections.' + ); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + error('Failed to save setting', msg); + setAuthDisabled(prev); + } finally { + setSavingAuthDisabled(false); + } + }; + // Save startup settings when they change const updateStartupSetting = async (key: keyof StartupSettings, value: boolean) => { console.log(`[Settings] Updating ${key} to ${value}`); @@ -591,6 +647,49 @@ export function SettingsPage() { + {/* Security Section */} +
+ + + + + Security + + + How McpMux authenticates apps connecting to the local gateway. + + + +
+
+ +
+ +

+ Let local apps connect with no access key — just the URL and a workspace header. + Quickest setup, but any app on this machine can then reach the gateway. +

+
+
+ +
+
+
+
+ {/* Appearance Section */} diff --git a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx index c30327e7..9641db22 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx @@ -245,6 +245,15 @@ export function WorkspaceBindingSheet() {
+ + {/* Self-intro: point at the per-workspace installer so apps that + don't report this folder (e.g. Cursor) still route here. */} +

+ Tip: app not routing here? In the Workspaces tab, open this folder and{' '} + Connect apps to this folder{' '} + to write its config with a workspace header — it works even when the app doesn't + report the folder. +

diff --git a/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx b/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx new file mode 100644 index 00000000..287c684d --- /dev/null +++ b/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx @@ -0,0 +1,287 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Check, Copy, Download, Loader2, ShieldCheck, ShieldOff, AlertCircle } from 'lucide-react'; +import { Button } from '@mcpmux/ui'; +import { getGatewayStatus } from '@/lib/api/gateway'; +import { useNavigateTo, useSetPendingSettingsSection } from '@/stores'; +import { + generateWorkspaceConfigSnippet, + getGatewayAuthDisabled, + installWorkspaceMcpConfig, + listWorkspaceInstallClients, + type WorkspaceInstallClient, + type WorkspaceInstallResult, +} from '@/lib/api/workspaceInstall'; + +/** Clients selected by default the first time, before the user picks. */ +const DEFAULT_SELECTED = ['cursor', 'claude-code', 'vscode']; + +/** Where the last client selection is remembered across folders/sessions. */ +const SELECTION_STORAGE_KEY = 'mcpmux:workspace-install-clients'; + +/** Read the remembered client selection, or null when none/invalid. */ +function loadSavedSelection(): Set | null { + try { + const raw = localStorage.getItem(SELECTION_STORAGE_KEY); + if (!raw) return null; + const arr: unknown = JSON.parse(raw); + if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) { + return new Set(arr as string[]); + } + } catch { + /* ignore corrupt / unavailable storage */ + } + return null; +} + +function saveSelection(ids: Set) { + try { + localStorage.setItem(SELECTION_STORAGE_KEY, JSON.stringify(Array.from(ids))); + } catch { + /* ignore */ + } +} + +/** + * "Connect apps to this folder" — writes (or extends) project-local MCP configs + * inside `workspaceRoot`, injecting `X-Mcpmux-Workspace: ` so the + * gateway routes those apps to this folder's binding deterministically, even + * when the client doesn't report MCP roots. Also surfaces (and can flip) the + * system-wide auth toggle inline, since disabling it makes the config a pure + * URL + header with no access key. + */ +export function WorkspaceInstallPanel({ workspaceRoot }: { workspaceRoot: string }) { + const [clients, setClients] = useState([]); + // Restore the user's last selection (remembered across folders); fall back to + // the common-three default the first time. + const [selected, setSelected] = useState>( + () => loadSavedSelection() ?? new Set(DEFAULT_SELECTED) + ); + const [mcpUrl, setMcpUrl] = useState(null); + const [authDisabled, setAuthDisabled] = useState(null); + const [installing, setInstalling] = useState(false); + const [results, setResults] = useState(null); + const [copiedId, setCopiedId] = useState(null); + const [error, setError] = useState(null); + const navigateTo = useNavigateTo(); + const setPendingSettingsSection = useSetPendingSettingsSection(); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const [list, status, disabled] = await Promise.all([ + listWorkspaceInstallClients(), + getGatewayStatus().catch(() => ({ running: false, url: null as string | null })), + getGatewayAuthDisabled().catch(() => false), + ]); + if (cancelled) return; + setClients(list); + // Drop any remembered ids that aren't supported anymore; if that + // leaves nothing, fall back to the defaults that do exist. + setSelected((prev) => { + const known = new Set(list.map((c) => c.id)); + const pruned = [...prev].filter((id) => known.has(id)); + return new Set(pruned.length ? pruned : DEFAULT_SELECTED.filter((id) => known.has(id))); + }); + setAuthDisabled(disabled); + setMcpUrl(status.url ? `${status.url}/mcp` : null); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Remember the selection across folders and sessions. + useEffect(() => { + saveSelection(selected); + }, [selected]); + + const toggleClient = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + setResults(null); + }; + + const handleCopy = useCallback( + async (clientId: string) => { + if (!mcpUrl) return; + try { + const snip = await generateWorkspaceConfigSnippet({ + client: clientId, + serverUrl: mcpUrl, + workspaceRoot, + }); + await navigator.clipboard.writeText(snip.content); + setCopiedId(clientId); + setTimeout(() => setCopiedId((c) => (c === clientId ? null : c)), 1500); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, + [mcpUrl, workspaceRoot] + ); + + const handleInstall = async () => { + if (!mcpUrl || selected.size === 0) return; + setInstalling(true); + setError(null); + setResults(null); + try { + const res = await installWorkspaceMcpConfig({ + workspaceRoot, + serverUrl: mcpUrl, + clients: Array.from(selected), + }); + setResults(res); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setInstalling(false); + } + }; + + return ( +
+

+ Add McpMux to this folder's MCP config for the apps you use. Each gets an{' '} + X-Mcpmux-Workspace header set to this path, so it routes + here automatically — even apps that don't report the folder. +

+ + {/* Self-introductory auth nudge — disabling auth makes the written config + a pure URL + header with no access key to manage. */} + {authDisabled === false && ( +
+ +
+

+ Enable and authenticate this app once to connect — or disable the requirement in + Settings. +

+ +
+
+ )} + {authDisabled === true && ( +
+ + Authentication is off — apps connect with just the URL and workspace header. +
+ )} + + {/* Client checklist with per-row copy. */} +
+ {clients.map((c, i) => { + const checked = selected.has(c.id); + return ( + + ); + })} +
+ + {error && ( +
+ + {error} +
+ )} + + {results && ( +
+ {results.map((r) => ( +
+ {r.action === 'error' ? ( + + ) : ( + + )} + {r.label} + + {r.action === 'error' ? r.error : `${r.action} ${r.path}`} + +
+ ))} +
+ )} + + +
+ ); +} diff --git a/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx b/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx new file mode 100644 index 00000000..21e0231c --- /dev/null +++ b/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx @@ -0,0 +1,339 @@ +import { useEffect, useMemo, useState } from 'react'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import { + ArrowLeft, + ArrowRight, + Check, + FolderOpen, + FolderSearch, + Layers, + Loader2, + Wrench, + X, +} from 'lucide-react'; +import { Button } from '@mcpmux/ui'; +import { + validateWorkspaceRoot, + type WorkspaceBinding, + type WorkspaceBindingInput, +} from '@/lib/api/workspaceBindings'; +import { isStarterFeatureSet, type FeatureSet } from '@/lib/api/featureSets'; +import type { Space } from '@/lib/api/spaces'; +import { WorkspaceInstallPanel } from './WorkspaceInstallPanel'; + +/** + * Guided "set up a folder" walkthrough (the create path; editing an existing + * mapping still uses the inspector). Three steps, by deliberate UX order: + * + * 1. Folder — required; pick via dialog or a detected workspace. + * 2. Connect apps — OPTIONAL; write the per-workspace config (header) so + * apps route here even without reporting roots. + * 3. Tools — defaults to the Space's Starter so Finish is one click; + * creating the binding here is what "maps" the folder. + * + * Abandoning before Finish is safe: the folder simply uses the default Starter + * set until it's mapped, and any installed config still points at it. + */ +export function WorkspaceSetupWizard({ + spaces, + featureSets, + reportedRoots, + existingBindings, + onClose, + onCreate, + onError, +}: { + spaces: Space[]; + featureSets: FeatureSet[]; + reportedRoots: string[]; + existingBindings: WorkspaceBinding[]; + onClose: () => void; + onCreate: (input: WorkspaceBindingInput) => Promise; + onError: (msg: string) => void; +}) { + const [step, setStep] = useState<1 | 2 | 3>(1); + const [folder, setFolder] = useState(''); + const [validating, setValidating] = useState(false); + const [saving, setSaving] = useState(false); + + const defaultSpaceId = useMemo( + () => spaces.find((s) => s.is_default)?.id ?? spaces[0]?.id ?? '', + [spaces] + ); + const [spaceId, setSpaceId] = useState(defaultSpaceId); + useEffect(() => { + if (!spaceId && defaultSpaceId) setSpaceId(defaultSpaceId); + }, [defaultSpaceId, spaceId]); + + const spaceFeatureSets = useMemo( + () => featureSets.filter((f) => f.space_id === spaceId), + [featureSets, spaceId] + ); + const starterId = useMemo( + () => spaceFeatureSets.find((f) => isStarterFeatureSet(f))?.id, + [spaceFeatureSets] + ); + const [fsIds, setFsIds] = useState>(new Set()); + // Default to the Space's Starter whenever the Space changes — keeps Finish a + // single click and guarantees a non-empty selection (bindings require one). + useEffect(() => { + setFsIds(starterId ? new Set([starterId]) : new Set()); + }, [spaceId, starterId]); + + // Detected folders not already mapped — quick-pick targets for step 1. + const boundRoots = useMemo( + () => new Set(existingBindings.map((b) => b.workspace_root.toLowerCase())), + [existingBindings] + ); + const unmappedRoots = useMemo( + () => reportedRoots.filter((r) => !boundRoots.has(r.toLowerCase())), + [reportedRoots, boundRoots] + ); + + const pickFolder = async () => { + try { + const picked = await openDialog({ directory: true, multiple: false, title: 'Pick a folder' }); + if (typeof picked !== 'string') return; + setValidating(true); + const normalized = await validateWorkspaceRoot(picked).catch(() => picked); + setFolder(normalized); + } catch (e) { + onError(e instanceof Error ? e.message : String(e)); + } finally { + setValidating(false); + } + }; + + const toggleFs = (id: string) => + setFsIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + const finish = async () => { + if (!folder || fsIds.size === 0 || !spaceId) return; + setSaving(true); + try { + await onCreate({ + workspace_root: folder, + space_id: spaceId, + feature_set_ids: Array.from(fsIds), + }); + // The parent transitions to the new mapping's inspector (which shows its + // effective features) — don't close here, or that view would be lost. + } catch (e) { + onError(e instanceof Error ? e.message : String(e)); + setSaving(false); + } + }; + + const TITLES = ['Choose a folder', 'Connect your apps', 'Choose its tools'] as const; + + return ( +
+ {/* Header + progress */} +
+
+
+
+ Set up a folder · Step {step} of 3 +
+

{TITLES[step - 1]}

+
+ +
+
+ {[1, 2, 3].map((n) => ( +
+ ))} +
+
+ +
+ {step === 1 && ( +
+

+ Which project folder do you want to map? Pick one, or choose a folder an app already + opened. +

+ + + {folder && ( +
+ + + {folder} + +
+ )} + + {unmappedRoots.length > 0 && ( +
+
+ + Detected workspaces +
+
+ {unmappedRoots.slice(0, 6).map((r, i) => ( + + ))} +
+
+ )} +
+ )} + + {step === 2 && ( +
+ +

+ Optional — you can connect apps later from this folder's mapping. +

+
+ )} + + {step === 3 && ( +
+

+ Pick the tools this folder gets. The default Starter set works out of the box — change + it only if this folder should see something different. +

+ +
+ + +
+ +
+ +
+ {spaceFeatureSets.length === 0 ? ( +
+ This Space has no feature sets yet. +
+ ) : ( + spaceFeatureSets.map((fs, i) => ( + + )) + )} +
+
+
+ )} +
+ + {/* Footer nav */} +
+ + + {step < 3 ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 2bcb57c7..0c677aa6 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -50,7 +50,9 @@ import { listFeatureSets, type FeatureSet, } from '@/lib/api/featureSets'; -import { useSpaces } from '@/stores'; +import { WorkspaceInstallPanel } from './WorkspaceInstallPanel'; +import { WorkspaceSetupWizard } from './WorkspaceSetupWizard'; +import { useSpaces, usePendingWorkspaceNew, useSetPendingWorkspaceNew } from '@/stores'; import type { Space } from '@/lib/api/spaces'; /** @@ -81,6 +83,8 @@ type Selected = { mode: 'new' } | { mode: 'entry'; id: string }; export function WorkspacesPage() { const spaces = useSpaces(); + const pendingNew = usePendingWorkspaceNew(); + const clearPendingNew = useSetPendingWorkspaceNew(); const [bindings, setBindings] = useState([]); const [reportedRoots, setReportedRoots] = useState([]); const [featureSets, setFeatureSets] = useState([]); @@ -115,6 +119,14 @@ export function WorkspacesPage() { void loadData().finally(() => setIsLoading(false)); }, [loadData]); + // Opened from the home "Set up a folder" CTA — launch the create walkthrough. + useEffect(() => { + if (pendingNew) { + setSelected({ mode: 'new' }); + clearPendingNew(false); + } + }, [pendingNew, clearPendingNew]); + // Refresh whenever something the table reflects changes outside the page: // • `session-roots-changed` — a connected client newly reported a root. // • `workspace-binding-changed` — a binding was created/updated/deleted @@ -445,27 +457,45 @@ export function WorkspacesPage() { className="fixed inset-0 bg-black/20 backdrop-blur-[2px] z-40 animate-in fade-in duration-200" onClick={() => setSelected(null)} /> - setSelected(null)} - onSubmit={async (input) => { - if (selectedEntry?.binding) { - await handleUpdate(selectedEntry.binding.id, input); - } else { + {selectedIsNew ? ( + setSelected(null)} + onCreate={async (input) => { const created = await handleCreate(input); + // Land on the new mapping's inspector so its effective features + // are shown right after creation. setSelected({ mode: 'entry', id: created.id }); - } - }} - onDelete={async () => { - if (selectedEntry?.binding) await handleDelete(selectedEntry.binding); - }} - onError={(msg) => showError('Could not save', msg)} - /> + return created; + }} + onError={(msg) => showError('Could not save', msg)} + /> + ) : ( + setSelected(null)} + onSubmit={async (input) => { + if (selectedEntry?.binding) { + await handleUpdate(selectedEntry.binding.id, input); + } else { + const created = await handleCreate(input); + setSelected({ mode: 'entry', id: created.id }); + } + }} + onDelete={async () => { + if (selectedEntry?.binding) await handleDelete(selectedEntry.binding); + }} + onError={(msg) => showError('Could not save', msg)} + /> + )} )} @@ -1017,6 +1047,19 @@ function InspectorPanel({ /> + {entry && !isNew && ( + } + tone="primary" + title="Connect apps to this folder" + subtitle="Write the McpMux config into this folder for the apps you use, with this folder's workspace header." + defaultOpen={!isMapped} + testId="workspace-install-section" + > + + + )} + {entry && !isNew && ( } diff --git a/apps/desktop/src/lib/api/workspaceInstall.ts b/apps/desktop/src/lib/api/workspaceInstall.ts new file mode 100644 index 00000000..20bd7f5b --- /dev/null +++ b/apps/desktop/src/lib/api/workspaceInstall.ts @@ -0,0 +1,74 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** A client the per-workspace installer can write a config for. */ +export interface WorkspaceInstallClient { + id: string; + label: string; + /** Project-local config path, relative to the workspace folder. */ + config_path: string; +} + +/** Result of writing one client's config. */ +export interface WorkspaceInstallResult { + client: string; + label: string; + path: string; + /** "created" | "updated" | "error". */ + action: string; + backed_up: string | null; + error: string | null; +} + +/** A copy-paste config snippet for one client. */ +export interface WorkspaceConfigSnippet { + client: string; + label: string; + config_path: string; + /** Full file content (top-level key + the McpMux entry). */ + content: string; +} + +/** The project-local clients the installer supports (Cursor, VS Code, …). */ +export async function listWorkspaceInstallClients(): Promise { + return invoke('list_workspace_install_clients'); +} + +/** Generate a copy-paste config snippet for one client (writes nothing). */ +export async function generateWorkspaceConfigSnippet(args: { + client: string; + serverUrl: string; + workspaceRoot: string; + bearer?: string | null; +}): Promise { + return invoke('generate_workspace_config_snippet', { + client: args.client, + serverUrl: args.serverUrl, + workspaceRoot: args.workspaceRoot, + bearer: args.bearer ?? null, + }); +} + +/** Create or extend the selected clients' configs inside `workspaceRoot`. */ +export async function installWorkspaceMcpConfig(args: { + workspaceRoot: string; + serverUrl: string; + clients: string[]; + bearer?: string | null; +}): Promise { + return invoke('install_workspace_mcp_config', { + workspaceRoot: args.workspaceRoot, + serverUrl: args.serverUrl, + clients: args.clients, + bearer: args.bearer ?? null, + }); +} + +/** Whether system-wide inbound auth is disabled (no access key required). */ +export async function getGatewayAuthDisabled(): Promise { + return invoke('get_gateway_auth_disabled'); +} + +/** Enable/disable system-wide inbound auth. Takes effect immediately. */ +export async function setGatewayAuthDisabled(disabled: boolean): Promise { + return invoke('set_gateway_auth_disabled', { disabled }); +} diff --git a/apps/desktop/src/stores/appStore.ts b/apps/desktop/src/stores/appStore.ts index c1453408..13e4e156 100644 --- a/apps/desktop/src/stores/appStore.ts +++ b/apps/desktop/src/stores/appStore.ts @@ -8,6 +8,8 @@ const initialState: AppState = { viewSpaceId: null, activeNav: 'home', pendingClientId: null, + pendingSettingsSection: null, + pendingWorkspaceNew: false, sidebarCollapsed: false, theme: 'system', analyticsEnabled: true, @@ -78,6 +80,16 @@ export const useAppStore = create()( state.pendingClientId = id; }), + setPendingSettingsSection: (section) => + set((state) => { + state.pendingSettingsSection = section; + }), + + setPendingWorkspaceNew: (v) => + set((state) => { + state.pendingWorkspaceNew = v; + }), + // UI toggleSidebar: () => set((state) => { diff --git a/apps/desktop/src/stores/selectors.ts b/apps/desktop/src/stores/selectors.ts index 99282afb..5c800120 100644 --- a/apps/desktop/src/stores/selectors.ts +++ b/apps/desktop/src/stores/selectors.ts @@ -8,6 +8,13 @@ export const useActiveNav = () => useAppStore((state) => state.activeNav); export const useNavigateTo = () => useAppStore((state) => state.navigateTo); export const usePendingClientId = () => useAppStore((state) => state.pendingClientId); export const useSetPendingClientId = () => useAppStore((state) => state.setPendingClientId); +export const usePendingSettingsSection = () => + useAppStore((state) => state.pendingSettingsSection); +export const useSetPendingSettingsSection = () => + useAppStore((state) => state.setPendingSettingsSection); +export const usePendingWorkspaceNew = () => useAppStore((state) => state.pendingWorkspaceNew); +export const useSetPendingWorkspaceNew = () => + useAppStore((state) => state.setPendingWorkspaceNew); export const useTheme = () => useAppStore((state) => state.theme); export const useSidebarCollapsed = () => useAppStore((state) => state.sidebarCollapsed); export const useAnalyticsEnabled = () => useAppStore((state) => state.analyticsEnabled); diff --git a/apps/desktop/src/stores/types.ts b/apps/desktop/src/stores/types.ts index 491765cc..c9626eba 100644 --- a/apps/desktop/src/stores/types.ts +++ b/apps/desktop/src/stores/types.ts @@ -26,6 +26,10 @@ export interface AppState { activeNav: NavItem; /** Client ID to auto-select when navigating to Clients page */ pendingClientId: string | null; + /** Section to scroll to + flash when navigating to Settings (e.g. 'security'). */ + pendingSettingsSection: string | null; + /** When true, the Workspaces page opens the New-mapping walkthrough on arrival. */ + pendingWorkspaceNew: boolean; // UI state sidebarCollapsed: boolean; @@ -50,6 +54,8 @@ export interface AppActions { // Navigation navigateTo: (nav: NavItem) => void; setPendingClientId: (id: string | null) => void; + setPendingSettingsSection: (section: string | null) => void; + setPendingWorkspaceNew: (v: boolean) => void; // UI toggleSidebar: () => void; diff --git a/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs index a5630b15..5fe9c93c 100644 --- a/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs +++ b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs @@ -18,6 +18,12 @@ use crate::auth::validate_token; use crate::logging::TraceContext; use crate::server::ServiceContainer; +/// Synthetic client identity used when system-wide inbound auth is disabled and +/// a connection arrives without a (valid) Bearer token. Routing still prefers +/// the `X-Mcpmux-Workspace` header → binding; this id only feeds the rootless +/// `client_grants` fallback (which finds none) → Space default. +const ANONYMOUS_CLIENT_ID: &str = "mcpmux-anonymous"; + /// OAuth middleware for MCP endpoints using rmcp /// /// Extracts Bearer token → Verifies JWT → Resolves space → Injects OAuthContext @@ -38,81 +44,131 @@ pub async fn mcp_oauth_middleware( .map(|ctx| ctx.trace_id.clone()) .unwrap_or_else(|| "??????".to_string()); - // Extract Authorization header + // System-wide inbound auth can be disabled (localhost-only convenience): + // when off, a connection is accepted without a Bearer token and routed by + // the workspace header / default space. A valid token is still honored when + // present, so flipping the setting never breaks an already-configured + // client. Default is auth-required. + let require_auth = !services.gateway_state.read().await.auth_disabled(); + let auth_header = request .headers() .get("authorization") - .and_then(|v| v.to_str().ok()); - - let Some(auth_value) = auth_header else { - warn!(trace_id = %trace_id, "Missing Authorization header"); - return unauthorized_response("Missing Authorization header"); - }; - - // Extract Bearer token - let token = match auth_value.strip_prefix("Bearer ") { - Some(t) => t, - None => { - warn!(trace_id = %trace_id, "Authorization header must use Bearer scheme"); - return unauthorized_response("Authorization header must use Bearer scheme"); + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let token = auth_header + .as_deref() + .and_then(|v| v.strip_prefix("Bearer ")); + + // Verify the Bearer token whenever one is present. + let claims = match token { + Some(token) => { + let jwt_secret = { + let state = services.gateway_state.read().await; + state.get_jwt_secret().map(|s| s.to_vec()) + }; + match jwt_secret { + Some(secret) => validate_token(token, &secret), + None => { + warn!(trace_id = %trace_id, "JWT secret not configured"); + None + } + } } + None => None, }; - // Verify JWT and extract claims - let jwt_secret = { - let state = services.gateway_state.read().await; - match state.get_jwt_secret() { - Some(secret) => secret.to_vec(), - None => { - warn!(trace_id = %trace_id, "JWT secret not configured"); + // Resolve (client_id, space_id) from the token, or — when auth is disabled + // — fall back to an anonymous identity on the default space. + let (client_id, space_id) = if let Some(claims) = claims { + match services + .space_resolver_service + .resolve_space_for_client(&claims.client_id) + .await + { + Ok(id) => (claims.client_id, id), + Err(e) => { + warn!( + trace_id = %trace_id, + client_id = %claims.client_id, + "Failed to resolve space: {}", e + ); return ( StatusCode::INTERNAL_SERVER_ERROR, - "Server not configured for authentication", + format!("Failed to resolve space: {}", e), ) .into_response(); } } - }; - - let claims = match validate_token(token, &jwt_secret) { - Some(claims) => claims, - None => { - warn!(trace_id = %trace_id, "Token verification failed"); - return unauthorized_response("Invalid token"); - } - }; - - // Resolve space for this client - let space_id = match services - .space_resolver_service - .resolve_space_for_client(&claims.client_id) - .await - { - Ok(id) => id, - Err(e) => { - warn!( - trace_id = %trace_id, - client_id = %claims.client_id, - "Failed to resolve space: {}", e - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to resolve space: {}", e), - ) - .into_response(); + } else if require_auth { + // No valid token and auth is required → 401 with the specific reason. + let msg = match auth_header.as_deref() { + None => "Missing Authorization header", + Some(v) if !v.starts_with("Bearer ") => "Authorization header must use Bearer scheme", + _ => "Invalid token", + }; + warn!(trace_id = %trace_id, "{}", msg); + return unauthorized_response(msg); + } else { + // Auth disabled → accept anonymously on the default space. Routing + // still prefers the workspace header (pinned below) → binding. + match services.dependencies.space_repo.get_default().await { + Ok(Some(space)) => (ANONYMOUS_CLIENT_ID.to_string(), space.id), + Ok(None) => { + warn!(trace_id = %trace_id, "Auth disabled but no default space configured"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "No default space configured", + ) + .into_response(); + } + Err(e) => { + warn!(trace_id = %trace_id, "Failed to resolve default space: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to resolve default space: {}", e), + ) + .into_response(); + } } }; // Inject OAuth context via custom headers (rmcp will preserve these) request.headers_mut().insert( "x-mcpmux-client-id", - claims.client_id.parse().expect("valid header value"), + client_id.parse().expect("valid header value"), ); request.headers_mut().insert( "x-mcpmux-space-id", space_id.to_string().parse().expect("valid header value"), ); + // Pin an explicit workspace root advertised by the client via the + // `X-Mcpmux-Workspace` header (injected by McpMux's per-workspace client + // configs). It shadows the client's MCP-reported roots in the resolver, so + // a connection routes to its workspace binding even when the client never + // reports `roots` or reports a stale one (e.g. Cursor sharing one MCP host + // across windows). Unlike client/space id above, this header is + // client-asserted — the same trust model as MCP roots: any approved local + // client can claim any binding (see FeatureSetResolver trust model). Keyed + // by the `mcp-session-id` the client echoes on every post-initialize + // request (the same key the handler stores reported roots under). + let pin = { + let headers = request.headers(); + let sid = headers + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let ws = headers + .get("x-mcpmux-workspace") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + sid.zip(ws) + }; + if let Some((sid, ws)) = pin { + services.session_roots.set_pinned(&sid, &ws); + } + // Extract MCP method from body if POST let mcp_method = if request.method() == axum::http::Method::POST { use axum::body::to_bytes; @@ -126,7 +182,7 @@ pub async fn mcp_oauth_middleware( // Log single consolidated entry line info!( trace_id = %trace_id, - client = %&claims.client_id[..claims.client_id.len().min(12)], + client = %&client_id[..client_id.len().min(12)], space = %&space_id.to_string()[..8], method = method.as_deref().unwrap_or("-"), "→ MCP" @@ -159,7 +215,7 @@ pub async fn mcp_oauth_middleware( warn!( trace_id = %trace_id, status = %status, - client = %claims.client_id, + client = %client_id, method = mcp_method.as_deref().unwrap_or("-"), "← MCP error" ); diff --git a/crates/mcpmux-gateway/src/server/state.rs b/crates/mcpmux-gateway/src/server/state.rs index 420918d2..d16190bb 100644 --- a/crates/mcpmux-gateway/src/server/state.rs +++ b/crates/mcpmux-gateway/src/server/state.rs @@ -61,6 +61,11 @@ pub struct GatewayState { client_metadata_service: Option>, /// Unified event broadcaster (UI subscribes to receive all domain events) domain_event_tx: broadcast::Sender, + /// When true, inbound MCP connections are accepted WITHOUT a Bearer token + /// (localhost-only convenience). Default false (auth required). Seeded from + /// the `gateway.auth_disabled` app setting at startup and flipped live by + /// the desktop toggle. A valid token is still honored when present. + auth_disabled: bool, } impl GatewayState { @@ -77,6 +82,7 @@ impl GatewayState { inbound_client_repository: None, client_metadata_service: None, domain_event_tx, + auth_disabled: false, } } @@ -86,6 +92,24 @@ impl GatewayState { self.base_url = base_url; } + /// Whether inbound MCP auth is disabled — connections may be accepted + /// without a Bearer token. See [`Self::auth_disabled`] field docs. + pub fn auth_disabled(&self) -> bool { + self.auth_disabled + } + + /// Enable/disable system-wide inbound auth. Called at startup (seed from + /// settings) and live from the desktop toggle. + pub fn set_auth_disabled(&mut self, disabled: bool) { + if self.auth_disabled != disabled { + info!( + "[State] Inbound auth {}", + if disabled { "DISABLED" } else { "enabled" } + ); + } + self.auth_disabled = disabled; + } + /// Subscribe to domain events (new unified channel) pub fn subscribe_domain_events(&self) -> broadcast::Receiver { self.domain_event_tx.subscribe() @@ -265,3 +289,19 @@ impl Default for GatewayState { Self::new(domain_event_tx) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_disabled_defaults_off_and_toggles() { + let mut state = GatewayState::default(); + // Secure default: auth is required (not disabled). + assert!(!state.auth_disabled()); + state.set_auth_disabled(true); + assert!(state.auth_disabled()); + state.set_auth_disabled(false); + assert!(!state.auth_disabled()); + } +} diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs index 4beb799c..7bad3a75 100644 --- a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -76,6 +76,19 @@ //! //! Roots-capable detection is stamped at `on_initialized` time into //! [`SessionRootsRegistry::set_roots_capable`]. +//! +//! # Explicit workspace root via the `X-Mcpmux-Workspace` header +//! +//! A connection can carry an explicit workspace root in the +//! `X-Mcpmux-Workspace` HTTP header, injected by McpMux's per-workspace client +//! configs. The OAuth middleware pins it into +//! [`SessionRootsRegistry::set_pinned`], where it **shadows** the client's +//! probed MCP roots in [`SessionRootsRegistry::get`]. Because this resolver +//! reads roots exclusively through `get`, a pinned root flows through Tier 1 +//! unchanged — an exact binding match, else the Space default — with no extra +//! tier or parameter. This is the deterministic path for clients that don't +//! report `roots` reliably (e.g. Cursor multiplexing one MCP host across +//! windows): the header always wins over a stale or absent reported root. use std::sync::Arc; use std::time::Duration; diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index 9cd8694f..e5a29a50 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -14,6 +14,7 @@ use std::time::{Duration, Instant}; use dashmap::DashMap; use mcpmux_core::normalize_workspace_root; +use tracing::debug; /// Thread-safe registry mapping `mcp-session-id` to the caller's reported /// workspace roots, plus the most recently resolved feature-set id so the @@ -66,6 +67,18 @@ pub struct SessionRootsRegistry { /// roots-capable client from flashing the default FeatureSet and then /// flipping to its mapped one the instant its root lands. first_seen: DashMap, + /// `session_id -> explicit workspace root pinned via the + /// `X-Mcpmux-Workspace` HTTP header`. + /// + /// McpMux's per-workspace client configs inject that header with the + /// folder's path, so a connection routes to its workspace binding even + /// when the client never reports MCP `roots` or reports a stale one (e.g. + /// Cursor sharing a single MCP host across windows, with + /// `roots.listChanged = false`). A pinned root is **authoritative**: it + /// shadows the probed [`Self::map`] roots in [`Self::get`], so the + /// resolver, the on-demand probe skip, and the prompt-root derivation all + /// honor the header with no special-casing. Already normalized on insert. + pinned: DashMap, } impl SessionRootsRegistry { @@ -77,6 +90,7 @@ impl SessionRootsRegistry { last_probe: DashMap::new(), probe_lock: DashMap::new(), first_seen: DashMap::new(), + pinned: DashMap::new(), }) } @@ -155,10 +169,53 @@ impl SessionRootsRegistry { } /// Retrieve the (already-normalized) roots for a session, if any. + /// + /// An explicit root pinned via the `X-Mcpmux-Workspace` header + /// ([`Self::set_pinned`]) takes precedence over — and entirely shadows — + /// the client's probed MCP roots. That single seam is what makes the + /// header authoritative everywhere `get` is consulted (resolver Tier 1, + /// the probe early-return, prompt-root derivation) without threading the + /// header through any of those call paths. pub fn get(&self, session_id: &str) -> Option> { + if let Some(pinned) = self.pinned.get(session_id) { + return Some(vec![pinned.clone()]); + } self.map.get(session_id).map(|v| v.clone()) } + /// Pin an explicit workspace root for a session, sourced from the + /// `X-Mcpmux-Workspace` HTTP header. `raw_root` is a filesystem path or + /// `file://` URI; it's normalized like every other root before storage. + /// A value that normalizes to empty is ignored (no pin), so a malformed + /// header falls back to the client's reported roots rather than denying. + /// Cheap to call on the request hot path: redundant writes (same + /// normalized value already pinned) are skipped to avoid shard churn. + pub fn set_pinned(&self, session_id: &str, raw_root: &str) { + let normalized = normalize_workspace_root(raw_root); + if normalized.is_empty() { + return; + } + if self + .pinned + .get(session_id) + .is_some_and(|v| *v == normalized) + { + return; + } + debug!( + %session_id, + workspace_root = %normalized, + "[SessionRoots] pinned explicit workspace root from X-Mcpmux-Workspace header", + ); + self.pinned.insert(session_id.to_string(), normalized); + } + + /// The explicit workspace root pinned for a session via the header, if any + /// (already normalized). + pub fn get_pinned(&self, session_id: &str) -> Option { + self.pinned.get(session_id).map(|v| v.clone()) + } + /// Drop a session's roots — call on client disconnect. pub fn remove(&self, session_id: &str) { self.map.remove(session_id); @@ -167,6 +224,7 @@ impl SessionRootsRegistry { self.last_probe.remove(session_id); self.probe_lock.remove(session_id); self.first_seen.remove(session_id); + self.pinned.remove(session_id); } /// Compare-and-set the session's resolved feature-set id. Returns `true` @@ -311,6 +369,56 @@ mod tests { assert_eq!(reg.len(), 0); } + #[test] + fn test_pinned_root_shadows_reported_roots() { + let reg = SessionRootsRegistry::default(); + #[cfg(windows)] + let (reported, pin_in, pin_norm) = ( + "file:///D:/reported/", + "D:\\Pinned\\Path", + "d:\\pinned\\path", + ); + #[cfg(not(windows))] + let (reported, pin_in, pin_norm) = ( + "file:///home/u/reported/", + "/home/u/Pinned", + "/home/u/Pinned", + ); + + reg.set("sess-1", [reported]); + reg.set_pinned("sess-1", pin_in); + + // The pinned (header) root entirely shadows the probed root. + assert_eq!(reg.get("sess-1"), Some(vec![pin_norm.to_string()])); + assert_eq!(reg.get_pinned("sess-1"), Some(pin_norm.to_string())); + } + + #[test] + fn test_set_pinned_ignores_empty_and_normalizes() { + let reg = SessionRootsRegistry::default(); + // Whitespace/garbage that normalizes to empty leaves no pin, so a + // malformed header falls back to reported roots rather than denying. + reg.set_pinned("sess-1", " "); + assert!(reg.get_pinned("sess-1").is_none()); + + #[cfg(windows)] + let (pin_in, pin_norm) = ("file:///D:/Foo/", "d:\\foo"); + #[cfg(not(windows))] + let (pin_in, pin_norm) = ("file:///home/u/Foo/", "/home/u/Foo"); + reg.set_pinned("sess-1", pin_in); + assert_eq!(reg.get_pinned("sess-1"), Some(pin_norm.to_string())); + } + + #[test] + fn test_remove_clears_pinned() { + let reg = SessionRootsRegistry::default(); + reg.set_pinned("sess-1", "/p"); + assert!(reg.get_pinned("sess-1").is_some()); + reg.remove("sess-1"); + assert!(reg.get_pinned("sess-1").is_none()); + assert!(reg.get("sess-1").is_none()); + } + #[test] fn test_record_resolution_flips_on_change() { let reg = SessionRootsRegistry::default(); diff --git a/docs/manual/workspace-header-routing.md b/docs/manual/workspace-header-routing.md new file mode 100644 index 00000000..7993e23e --- /dev/null +++ b/docs/manual/workspace-header-routing.md @@ -0,0 +1,111 @@ +# Manual test — per-workspace routing via `X-Mcpmux-Workspace` + +Covers the feature added on `feat/workspace-header-mapping`: + +1. Deterministic per-workspace routing via the `X-Mcpmux-Workspace` header + (fixes Cursor reporting the wrong/another workspace root). +2. One-click per-workspace client config install. +3. System-wide "disable authentication" toggle. + +Automated coverage exists for the resolver, the config writer, the gateway +state toggle, and the install panel (see _Automated tests_ at the end). The +steps below verify the end-to-end behavior that automation can't — a real +client connecting through the gateway. + +## Prerequisites + +- `pnpm dev` (desktop app + gateway) running. +- Two real workspace folders, e.g. `D:\proj\alpha` and `D:\proj\beta`. +- Cursor installed (the client this feature primarily targets). VS Code / + Claude Code are good controls — they already route correctly via roots. + +--- + +## A. Header routing fixes the wrong-workspace bug + +**Goal:** prove the header overrides what the client reports. + +1. In the app, **Workspaces → New mapping**: map `D:\proj\alpha` to a Space + + a distinctive FeatureSet (call it _Alpha FS_, with a tool only it has). + Map `D:\proj\beta` to a different _Beta FS_. +2. In the `D:\proj\alpha` mapping, open **Connect apps to this folder**, tick + **Cursor**, and click **Install into 1 app**. Confirm + `D:\proj\alpha\.cursor\mcp.json` now contains an `mcpmux` entry with + `"headers": { "X-Mcpmux-Workspace": "D:\\proj\\alpha" }`. +3. Repeat for `D:\proj\beta`. +4. Open **both** folders in Cursor (two windows). In each, ask the agent to + list mcpmux tools (or invoke `@mux`). + +**Expected:** the `alpha` window sees _Alpha FS_ tools; the `beta` window sees +_Beta FS_ tools. Before this change, both windows showed whichever folder +Cursor happened to report — the bug. + +**Verify in logs** (`%LOCALAPPDATA%\com.mcpmux.desktop\logs\mcpmux..log`): + +- `[SessionRoots] pinned explicit workspace root from X-Mcpmux-Workspace header` + with the right path per session. +- `[FeatureSetResolver] resolved via WorkspaceBinding workspace_root=d:\proj\alpha` + (and `…\beta`) — note the header path wins even if Cursor also reports a + different root. + +--- + +## B. One-click install — create and extend + +1. **New folder, no config:** pick a fresh folder with no `.cursor/` etc. + Install for Cursor + Claude Code + VS Code. Confirm three files are + **created**: `.cursor/mcp.json`, `.mcp.json`, `.vscode/mcp.json`, each with + the correct top-level key (`mcpServers` / `mcpServers` / `servers`) and the + workspace header. +2. **Existing config, preserved:** in a folder that already has a + `.cursor/mcp.json` with another server, install again. Confirm: + - the other server is still present, + - an `mcpmux` entry was added/updated, + - a `mcp.json.mcpmux-bak` backup was written. +3. **Non-JSON guard:** put a `//` comment in `.cursor/mcp.json`, install, and + confirm that client reports an **error** ("not plain JSON…") and the file is + left untouched (no clobber). +4. **Copy config:** click the copy icon on a client row, paste — you get a full + `{ "": { "mcpmux": { … } } }` snippet for that client. + +--- + +## C. Disable authentication + +1. **Settings → Security → Disable authentication: ON.** Toast confirms. +2. Connect a client whose config has **no** `Authorization` header (the + installer writes none) — e.g. the Cursor config from step A. + +**Expected:** the client connects and resolves normally (no 401). Logs show +`→ MCP` lines with `client=mcpmux-anon…` for tokenless requests. + +3. **Toggle OFF again.** A tokenless client now gets `401`; a client with a + valid access key still connects (lenient — a valid token is always honored). +4. Restart the app with the toggle ON and confirm it persists (seeded into the + gateway at startup). + +The install panel's inline **Disable authentication** button (shown when auth +is on) performs the same toggle without leaving the flow. + +--- + +## D. Self-introductory hints (discoverability) + +- **Approval sheet** (open an unmapped folder in a connected app): shows the + "Connect apps to this folder" tip. +- **Apps page → a client → "Routing is workspace-driven":** mentions installing + a per-workspace config when a client doesn't report folders reliably. +- **Install panel:** shows the auth state and offers to disable it inline. + +--- + +## Automated tests (run before manual) + +```bash +pnpm test:rust:int # resolver: pinned-header routing, override, fallback +pnpm test:rust:unit # session_roots pin/shadow/clear; GatewayState auth toggle; + # workspace_install merge/create/extend/backup +pnpm test:ts -- WorkspaceInstallPanel +``` + +All should pass. diff --git a/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs index d515f124..c5328b49 100644 --- a/tests/rust/tests/integration/feature_set_resolver.rs +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -655,3 +655,128 @@ async fn two_sessions_on_same_root_resolve_to_the_same_binding() { assert_eq!(r2.feature_set_ids, vec![f.fs_a_id.clone()]); assert_eq!(r1.space_id, r2.space_id); } + +// --------------------------------------------------------------------------- +// Explicit workspace root via the X-Mcpmux-Workspace header (pinned root) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn pinned_header_root_routes_to_binding_without_any_reported_roots() { + // The deterministic fix for clients that don't report MCP roots reliably + // (e.g. Cursor multiplexing one MCP host across windows): a session flagged + // explicitly rootless, with no reported roots, still routes to its + // workspace binding purely from the X-Mcpmux-Workspace header the gateway + // pinned. + let f = Fixture::new().await; + f.binding_repo + .create(&WorkspaceBinding::new( + normalize_workspace_root(test_root()), + f.space_id, + f.fs_a_id.clone(), + )) + .await + .unwrap(); + + f.session_roots.set_roots_capable("s", false); // client says it has no roots + f.session_roots.set_pinned("s", test_root()); // ...but the header pins one + + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id]); +} + +#[tokio::test] +async fn pinned_header_root_overrides_a_conflicting_reported_root() { + // The header is authoritative. When the client reports a stale/wrong root + // AND a header root is pinned, the pinned one wins — exactly the Cursor + // "reported the wrong window's root" failure, now corrected. + let f = Fixture::new().await; + let (reported, pinned) = if cfg!(windows) { + ("d:\\work\\reported", "d:\\work\\pinned") + } else { + ("/work/reported", "/work/pinned") + }; + f.binding_repo + .create(&WorkspaceBinding::new( + normalize_workspace_root(reported), + f.space_id, + f.fs_a_id.clone(), + )) + .await + .unwrap(); + f.binding_repo + .create(&WorkspaceBinding::new( + normalize_workspace_root(pinned), + f.space_id, + f.fs_b_id.clone(), + )) + .await + .unwrap(); + + f.session_roots.set("s", [reported]); + f.session_roots.set_roots_capable("s", true); + f.session_roots.set_pinned("s", pinned); + + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + // Resolved to the PINNED root's FS (B), not the reported root's FS (A). + assert_eq!(r.feature_set_ids, vec![f.fs_b_id]); + assert_ne!(r.feature_set_ids, vec![f.fs_a_id]); +} + +#[tokio::test] +async fn header_takes_priority_but_reported_roots_still_map_without_one() { + // The two mechanisms coexist by design: a session that only reports MCP + // roots (no header) keeps mapping via those roots; pinning a header root + // then overrides them. This guards against the pin ever becoming + // unconditional and breaking roots-reporting clients (VS Code, Claude Code). + let f = Fixture::new().await; + let (root_a, root_b) = if cfg!(windows) { + ("d:\\work\\a", "d:\\work\\b") + } else { + ("/work/a", "/work/b") + }; + f.binding_repo + .create(&WorkspaceBinding::new( + normalize_workspace_root(root_a), + f.space_id, + f.fs_a_id.clone(), + )) + .await + .unwrap(); + f.binding_repo + .create(&WorkspaceBinding::new( + normalize_workspace_root(root_b), + f.space_id, + f.fs_b_id.clone(), + )) + .await + .unwrap(); + + // No header pinned → the reported root drives resolution (FS A). + f.session_roots.set("s", [root_a]); + f.session_roots.set_roots_capable("s", true); + let reported = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!(reported.source, ResolutionSource::WorkspaceBinding); + assert_eq!(reported.feature_set_ids, vec![f.fs_a_id.clone()]); + + // Pin a header root for a different folder → it takes priority (FS B). + f.session_roots.set_pinned("s", root_b); + let pinned = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!(pinned.source, ResolutionSource::WorkspaceBinding); + assert_eq!(pinned.feature_set_ids, vec![f.fs_b_id.clone()]); +} + +#[tokio::test] +async fn pinned_header_root_without_binding_falls_back_to_space_default() { + // A header root for an as-yet-unmapped folder still works out of the box on + // the Space default (upstream emits WorkspaceNeedsBinding so the user can + // attach an explicit mapping). + let f = Fixture::new().await; + f.session_roots.set_pinned("s", test_root()); + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); + assert_eq!(r.space_id, Some(f.space_id)); +} diff --git a/tests/rust/tests/streamable_http/auth_disable.rs b/tests/rust/tests/streamable_http/auth_disable.rs new file mode 100644 index 00000000..711513e8 --- /dev/null +++ b/tests/rust/tests/streamable_http/auth_disable.rs @@ -0,0 +1,180 @@ +//! End-to-end proof that the gateway is *truly* authless when the +//! `gateway.auth_disabled` toggle is on. +//! +//! Unlike `gateway_notifications.rs` (which bypasses auth with a test +//! middleware), this drives the **real** `mcp_oauth_middleware` over HTTP and +//! sends requests with **no** `Authorization` header: +//! - auth disabled → the request is accepted and an anonymous client identity +//! is injected (200, not 401), +//! - auth required (default) → the same tokenless request is rejected (401). + +use axum::{ + body::Body, + http::{Request, StatusCode}, + middleware, + response::{IntoResponse, Response}, + routing::post, + Router, +}; +use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerLogManager}; +use mcpmux_gateway::{ + mcp::mcp_oauth_middleware, + server::{DependenciesBuilder, GatewayDependencies, GatewayState, ServiceContainer}, +}; +use mcpmux_storage::SqliteSpaceRepository; +use std::sync::Arc; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use tests::db::TestDatabase; +use tests::mocks::*; + +/// Minimal `/mcp` handler that echoes the gateway-injected client id so the +/// test can confirm the middleware ran and assigned an identity. +async fn echo_client_id(req: Request) -> Response { + let cid = req + .headers() + .get("x-mcpmux-client-id") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + (StatusCode::OK, cid).into_response() +} + +struct Harness { + url: String, + ct: CancellationToken, +} + +impl Harness { + /// Boot a gateway exposing `/mcp` behind the REAL oauth middleware, with the + /// inbound-auth toggle set to `auth_disabled`. + async fn start(auth_disabled: bool) -> Self { + let ct = CancellationToken::new(); + let space_id = Uuid::new_v4(); + + let test_db = TestDatabase::in_memory(); + let database = Arc::new(tokio::sync::Mutex::new(test_db.db)); + + let space_repo = Arc::new(SqliteSpaceRepository::new(database.clone())); + let space = mcpmux_core::domain::Space { + id: space_id, + name: "Test Space".to_string(), + icon: None, + description: None, + is_default: true, + sort_order: 0, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + mcpmux_core::SpaceRepository::create(&*space_repo, &space) + .await + .expect("create space"); + mcpmux_core::SpaceRepository::set_default(&*space_repo, &space_id) + .await + .expect("set default"); + + let deps = DependenciesBuilder::new() + .with_installed_server_repo(Arc::new(MockInstalledServerRepository::new())) + .with_credential_repo(Arc::new(MockCredentialRepository::new())) + .with_backend_oauth_repo(Arc::new(MockOutboundOAuthRepository::new())) + .with_feature_repo(Arc::new(MockServerFeatureRepository::new()) + as Arc) + .with_feature_set_repo(Arc::new(MockFeatureSetRepository::new()) + as Arc) + .with_server_discovery(Arc::new(ServerDiscoveryService::new( + std::path::PathBuf::from("test-data"), + std::path::PathBuf::from("test-spaces"), + ))) + .with_log_manager(Arc::new(ServerLogManager::new( + mcpmux_core::LogConfig::default(), + ))) + .with_database(database) + .build() + .expect("build dependencies"); + let deps = GatewayDependencies { + space_repo: space_repo as Arc, + ..deps + }; + + let (event_tx, _) = broadcast::channel::(64); + let mut gw_state = GatewayState::new(event_tx.clone()); + gw_state.set_base_url("http://127.0.0.1:0".to_string()); + // No JWT secret needed: these tests send no token, so the auth-required + // path 401s before the secret is ever consulted. + gw_state.set_auth_disabled(auth_disabled); + let gateway_state = Arc::new(tokio::sync::RwLock::new(gw_state)); + + let services = Arc::new(ServiceContainer::initialize( + &deps, + event_tx.clone(), + gateway_state, + )); + + let router = Router::new().route("/mcp", post(echo_client_id)).layer( + middleware::from_fn_with_state(services.clone(), mcp_oauth_middleware), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().unwrap().port(); + let ct_clone = ct.clone(); + tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { ct_clone.cancelled().await }) + .await + .unwrap(); + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + Self { + url: format!("http://127.0.0.1:{port}/mcp"), + ct, + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.ct.cancel(); + } +} + +#[tokio::test] +async fn authless_gateway_accepts_request_without_token() { + let h = Harness::start(true).await; + let resp = reqwest::Client::new() + .post(&h.url) + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#) + .send() + .await + .expect("request"); + assert_eq!( + resp.status(), + reqwest::StatusCode::OK, + "auth-disabled gateway must accept a tokenless request" + ); + // The middleware injected an anonymous identity rather than rejecting. + let body = resp.text().await.unwrap(); + assert_eq!(body, "mcpmux-anonymous"); +} + +#[tokio::test] +async fn auth_required_gateway_rejects_request_without_token() { + let h = Harness::start(false).await; + let resp = reqwest::Client::new() + .post(&h.url) + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#) + .send() + .await + .expect("request"); + assert_eq!( + resp.status(), + reqwest::StatusCode::UNAUTHORIZED, + "default gateway must reject a tokenless request" + ); +} diff --git a/tests/rust/tests/streamable_http/mod.rs b/tests/rust/tests/streamable_http/mod.rs index 1f1ac4f6..ced1d6a6 100644 --- a/tests/rust/tests/streamable_http/mod.rs +++ b/tests/rust/tests/streamable_http/mod.rs @@ -5,5 +5,6 @@ //! - Server-initiated notifications (list_changed via SSE) //! - Proper protocol negotiation +mod auth_disable; mod gateway_notifications; mod notifications; diff --git a/tests/ts/components/HomePageStats.test.tsx b/tests/ts/components/HomePageStats.test.tsx index b28b5568..3d0ad618 100644 --- a/tests/ts/components/HomePageStats.test.tsx +++ b/tests/ts/components/HomePageStats.test.tsx @@ -36,6 +36,7 @@ vi.mock('@/lib/api/registry', () => ({ listInstalledServers: mockListInstalled } vi.mock('@/stores', () => ({ useViewSpace: () => ({ id: 'space-1', name: 'My Space' }), useNavigateTo: () => () => {}, + useSetPendingWorkspaceNew: () => () => {}, })); vi.mock('@/components/ConnectionCard', () => ({ ConnectionCard: () => null })); diff --git a/tests/ts/components/WorkspaceInstallPanel.test.tsx b/tests/ts/components/WorkspaceInstallPanel.test.tsx new file mode 100644 index 00000000..95e7e9e3 --- /dev/null +++ b/tests/ts/components/WorkspaceInstallPanel.test.tsx @@ -0,0 +1,178 @@ +/** + * Workspaces — "Connect apps to this folder" install panel. + * + * The panel must: list the supported clients, write the selected clients' + * configs via `install_workspace_mcp_config` with this folder's path as the + * `X-Mcpmux-Workspace` header (carried server-side), copy a per-client snippet, + * and surface (and be able to flip) the system-wide auth toggle inline. + * + * `@mcpmux/ui` is aliased to real source in vitest.config, so the real Button + * renders. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const { + listClientsMock, + installMock, + snippetMock, + getAuthMock, + gatewayStatusMock, + navigateMock, + setSectionMock, +} = vi.hoisted(() => ({ + listClientsMock: vi.fn(), + installMock: vi.fn(), + snippetMock: vi.fn(), + getAuthMock: vi.fn(), + gatewayStatusMock: vi.fn(), + navigateMock: vi.fn(), + setSectionMock: vi.fn(), +})); + +vi.mock('@/lib/api/workspaceInstall', () => ({ + listWorkspaceInstallClients: listClientsMock, + installWorkspaceMcpConfig: installMock, + generateWorkspaceConfigSnippet: snippetMock, + getGatewayAuthDisabled: getAuthMock, +})); + +vi.mock('@/lib/api/gateway', () => ({ + getGatewayStatus: gatewayStatusMock, +})); + +vi.mock('@/stores', () => ({ + useNavigateTo: () => navigateMock, + useSetPendingSettingsSection: () => setSectionMock, +})); + +import { WorkspaceInstallPanel } from '@/features/workspaces/WorkspaceInstallPanel'; + +const CLIENTS = [ + { id: 'cursor', label: 'Cursor', config_path: '.cursor/mcp.json' }, + { id: 'claude-code', label: 'Claude Code', config_path: '.mcp.json' }, + { id: 'vscode', label: 'VS Code / Copilot', config_path: '.vscode/mcp.json' }, + { id: 'opencode', label: 'opencode', config_path: 'opencode.json' }, + { id: 'zed', label: 'Zed', config_path: '.zed/settings.json' }, +]; + +const ROOT = process.platform === 'win32' ? 'd:\\proj\\app' : '/proj/app'; + +describe('WorkspaceInstallPanel', () => { + beforeEach(() => { + localStorage.clear(); + listClientsMock.mockReset().mockResolvedValue(CLIENTS); + installMock.mockReset(); + snippetMock.mockReset(); + getAuthMock.mockReset().mockResolvedValue(true); + navigateMock.mockReset(); + setSectionMock.mockReset(); + gatewayStatusMock + .mockReset() + .mockResolvedValue({ running: true, url: 'http://localhost:45818' }); + }); + + it('lists every supported client', async () => { + render(); + expect(await screen.findByText('Cursor')).toBeTruthy(); + for (const c of CLIENTS) { + expect(screen.getByTestId(`workspace-install-client-${c.id}`)).toBeTruthy(); + } + }); + + it('installs the default-selected clients with the gateway /mcp url', async () => { + const user = userEvent.setup(); + installMock.mockResolvedValue([ + { client: 'cursor', label: 'Cursor', path: '/p/.cursor/mcp.json', action: 'created', backed_up: null, error: null }, + ]); + render(); + + const btn = await screen.findByTestId('workspace-install-button'); + await user.click(btn); + + await waitFor(() => expect(installMock).toHaveBeenCalledTimes(1)); + const arg = installMock.mock.calls[0][0]; + expect(arg.workspaceRoot).toBe(ROOT); + expect(arg.serverUrl).toBe('http://localhost:45818/mcp'); + // Defaults to the common three. + expect(arg.clients).toEqual(['cursor', 'claude-code', 'vscode']); + // Result row is shown. + expect(await screen.findByTestId('workspace-install-results')).toBeTruthy(); + }); + + it('remembers the previous client selection across renders', async () => { + const user = userEvent.setup(); + installMock.mockResolvedValue([]); + + // First mount: deselect the defaults down to just opencode, then install + // (which persists the selection). + const first = render(); + await screen.findByTestId('workspace-install-client-cursor'); + for (const id of ['cursor', 'claude-code', 'vscode']) { + await user.click(screen.getByTestId(`workspace-install-client-${id}`)); + } + await user.click(screen.getByTestId('workspace-install-client-opencode')); + await user.click(screen.getByTestId('workspace-install-button')); + await waitFor(() => expect(installMock).toHaveBeenCalled()); + expect(installMock.mock.calls[0][0].clients).toEqual(['opencode']); + first.unmount(); + + // Second mount: the remembered selection (opencode only) is restored, not + // the big-three default. + installMock.mockClear(); + render(); + await screen.findByTestId('workspace-install-button'); + await user.click(screen.getByTestId('workspace-install-button')); + await waitFor(() => expect(installMock).toHaveBeenCalled()); + expect(installMock.mock.calls[0][0].clients).toEqual(['opencode']); + }); + + it('shows the auth nudge and routes to Settings (no inline disable)', async () => { + const user = userEvent.setup(); + getAuthMock.mockResolvedValue(false); // auth currently required + render(); + + // Auth is application-wide: the panel links to Settings instead of + // flipping it inline. + const openSettings = await screen.findByTestId('workspace-install-open-auth-settings'); + await user.click(openSettings); + expect(setSectionMock).toHaveBeenCalledWith('security'); + expect(navigateMock).toHaveBeenCalledWith('settings'); + }); + + it('copies a client snippet to the clipboard', async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + snippetMock.mockResolvedValue({ + client: 'cursor', + label: 'Cursor', + config_path: '.cursor/mcp.json', + content: '{ "mcpServers": { "mcpmux": {} } }', + }); + render(); + + const copyBtn = await screen.findByTestId('workspace-install-copy-cursor'); + await user.click(copyBtn); + + await waitFor(() => + expect(snippetMock).toHaveBeenCalledWith( + expect.objectContaining({ client: 'cursor', workspaceRoot: ROOT }) + ) + ); + await waitFor(() => expect(writeText).toHaveBeenCalled()); + }); + + it('blocks install until the gateway is running', async () => { + gatewayStatusMock.mockResolvedValue({ running: false, url: null }); + render(); + const btn = await screen.findByTestId('workspace-install-button'); + await waitFor(() => expect(btn).toHaveProperty('disabled', true)); + expect(btn.textContent).toContain('Start the gateway'); + }); +}); diff --git a/tests/ts/components/WorkspaceSetupWizard.test.tsx b/tests/ts/components/WorkspaceSetupWizard.test.tsx new file mode 100644 index 00000000..ab96dded --- /dev/null +++ b/tests/ts/components/WorkspaceSetupWizard.test.tsx @@ -0,0 +1,99 @@ +/** + * Workspaces — "Set up a folder" walkthrough. + * + * Verifies the 3-step create flow: pick a folder (step 1, required), advance + * through the optional connect-apps step (2), and on the tools step (3) Finish + * creates a binding with the folder path, chosen Space, and the default Starter + * feature set pre-selected. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const { openMock, validateMock } = vi.hoisted(() => ({ + openMock: vi.fn(), + validateMock: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ open: openMock })); +vi.mock('@/lib/api/workspaceBindings', () => ({ validateWorkspaceRoot: validateMock })); +vi.mock('@/lib/api/featureSets', () => ({ + isStarterFeatureSet: (fs: { feature_set_type: string }) => + fs.feature_set_type === 'starter' || fs.feature_set_type === 'default', +})); +// Step 2 embeds the install panel; stub it out — it has its own tests. +vi.mock('@/features/workspaces/WorkspaceInstallPanel', () => ({ + WorkspaceInstallPanel: () => null, +})); + +import { WorkspaceSetupWizard } from '@/features/workspaces/WorkspaceSetupWizard'; + +const SPACES = [ + { id: 's1', name: 'Default', icon: '', description: null, is_default: true, sort_order: 0, created_at: '', updated_at: '' }, +]; +const FEATURE_SETS = [ + { id: 'fs_starter', name: 'Starter', space_id: 's1', feature_set_type: 'starter' }, + { id: 'fs_a', name: 'Custom A', space_id: 's1', feature_set_type: 'custom' }, +]; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const props = (over: any = {}) => ({ + spaces: SPACES as any, + featureSets: FEATURE_SETS as any, + reportedRoots: ['/proj/app'], + existingBindings: [], + onClose: vi.fn(), + onCreate: vi.fn().mockResolvedValue({ id: 'b1' }), + onError: vi.fn(), + ...over, +}); + +describe('WorkspaceSetupWizard', () => { + beforeEach(() => { + openMock.mockReset(); + validateMock.mockReset(); + }); + + it('walks folder → apps → tools and Finish creates the binding', async () => { + const user = userEvent.setup(); + const p = props(); + render(); + + // Step 1: Next is disabled until a folder is chosen. + expect(screen.getByTestId('wizard-step-folder')).toBeTruthy(); + expect(screen.getByTestId('wizard-next')).toHaveProperty('disabled', true); + + // Quick-pick the detected folder. + await user.click(screen.getByRole('button', { name: /proj\/app/ })); + expect(screen.getByTestId('wizard-next')).toHaveProperty('disabled', false); + await user.click(screen.getByTestId('wizard-next')); + + // Step 2: connect apps (stubbed) → Next. + expect(screen.getByTestId('wizard-step-apps')).toBeTruthy(); + await user.click(screen.getByTestId('wizard-next')); + + // Step 3: Starter is pre-selected; Finish creates the binding. + expect(screen.getByTestId('wizard-step-tools')).toBeTruthy(); + await user.click(screen.getByTestId('wizard-finish')); + + await waitFor(() => expect(p.onCreate).toHaveBeenCalledTimes(1)); + expect(p.onCreate).toHaveBeenCalledWith({ + workspace_root: '/proj/app', + space_id: 's1', + feature_set_ids: ['fs_starter'], + }); + // The parent navigates to the new mapping's inspector (effective features); + // the wizard itself does not close. + expect(p.onClose).not.toHaveBeenCalled(); + }); + + it('lets you go Back from a later step', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /proj\/app/ })); + await user.click(screen.getByTestId('wizard-next')); // → step 2 + expect(screen.getByTestId('wizard-step-apps')).toBeTruthy(); + await user.click(screen.getByTestId('wizard-back')); // → step 1 + expect(screen.getByTestId('wizard-step-folder')).toBeTruthy(); + }); +}); diff --git a/tests/ts/components/WorkspacesClearUnmapped.test.tsx b/tests/ts/components/WorkspacesClearUnmapped.test.tsx index 12ef074a..47da1d56 100644 --- a/tests/ts/components/WorkspacesClearUnmapped.test.tsx +++ b/tests/ts/components/WorkspacesClearUnmapped.test.tsx @@ -43,6 +43,8 @@ vi.mock('@/lib/api/featureSets', () => ({ vi.mock('@/stores', () => ({ useSpaces: () => [], + usePendingWorkspaceNew: () => false, + useSetPendingWorkspaceNew: () => () => {}, })); import { WorkspacesPage } from '@/features/workspaces/WorkspacesPage'; diff --git a/tests/ts/components/WorkspacesMappedFilter.test.tsx b/tests/ts/components/WorkspacesMappedFilter.test.tsx index 8ea0ee92..8bd893f7 100644 --- a/tests/ts/components/WorkspacesMappedFilter.test.tsx +++ b/tests/ts/components/WorkspacesMappedFilter.test.tsx @@ -41,6 +41,8 @@ vi.mock('@/lib/api/featureSets', () => ({ vi.mock('@/stores', () => ({ useSpaces: () => [{ id: 's1', name: 'Space One' }], + usePendingWorkspaceNew: () => false, + useSetPendingWorkspaceNew: () => () => {}, })); import { WorkspacesPage } from '@/features/workspaces/WorkspacesPage';