From b5a91679b355b6999087a99c4479addd43709114 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 24 Jun 2026 12:51:14 +0800 Subject: [PATCH 01/14] feat(gateway): route by explicit X-Mcpmux-Workspace header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients that don't report MCP `roots` reliably — notably Cursor, which multiplexes one MCP host across windows with `roots.listChanged=false` — could not be routed to the right workspace binding, so `@mux` saw another window's FeatureSet. `roots` is also deprecated (SEP-2577) and sessions are being removed (SEP-2575/2567), so connection-carried identity, not the client's roots, is the durable signal. Add an `X-Mcpmux-Workspace` request header whose value is the workspace root path. The OAuth middleware pins it into SessionRootsRegistry, where it shadows the client's probed roots in `get()`. Because the resolver, the on-demand probe skip, and the prompt-root derivation all read roots through `get()`, the header flows through Tier 1 unchanged — exact binding match, else Space default — and is authoritative over a stale or absent reported root. No new resolver tier, parameter, or DB migration; the header value is just a workspace root normalized like any other. Tests: registry pin/shadow/clear units; resolver integration cases proving a pinned root routes to its binding with no reported roots, overrides a conflicting reported root, and falls back to Space default when unmapped. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- Cargo.lock | 10 +- .../src/mcp/oauth_middleware.rs | 26 +++++ .../src/services/feature_set_resolver.rs | 13 +++ .../src/services/session_roots.rs | 108 ++++++++++++++++++ .../tests/integration/feature_set_resolver.rs | 82 +++++++++++++ 5 files changed, 234 insertions(+), 5 deletions(-) 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/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs index a5630b15..1d795f5d 100644 --- a/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs +++ b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs @@ -113,6 +113,32 @@ pub async fn mcp_oauth_middleware( 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; 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/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs index d515f124..36e62691 100644 --- a/tests/rust/tests/integration/feature_set_resolver.rs +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -655,3 +655,85 @@ 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 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)); +} From 429e69a112197095b782d2561e041e828769266d Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 24 Jun 2026 12:58:35 +0800 Subject: [PATCH 02/14] feat(gateway): optional system-wide disable of inbound auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `gateway.auth_disabled` setting (default false — auth required) that, when on, lets inbound MCP clients connect without a Bearer token. This makes the upcoming one-click per-workspace install trivial: a client config needs only the URL + `X-Mcpmux-Workspace` header, no OAuth/access-key dance. The middleware is now lenient rather than all-or-nothing: a valid token is always honored when present, so flipping the setting never breaks an already-configured client. With auth disabled and no valid token, the connection is accepted as an anonymous client on the default Space; routing still prefers the workspace header → binding. The toggle lives in GatewayState (seeded from settings at startup, flipped live by set_gateway_auth_disabled so no restart is needed). Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- .../desktop/src-tauri/src/commands/gateway.rs | 58 ++++++++ apps/desktop/src-tauri/src/lib.rs | 2 + .../src/mcp/oauth_middleware.rs | 136 +++++++++++------- crates/mcpmux-gateway/src/server/state.rs | 40 ++++++ 4 files changed, 183 insertions(+), 53 deletions(-) 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/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6cc6b1a7..e4d60a6e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -945,6 +945,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/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs index 1d795f5d..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,75 +44,99 @@ 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", @@ -152,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" @@ -185,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()); + } +} From 72f310f50da7be326209421304cd70b6504015d7 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 24 Jun 2026 13:04:13 +0800 Subject: [PATCH 03/14] feat(desktop): per-workspace MCP client config installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add commands to register the gateway endpoint in a project-local MCP config inside a chosen folder, injecting `X-Mcpmux-Workspace: ` so the gateway routes the connection to that folder's workspace binding without relying on the client reporting MCP roots. - list_workspace_install_clients: the supported project-local clients (Cursor, Claude Code, VS Code/Copilot, opencode, Zed) with their config paths. Each client's config-shape differences (top-level key mcpServers / servers / mcp / context_servers, and the type field) live in one table. - generate_workspace_config_snippet: copy-paste full-file snippet per client. - install_workspace_mcp_config: create or extend each client's config, preserving other servers, backing up an existing file, creating parent dirs. Refuses to clobber a non-JSON (JSONC) file, reporting it instead. Global-only clients (Windsurf, Cline) and Claude Desktop (stdio, no static headers) are excluded — a per-workspace header needs project-local scope. Tested: entry shape per client, merge create/extend/replace, preserve siblings, reject JSONC/non-object, create-then-update-with-backup on disk. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/workspace_install.rs | 529 ++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 4 + 3 files changed, 535 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/workspace_install.rs 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 e4d60a6e..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, From ee995b632b2c87c852e0e43a1dbd83c3707743c6 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 24 Jun 2026 13:10:17 +0800 Subject: [PATCH 04/14] feat(ui): per-workspace install panel + disable-auth toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the per-workspace MCP install: - WorkspaceInstallPanel: a "Connect apps to this folder" section in the Workspaces inspector. Lists the supported clients (Cursor, Claude Code, VS Code/Copilot, opencode, Zed), copies a per-client snippet, and writes the configs into the folder with this folder's path as the X-Mcpmux-Workspace header. Self-introductory: shows the system-wide auth state and offers an inline "Disable authentication" action, since turning it off makes the written config a pure URL + header. - Settings → Security: a "Disable authentication" toggle. - workspaceInstall API wrappers for the new commands. Tested (vitest): lists clients, installs the default-selected clients with the gateway /mcp url, flips auth inline, copies a snippet, and blocks install until the gateway is running. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- .../src/features/settings/SettingsPage.tsx | 69 +++++ .../workspaces/WorkspaceInstallPanel.tsx | 261 ++++++++++++++++++ .../features/workspaces/WorkspacesPage.tsx | 14 + apps/desktop/src/lib/api/workspaceInstall.ts | 74 +++++ .../components/WorkspaceInstallPanel.test.tsx | 146 ++++++++++ 5 files changed, 564 insertions(+) create mode 100644 apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx create mode 100644 apps/desktop/src/lib/api/workspaceInstall.ts create mode 100644 tests/ts/components/WorkspaceInstallPanel.test.tsx diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index 97b11995..7988d1d4 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -31,6 +31,7 @@ import { Network, RotateCcw, AlertCircle, + ShieldOff, } from 'lucide-react'; import { useAppStore, useTheme, useAnalyticsEnabled } from '@/stores'; import { UpdateChecker } from './UpdateChecker'; @@ -77,6 +78,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 +251,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 +625,41 @@ export function SettingsPage() { + {/* Security Section */} + + + + + Security + + + How McpMux authenticates apps connecting to the local gateway. + + + +
+
+ +
+ +

+ Let local apps connect to the gateway with no access key — just the URL and a + workspace header. Makes one-click per-workspace setup trivial. The gateway only + listens on localhost, but any app on this machine can then reach it. Leave on + unless you want the simplest setup. +

+
+
+ +
+
+
+ {/* Appearance Section */} diff --git a/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx b/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx new file mode 100644 index 00000000..61bde2be --- /dev/null +++ b/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx @@ -0,0 +1,261 @@ +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 { + generateWorkspaceConfigSnippet, + getGatewayAuthDisabled, + installWorkspaceMcpConfig, + listWorkspaceInstallClients, + setGatewayAuthDisabled, + type WorkspaceInstallClient, + type WorkspaceInstallResult, +} from '@/lib/api/workspaceInstall'; + +/** Clients selected by default — the most common three. */ +const DEFAULT_SELECTED = ['cursor', 'claude-code', 'vscode']; + +/** + * "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([]); + const [selected, setSelected] = useState>(() => 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 [togglingAuth, setTogglingAuth] = useState(false); + + 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); + setAuthDisabled(disabled); + setMcpUrl(status.url ? `${status.url}/mcp` : null); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + 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); + } + }; + + const handleDisableAuth = async () => { + setTogglingAuth(true); + try { + const v = await setGatewayAuthDisabled(true); + setAuthDisabled(v); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setTogglingAuth(false); + } + }; + + return ( +
+

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

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

+ Apps will need an access key to connect. +

+

+ For zero-config setup, disable system-wide authentication — apps then connect with + just the URL and this workspace header. +

+ +
+
+ )} + {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/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 2bcb57c7..87cdf3e4 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -50,6 +50,7 @@ import { listFeatureSets, type FeatureSet, } from '@/lib/api/featureSets'; +import { WorkspaceInstallPanel } from './WorkspaceInstallPanel'; import { useSpaces } from '@/stores'; import type { Space } from '@/lib/api/spaces'; @@ -1017,6 +1018,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/tests/ts/components/WorkspaceInstallPanel.test.tsx b/tests/ts/components/WorkspaceInstallPanel.test.tsx new file mode 100644 index 00000000..5b988064 --- /dev/null +++ b/tests/ts/components/WorkspaceInstallPanel.test.tsx @@ -0,0 +1,146 @@ +/** + * 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, + setAuthMock, + gatewayStatusMock, +} = vi.hoisted(() => ({ + listClientsMock: vi.fn(), + installMock: vi.fn(), + snippetMock: vi.fn(), + getAuthMock: vi.fn(), + setAuthMock: vi.fn(), + gatewayStatusMock: vi.fn(), +})); + +vi.mock('@/lib/api/workspaceInstall', () => ({ + listWorkspaceInstallClients: listClientsMock, + installWorkspaceMcpConfig: installMock, + generateWorkspaceConfigSnippet: snippetMock, + getGatewayAuthDisabled: getAuthMock, + setGatewayAuthDisabled: setAuthMock, +})); + +vi.mock('@/lib/api/gateway', () => ({ + getGatewayStatus: gatewayStatusMock, +})); + +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(() => { + listClientsMock.mockReset().mockResolvedValue(CLIENTS); + installMock.mockReset(); + snippetMock.mockReset(); + getAuthMock.mockReset().mockResolvedValue(true); + setAuthMock.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('shows the auth nudge and disables auth inline', async () => { + const user = userEvent.setup(); + getAuthMock.mockResolvedValue(false); // auth currently required + setAuthMock.mockResolvedValue(true); + render(); + + const disableBtn = await screen.findByTestId('workspace-install-disable-auth'); + await user.click(disableBtn); + + await waitFor(() => expect(setAuthMock).toHaveBeenCalledWith(true)); + // Nudge is replaced by the "auth is off" confirmation. + await waitFor(() => + expect(screen.queryByTestId('workspace-install-auth-nudge')).toBeNull() + ); + }); + + 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'); + }); +}); From fd0f4408eb951b70a9284c32f9849c6df9948c7e Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 24 Jun 2026 13:12:10 +0800 Subject: [PATCH 05/14] docs(workspaces): self-intro hints + manual test guide - Approval sheet and the Apps page's "Routing is workspace-driven" panel now point users at "Connect apps to this folder" when a client (e.g. Cursor) doesn't report the folder reliably. - Add a manual test guide for header routing, the per-workspace installer, and the disable-auth toggle, with log lines to look for. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- .../src/features/clients/ClientsPage.tsx | 5 +- .../workspaces/WorkspaceBindingSheet.tsx | 9 ++ docs/manual/workspace-header-routing.md | 111 ++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 docs/manual/workspace-header-routing.md 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.

diff --git a/tests/ts/components/WorkspaceInstallPanel.test.tsx b/tests/ts/components/WorkspaceInstallPanel.test.tsx index 15411c34..4f80faf8 100644 --- a/tests/ts/components/WorkspaceInstallPanel.test.tsx +++ b/tests/ts/components/WorkspaceInstallPanel.test.tsx @@ -19,15 +19,15 @@ const { installMock, snippetMock, getAuthMock, - setAuthMock, gatewayStatusMock, + navigateMock, } = vi.hoisted(() => ({ listClientsMock: vi.fn(), installMock: vi.fn(), snippetMock: vi.fn(), getAuthMock: vi.fn(), - setAuthMock: vi.fn(), gatewayStatusMock: vi.fn(), + navigateMock: vi.fn(), })); vi.mock('@/lib/api/workspaceInstall', () => ({ @@ -35,13 +35,16 @@ vi.mock('@/lib/api/workspaceInstall', () => ({ installWorkspaceMcpConfig: installMock, generateWorkspaceConfigSnippet: snippetMock, getGatewayAuthDisabled: getAuthMock, - setGatewayAuthDisabled: setAuthMock, })); vi.mock('@/lib/api/gateway', () => ({ getGatewayStatus: gatewayStatusMock, })); +vi.mock('@/stores', () => ({ + useNavigateTo: () => navigateMock, +})); + import { WorkspaceInstallPanel } from '@/features/workspaces/WorkspaceInstallPanel'; const CLIENTS = [ @@ -61,7 +64,7 @@ describe('WorkspaceInstallPanel', () => { installMock.mockReset(); snippetMock.mockReset(); getAuthMock.mockReset().mockResolvedValue(true); - setAuthMock.mockReset(); + navigateMock.mockReset(); gatewayStatusMock .mockReset() .mockResolvedValue({ running: true, url: 'http://localhost:45818' }); @@ -122,20 +125,16 @@ describe('WorkspaceInstallPanel', () => { expect(installMock.mock.calls[0][0].clients).toEqual(['opencode']); }); - it('shows the auth nudge and disables auth inline', async () => { + it('shows the auth nudge and routes to Settings (no inline disable)', async () => { const user = userEvent.setup(); getAuthMock.mockResolvedValue(false); // auth currently required - setAuthMock.mockResolvedValue(true); render(); - const disableBtn = await screen.findByTestId('workspace-install-disable-auth'); - await user.click(disableBtn); - - await waitFor(() => expect(setAuthMock).toHaveBeenCalledWith(true)); - // Nudge is replaced by the "auth is off" confirmation. - await waitFor(() => - expect(screen.queryByTestId('workspace-install-auth-nudge')).toBeNull() - ); + // 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(navigateMock).toHaveBeenCalledWith('settings'); }); it('copies a client snippet to the clipboard', async () => { From 428e3fc2337433f66b2ea78c12c7f139591643dc Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 24 Jun 2026 19:19:48 +0800 Subject: [PATCH 09/14] feat(ui): clearer auth nudge copy in the install panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe the auth notice from "Apps will need an access key" to the actual one-time flow: you enable and authenticate the app once and then it connects, and the client authentication requirement can be disabled in Settings → Security for a no-key setup. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- .../src/features/workspaces/WorkspaceInstallPanel.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx b/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx index 0e3497cd..68d7b5f1 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx @@ -164,11 +164,12 @@ export function WorkspaceInstallPanel({ workspaceRoot }: { workspaceRoot: string

- Apps will need an access key to connect. + You'll enable and authenticate this app once for it to work.

- Authentication is an application-wide setting. For zero-config setup, turn it off in - Settings → Security — apps then connect with just the URL and this workspace header. + With authentication on, you approve and sign the app in a single time, then it + connects. The client authentication requirement can be disabled in Settings → Security + — apps then connect with just the URL and this workspace header.

{/* Appearance Section */} diff --git a/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx b/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx index 26a650c4..287c684d 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx @@ -2,7 +2,7 @@ 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 } from '@/stores'; +import { useNavigateTo, useSetPendingSettingsSection } from '@/stores'; import { generateWorkspaceConfigSnippet, getGatewayAuthDisabled, @@ -63,6 +63,7 @@ export function WorkspaceInstallPanel({ workspaceRoot }: { workspaceRoot: string const [copiedId, setCopiedId] = useState(null); const [error, setError] = useState(null); const navigateTo = useNavigateTo(); + const setPendingSettingsSection = useSetPendingSettingsSection(); useEffect(() => { let cancelled = false; @@ -171,7 +172,10 @@ export function WorkspaceInstallPanel({ workspaceRoot }: { workspaceRoot: string variant="secondary" size="sm" className="mt-2 h-7 text-xs" - onClick={() => navigateTo('settings')} + onClick={() => { + setPendingSettingsSection('security'); + navigateTo('settings'); + }} data-testid="workspace-install-open-auth-settings" > diff --git a/apps/desktop/src/stores/appStore.ts b/apps/desktop/src/stores/appStore.ts index c1453408..d9fb3f77 100644 --- a/apps/desktop/src/stores/appStore.ts +++ b/apps/desktop/src/stores/appStore.ts @@ -8,6 +8,7 @@ const initialState: AppState = { viewSpaceId: null, activeNav: 'home', pendingClientId: null, + pendingSettingsSection: null, sidebarCollapsed: false, theme: 'system', analyticsEnabled: true, @@ -78,6 +79,11 @@ export const useAppStore = create()( state.pendingClientId = id; }), + setPendingSettingsSection: (section) => + set((state) => { + state.pendingSettingsSection = section; + }), + // UI toggleSidebar: () => set((state) => { diff --git a/apps/desktop/src/stores/selectors.ts b/apps/desktop/src/stores/selectors.ts index 99282afb..4ec7cc4d 100644 --- a/apps/desktop/src/stores/selectors.ts +++ b/apps/desktop/src/stores/selectors.ts @@ -8,6 +8,10 @@ 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 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..6f8e940a 100644 --- a/apps/desktop/src/stores/types.ts +++ b/apps/desktop/src/stores/types.ts @@ -26,6 +26,8 @@ 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; // UI state sidebarCollapsed: boolean; @@ -50,6 +52,7 @@ export interface AppActions { // Navigation navigateTo: (nav: NavItem) => void; setPendingClientId: (id: string | null) => void; + setPendingSettingsSection: (section: string | null) => void; // UI toggleSidebar: () => void; diff --git a/tests/ts/components/WorkspaceInstallPanel.test.tsx b/tests/ts/components/WorkspaceInstallPanel.test.tsx index 4f80faf8..95e7e9e3 100644 --- a/tests/ts/components/WorkspaceInstallPanel.test.tsx +++ b/tests/ts/components/WorkspaceInstallPanel.test.tsx @@ -21,6 +21,7 @@ const { getAuthMock, gatewayStatusMock, navigateMock, + setSectionMock, } = vi.hoisted(() => ({ listClientsMock: vi.fn(), installMock: vi.fn(), @@ -28,6 +29,7 @@ const { getAuthMock: vi.fn(), gatewayStatusMock: vi.fn(), navigateMock: vi.fn(), + setSectionMock: vi.fn(), })); vi.mock('@/lib/api/workspaceInstall', () => ({ @@ -43,6 +45,7 @@ vi.mock('@/lib/api/gateway', () => ({ vi.mock('@/stores', () => ({ useNavigateTo: () => navigateMock, + useSetPendingSettingsSection: () => setSectionMock, })); import { WorkspaceInstallPanel } from '@/features/workspaces/WorkspaceInstallPanel'; @@ -65,6 +68,7 @@ describe('WorkspaceInstallPanel', () => { snippetMock.mockReset(); getAuthMock.mockReset().mockResolvedValue(true); navigateMock.mockReset(); + setSectionMock.mockReset(); gatewayStatusMock .mockReset() .mockResolvedValue({ running: true, url: 'http://localhost:45818' }); @@ -134,6 +138,7 @@ describe('WorkspaceInstallPanel', () => { // 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'); }); From c2c8455d29d7bdaf088fa71857a5b334c8a949f8 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 24 Jun 2026 19:44:01 +0800 Subject: [PATCH 13/14] feat(ui): guided "set up a folder" walkthrough + home entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New mapping (create path) is now a 3-step walkthrough; editing an existing mapping still uses the inspector. 1. Folder — required; pick via dialog or a detected workspace. 2. Apps — optional; the install panel writes the per-workspace config (X-Mcpmux-Workspace header) so apps route here. 3. Tools — defaults to the Space's Starter so Finish is one click; the binding is created on Finish. Abandoning before Finish is safe (the folder uses the default Starter until mapped, and any installed config still points at it). A "Set up a folder" card on Home opens the walkthrough via a pendingWorkspaceNew store signal (WorkspacesPage launches it on arrival). Tests: wizard walks the 3 steps and Finish creates the binding with the folder path + chosen Space + default Starter; Back navigation. Updated the three store-mocking suites for the new selectors. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src/features/home/HomePage.tsx | 49 ++- .../workspaces/WorkspaceSetupWizard.tsx | 338 ++++++++++++++++++ .../features/workspaces/WorkspacesPage.tsx | 67 ++-- apps/desktop/src/stores/appStore.ts | 6 + apps/desktop/src/stores/selectors.ts | 3 + apps/desktop/src/stores/types.ts | 3 + tests/ts/components/HomePageStats.test.tsx | 1 + .../components/WorkspaceSetupWizard.test.tsx | 97 +++++ .../WorkspacesClearUnmapped.test.tsx | 2 + .../WorkspacesMappedFilter.test.tsx | 2 + 10 files changed, 544 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx create mode 100644 tests/ts/components/WorkspaceSetupWizard.test.tsx diff --git a/apps/desktop/src/features/home/HomePage.tsx b/apps/desktop/src/features/home/HomePage.tsx index 650a7874..ada34911 100644 --- a/apps/desktop/src/features/home/HomePage.tsx +++ b/apps/desktop/src/features/home/HomePage.tsx @@ -10,12 +10,21 @@ * the page that manages what it counts. */ import { useEffect, useState, useCallback } from 'react'; -import { Server, Wrench, Monitor, Globe, ArrowUpRight, Compass, ArrowRight } from 'lucide-react'; +import { + Server, + Wrench, + Monitor, + Globe, + ArrowUpRight, + Compass, + ArrowRight, + FolderPlus, +} from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { PageHeader } from '@mcpmux/ui'; import { ConnectionCard } from '@/components/ConnectionCard'; import { useGatewayEvents, useServerStatusEvents, useDomainEvents } from '@/hooks/useDomainEvents'; -import { useViewSpace, useNavigateTo } from '@/stores'; +import { useViewSpace, useNavigateTo, useSetPendingWorkspaceNew } from '@/stores'; import type { NavItem } from '@/stores/types'; import { spaceAccentColor } from '@/lib/spaceAccent'; @@ -152,6 +161,39 @@ function GetStartedStrip() { ); } +/** + * Per-folder setup entry point. The ConnectionCard above connects an app to + * the gateway globally; this routes into the Workspaces walkthrough to map a + * specific project and write its per-folder config. + */ +function SetUpFolderCard() { + const navigateTo = useNavigateTo(); + const openWizard = useSetPendingWorkspaceNew(); + return ( + + ); +} + 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. */}
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), + }); + onClose(); + } 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 87cdf3e4..51bd7c62 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -51,7 +51,8 @@ import { type FeatureSet, } from '@/lib/api/featureSets'; import { WorkspaceInstallPanel } from './WorkspaceInstallPanel'; -import { useSpaces } from '@/stores'; +import { WorkspaceSetupWizard } from './WorkspaceSetupWizard'; +import { useSpaces, usePendingWorkspaceNew, useSetPendingWorkspaceNew } from '@/stores'; import type { Space } from '@/lib/api/spaces'; /** @@ -82,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([]); @@ -116,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 @@ -446,27 +457,39 @@ 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 { - 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)} - /> + {selectedIsNew ? ( + setSelected(null)} + onCreate={handleCreate} + 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)} + /> + )} )} diff --git a/apps/desktop/src/stores/appStore.ts b/apps/desktop/src/stores/appStore.ts index d9fb3f77..13e4e156 100644 --- a/apps/desktop/src/stores/appStore.ts +++ b/apps/desktop/src/stores/appStore.ts @@ -9,6 +9,7 @@ const initialState: AppState = { activeNav: 'home', pendingClientId: null, pendingSettingsSection: null, + pendingWorkspaceNew: false, sidebarCollapsed: false, theme: 'system', analyticsEnabled: true, @@ -84,6 +85,11 @@ export const useAppStore = create()( 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 4ec7cc4d..5c800120 100644 --- a/apps/desktop/src/stores/selectors.ts +++ b/apps/desktop/src/stores/selectors.ts @@ -12,6 +12,9 @@ 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 6f8e940a..c9626eba 100644 --- a/apps/desktop/src/stores/types.ts +++ b/apps/desktop/src/stores/types.ts @@ -28,6 +28,8 @@ export interface AppState { 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; @@ -53,6 +55,7 @@ export interface AppActions { navigateTo: (nav: NavItem) => void; setPendingClientId: (id: string | null) => void; setPendingSettingsSection: (section: string | null) => void; + setPendingWorkspaceNew: (v: boolean) => void; // UI toggleSidebar: () => void; 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/WorkspaceSetupWizard.test.tsx b/tests/ts/components/WorkspaceSetupWizard.test.tsx new file mode 100644 index 00000000..4f8784e7 --- /dev/null +++ b/tests/ts/components/WorkspaceSetupWizard.test.tsx @@ -0,0 +1,97 @@ +/** + * 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'], + }); + await waitFor(() => expect(p.onClose).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'; From 41c8c862852de2128cb15a92bdb9dcf942af2361 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 24 Jun 2026 19:58:55 +0800 Subject: [PATCH 14/14] feat(workspaces): show effective features after setup + prove authless gateway - After the setup walkthrough's Finish, land on the new mapping's inspector (which shows its effective features) instead of just closing. The wizard no longer self-closes on create; the page transitions to the created entry. - Prove the gateway is truly authless when inbound auth is disabled: a new HTTP integration test drives the REAL oauth middleware and asserts a tokenless POST to /mcp is accepted (200, anonymous identity injected) when disabled, and rejected (401) when auth is required. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- .../workspaces/WorkspaceSetupWizard.tsx | 3 +- .../features/workspaces/WorkspacesPage.tsx | 8 +- .../tests/streamable_http/auth_disable.rs | 180 ++++++++++++++++++ tests/rust/tests/streamable_http/mod.rs | 1 + .../components/WorkspaceSetupWizard.test.tsx | 4 +- 5 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 tests/rust/tests/streamable_http/auth_disable.rs diff --git a/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx b/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx index bc75b0dc..21e0231c 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx @@ -121,7 +121,8 @@ export function WorkspaceSetupWizard({ space_id: spaceId, feature_set_ids: Array.from(fsIds), }); - onClose(); + // 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); diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 51bd7c62..0c677aa6 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -464,7 +464,13 @@ export function WorkspacesPage() { reportedRoots={reportedRoots} existingBindings={bindings} onClose={() => setSelected(null)} - onCreate={handleCreate} + 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 }); + return created; + }} onError={(msg) => showError('Could not save', msg)} /> ) : ( 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/WorkspaceSetupWizard.test.tsx b/tests/ts/components/WorkspaceSetupWizard.test.tsx index 4f8784e7..ab96dded 100644 --- a/tests/ts/components/WorkspaceSetupWizard.test.tsx +++ b/tests/ts/components/WorkspaceSetupWizard.test.tsx @@ -82,7 +82,9 @@ describe('WorkspaceSetupWizard', () => { space_id: 's1', feature_set_ids: ['fs_starter'], }); - await waitFor(() => expect(p.onClose).toHaveBeenCalled()); + // 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 () => {