From 3640dea441d5dd665ff81b3f6b94621f4cccab7f Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 18:30:31 +0800 Subject: [PATCH 01/19] feat(storage): inbound API-key credentials (migration 020 + repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of API-key inbound auth (so headless/remote clients authenticate without the host-only OAuth consent deep link). Adds the inbound_client_api_keys table — SHA-256 hashes only, never plaintext; multiple keys per client for rotation — and InboundClientRepository methods create/validate/list/revoke (validation rejects revoked/expired keys and touches last_used_at), plus InboundApiKey + ApiKeyAuth types. Signed-off-by: Mohammod Al Amin Ashik --- crates/mcpmux-storage/src/database.rs | 5 + .../020_inbound_client_api_keys.sql | 25 ++++ .../repositories/inbound_client_repository.rs | 141 ++++++++++++++++++ crates/mcpmux-storage/src/repositories/mod.rs | 4 +- 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index 14a32c2a..93fc27d7 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -128,6 +128,11 @@ const MIGRATIONS: &[Migration] = &[ name: "space_base_dirs", sql: include_str!("migrations/019_space_base_dirs.sql"), }, + Migration { + version: 20, + name: "inbound_client_api_keys", + sql: include_str!("migrations/020_inbound_client_api_keys.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql b/crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql new file mode 100644 index 00000000..4b686086 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql @@ -0,0 +1,25 @@ +-- Migration 020: Inbound client API keys +-- +-- Long-lived, host-issued bearer credentials for manually-registered +-- (preregistered) inbound clients, so headless/remote clients can authenticate +-- WITHOUT the interactive OAuth consent flow (the mcpmux:// deep link only +-- works on the host). Keys are shown once at creation and stored only as a +-- SHA-256 hash — never in plaintext. Multiple keys per client allow rotation. + +CREATE TABLE IF NOT EXISTS inbound_client_api_keys ( + key_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, -- SHA-256(presented key), hex + key_prefix TEXT NOT NULL, -- first chars (e.g. "mcpk_ab12") for UI display + label TEXT, -- optional user-facing name for the key + revoked INTEGER NOT NULL DEFAULT 0, + last_used_at TEXT, + expires_at TEXT, -- optional ISO-8601; NULL = no expiry + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (client_id) REFERENCES inbound_clients(client_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_client ON inbound_client_api_keys(client_id); +-- Lookups on auth validate by hash and only care about live keys. +CREATE INDEX IF NOT EXISTS idx_api_keys_hash_live ON inbound_client_api_keys(key_hash) WHERE revoked = 0; diff --git a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs index 44676fc6..f20b353a 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs @@ -160,6 +160,27 @@ pub struct TokenRecord { pub parent_token_id: Option, } +/// A stored API-key record. Never exposes the secret — only its display prefix. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InboundApiKey { + pub key_id: String, + pub client_id: String, + pub key_prefix: String, + pub label: Option, + pub revoked: bool, + pub last_used_at: Option, + pub expires_at: Option, + pub created_at: String, + pub updated_at: String, +} + +/// Identity resolved from a presented API key. +#[derive(Debug, Clone)] +pub struct ApiKeyAuth { + pub key_id: String, + pub client_id: String, +} + /// OAuth Repository with database persistence pub struct InboundClientRepository { db: Arc>, @@ -566,6 +587,126 @@ impl InboundClientRepository { hex::encode(hasher.finalize()) } + // ========================================================================= + // Inbound client API keys (long-lived, host-issued bearer credentials) + // ========================================================================= + + /// SHA-256 hex of an API key — the only form ever persisted. Same algorithm + /// as `hash_token`; named separately for intent. + pub fn hash_api_key(key: &str) -> String { + Self::hash_token(key) + } + + /// Persist a freshly-generated API key for a client. The caller generates + /// the random `plaintext` (shown to the user once) and a unique `key_id`; + /// only the SHA-256 hash + a display prefix are stored. + pub async fn create_api_key( + &self, + key_id: &str, + client_id: &str, + plaintext: &str, + key_prefix: &str, + label: Option<&str>, + expires_at: Option<&str>, + ) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + let hash = Self::hash_api_key(plaintext); + let db = self.db.lock().await; + let conn = db.connection(); + conn.execute( + "INSERT INTO inbound_client_api_keys + (key_id, client_id, key_hash, key_prefix, label, revoked, expires_at, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, ?7)", + params![key_id, client_id, hash, key_prefix, label, expires_at, now], + )?; + info!( + "[ApiKey] Created key {} for client {}", + key_prefix, client_id + ); + Ok(()) + } + + /// Validate a presented API key: look up a live (non-revoked, unexpired) key + /// by hash, touch `last_used_at`, and return the owning client. + pub async fn validate_api_key(&self, presented: &str) -> Result> { + let hash = Self::hash_api_key(presented); + let now = chrono::Utc::now().to_rfc3339(); + let db = self.db.lock().await; + let conn = db.connection(); + + let result = conn.query_row( + "SELECT key_id, client_id, expires_at + FROM inbound_client_api_keys + WHERE key_hash = ?1 AND revoked = 0", + params![hash], + |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, Option>(2)?, + )) + }, + ); + let (key_id, client_id, expires_at) = match result { + Ok(t) => t, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + + // ISO-8601 strings compare lexicographically; reject expired keys. + if let Some(exp) = expires_at.as_deref() { + if exp <= now.as_str() { + return Ok(None); + } + } + + conn.execute( + "UPDATE inbound_client_api_keys SET last_used_at = ?1 WHERE key_id = ?2", + params![now, key_id], + )?; + Ok(Some(ApiKeyAuth { key_id, client_id })) + } + + /// List a client's API keys (no secrets — prefix + metadata only). + pub async fn list_api_keys(&self, client_id: &str) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + let mut stmt = conn.prepare( + "SELECT key_id, client_id, key_prefix, label, revoked, last_used_at, expires_at, created_at, updated_at + FROM inbound_client_api_keys WHERE client_id = ?1 ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![client_id], |r| { + Ok(InboundApiKey { + key_id: r.get(0)?, + client_id: r.get(1)?, + key_prefix: r.get(2)?, + label: r.get(3)?, + revoked: r.get::<_, i32>(4)? != 0, + last_used_at: r.get(5)?, + expires_at: r.get(6)?, + created_at: r.get(7)?, + updated_at: r.get(8)?, + }) + })?; + let mut keys = Vec::new(); + for k in rows { + keys.push(k?); + } + Ok(keys) + } + + /// Revoke a single API key (irreversible — it can never authenticate again). + pub async fn revoke_api_key(&self, key_id: &str) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + let db = self.db.lock().await; + let conn = db.connection(); + conn.execute( + "UPDATE inbound_client_api_keys SET revoked = 1, updated_at = ?1 WHERE key_id = ?2", + params![now, key_id], + )?; + Ok(()) + } + /// Save a token record pub async fn save_token(&self, record: &TokenRecord) -> Result<()> { let db = self.db.lock().await; diff --git a/crates/mcpmux-storage/src/repositories/mod.rs b/crates/mcpmux-storage/src/repositories/mod.rs index 9b1947db..fb6a87d4 100644 --- a/crates/mcpmux-storage/src/repositories/mod.rs +++ b/crates/mcpmux-storage/src/repositories/mod.rs @@ -17,8 +17,8 @@ pub use app_settings_repository::SqliteAppSettingsRepository; pub use credential_repository::SqliteCredentialRepository; pub use feature_set_repository::SqliteFeatureSetRepository; pub use inbound_client_repository::{ - AuthorizationCode, InboundClient, InboundClientRepository, RegistrationType, TokenRecord, - TokenType, + ApiKeyAuth, AuthorizationCode, InboundApiKey, InboundClient, InboundClientRepository, + RegistrationType, TokenRecord, TokenType, }; pub use inbound_mcp_client_repository::SqliteInboundMcpClientRepository; pub use installed_server_repository::SqliteInstalledServerRepository; From b0afeee96a7a1eea11c0d126e6ddae14df8278c1 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 18:34:50 +0800 Subject: [PATCH 02/19] feat(gateway): accept API keys in inbound auth middleware Phase 1. The auth chokepoint now resolves an identity from a valid JWT OR a valid API key (host-issued, validated via the inbound-client repo): when there is no valid JWT, a Bearer that matches a live key resolves to that key's client_id. Falls through to 401 (auth on) / anonymous (auth off) exactly as before. Lets headless/remote clients authenticate without the host-only OAuth consent deep link. Signed-off-by: Mohammod Al Amin Ashik --- .../src/mcp/oauth_middleware.rs | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs index 0a9be4da..561e0ade 100644 --- a/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs +++ b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs @@ -95,19 +95,45 @@ pub async fn mcp_oauth_middleware( None => None, }; - // 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 { + // If there's no valid JWT, a presented Bearer may instead be a long-lived + // API key (host-issued, for headless/remote clients) — validate it directly + // to a client_id so a remote client can authenticate with no interactive + // consent (the OAuth consent deep link only works on the host). + let api_key_client_id = if claims.is_none() { + match token { + Some(tok) => match services + .dependencies + .inbound_client_repo + .validate_api_key(tok) + .await + { + Ok(result) => result.map(|auth| auth.client_id), + Err(e) => { + warn!(trace_id = %trace_id, "API key validation error: {}", e); + None + } + }, + None => None, + } + } else { + None + }; + + // Resolve (client_id, space_id) from the authenticated identity (JWT or API + // key); when auth is disabled, fall back to an anonymous identity on the + // default space. + let authed_client_id = claims.map(|c| c.client_id).or(api_key_client_id); + let (client_id, space_id) = if let Some(cid) = authed_client_id { match services .space_resolver_service - .resolve_space_for_client(&claims.client_id) + .resolve_space_for_client(&cid) .await { - Ok(id) => (claims.client_id, id), + Ok(id) => (cid, id), Err(e) => { warn!( trace_id = %trace_id, - client_id = %claims.client_id, + client_id = %cid, "Failed to resolve space: {}", e ); return ( From 7496e8f1ab23c766392cf33de372d4e3d0ebda8f Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 29 Jun 2026 10:38:33 +0800 Subject: [PATCH 03/19] feat: register API-key clients + key-management commands Phase 1. Host-side surface to mint/manage API keys: - Tauri commands: register_api_key_client (creates a Preregistered + approved client, issues a key shown once), create_client_api_key (rotation), list_client_api_keys, revoke_client_api_key. - TS wrappers + RegisteredApiKeyClient / ApiKeyInfo types. The generated key (mcpk_ + 256 bits) is returned once; only its SHA-256 hash is stored. A remote client then authenticates with `Authorization: Bearer `. The locked_space wiring (set_locked_space/get_locked_space repo methods, register_api_key_client's locked_space_id param, and the lockedSpaceId TS field) is deferred to P2, where migration 022 introduces the locked_space_id column (Strategy Y). P1 ships pure API-key auth with migration 020 only. Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src-tauri/src/commands/oauth.rs | 215 +++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 4 + apps/desktop/src/lib/api/gateway.ts | 95 ++++++-- 3 files changed, 292 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index b06737ec..0f45cd13 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -927,6 +927,221 @@ pub async fn delete_oauth_client( Ok(()) } +// ============================================================================= +// API-key clients (manually registered, host-issued credentials) +// +// A "preregistered", pre-approved inbound client authenticated by a long-lived +// API key. Unlike DCR clients it skips the browser-consent deep link, so +// headless/remote clients can connect with just the key — the secure path when +// the gateway is exposed over the network. +// ============================================================================= + +/// A newly-registered API-key client. `api_key` is returned ONCE at creation — +/// McpMux stores only its SHA-256 hash and can never show it again. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisteredApiKeyClient { + pub client_id: String, + pub client_name: String, + pub api_key: String, + pub key_prefix: String, +} + +/// API-key metadata for display (never includes the secret). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiKeyInfo { + pub key_id: String, + pub key_prefix: String, + pub label: Option, + pub revoked: bool, + pub last_used_at: Option, + pub created_at: String, +} + +/// Generate a strong API key: `mcpk_` + 256 bits of v4-UUID randomness. +/// Returns `(key_id, plaintext, key_prefix)`. Only the hash is ever stored. +fn generate_api_key() -> (String, String, String) { + let key_id = uuid::Uuid::new_v4().to_string(); + let secret = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let plaintext = format!("mcpk_{secret}"); + let key_prefix: String = plaintext.chars().take(13).collect(); // "mcpk_" + 8 chars + (key_id, plaintext, key_prefix) +} + +/// Register a new pre-approved client authenticated by an API key. The returned +/// `api_key` is shown once and never stored. +#[tauri::command] +pub async fn register_api_key_client( + gateway_state: State<'_, Arc>>, + name: String, +) -> Result { + let app_state = gateway_state.read().await; + let Some(ref gw_state) = app_state.gateway_state else { + return Err("Gateway not running".to_string()); + }; + let state = gw_state.read().await; + let Some(repo) = state.inbound_client_repository() else { + return Err("Database not available".to_string()); + }; + + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Client name is required".to_string()); + } + + let now = chrono::Utc::now().to_rfc3339(); + let client_id = format!("mcp_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); + let client = mcpmux_storage::InboundClient { + client_id: client_id.clone(), + registration_type: mcpmux_storage::RegistrationType::Preregistered, + client_name: trimmed.to_string(), + client_alias: None, + redirect_uris: vec![], + grant_types: vec![], + response_types: vec![], + token_endpoint_auth_method: "none".to_string(), + scope: None, + approved: true, + logo_uri: None, + client_uri: None, + software_id: None, + software_version: None, + metadata_url: None, + metadata_cached_at: None, + metadata_cache_ttl: None, + last_seen: None, + created_at: now.clone(), + updated_at: now, + reports_roots: false, + roots_capability_known: false, + }; + repo.save_client(&client) + .await + .map_err(|e| format!("Failed to create client: {}", e))?; + + let (key_id, plaintext, key_prefix) = generate_api_key(); + repo.create_api_key(&key_id, &client_id, &plaintext, &key_prefix, None, None) + .await + .map_err(|e| format!("Failed to create API key: {}", e))?; + + info!( + "[OAuth] Registered API-key client {} ({})", + trimmed, client_id + ); + + Ok(RegisteredApiKeyClient { + client_id, + client_name: trimmed.to_string(), + api_key: plaintext, + key_prefix, + }) +} + +/// Issue an additional API key for an existing client (rotation). Returns the +/// new key plaintext once. +#[tauri::command] +pub async fn create_client_api_key( + gateway_state: State<'_, Arc>>, + client_id: String, + label: Option, +) -> Result { + let app_state = gateway_state.read().await; + let Some(ref gw_state) = app_state.gateway_state else { + return Err("Gateway not running".to_string()); + }; + let state = gw_state.read().await; + let Some(repo) = state.inbound_client_repository() else { + return Err("Database not available".to_string()); + }; + + let Some(client) = repo + .get_client(&client_id) + .await + .map_err(|e| format!("Failed to load client: {}", e))? + else { + return Err("Client not found".to_string()); + }; + + let (key_id, plaintext, key_prefix) = generate_api_key(); + repo.create_api_key( + &key_id, + &client_id, + &plaintext, + &key_prefix, + label.as_deref(), + None, + ) + .await + .map_err(|e| format!("Failed to create API key: {}", e))?; + + Ok(RegisteredApiKeyClient { + client_id, + client_name: client.client_name, + api_key: plaintext, + key_prefix, + }) +} + +/// List a client's API keys (metadata only — never the secret). +#[tauri::command] +pub async fn list_client_api_keys( + gateway_state: State<'_, Arc>>, + client_id: String, +) -> Result, String> { + let app_state = gateway_state.read().await; + let Some(ref gw_state) = app_state.gateway_state else { + return Err("Gateway not running".to_string()); + }; + let state = gw_state.read().await; + let Some(repo) = state.inbound_client_repository() else { + return Err("Database not available".to_string()); + }; + + let keys = repo + .list_api_keys(&client_id) + .await + .map_err(|e| format!("Failed to list API keys: {}", e))?; + + Ok(keys + .into_iter() + .map(|k| ApiKeyInfo { + key_id: k.key_id, + key_prefix: k.key_prefix, + label: k.label, + revoked: k.revoked, + last_used_at: k.last_used_at, + created_at: k.created_at, + }) + .collect()) +} + +/// Revoke a single API key (it can never authenticate again). +#[tauri::command] +pub async fn revoke_client_api_key( + gateway_state: State<'_, Arc>>, + key_id: String, +) -> Result<(), String> { + let app_state = gateway_state.read().await; + let Some(ref gw_state) = app_state.gateway_state else { + return Err("Gateway not running".to_string()); + }; + let state = gw_state.read().await; + let Some(repo) = state.inbound_client_repository() else { + return Err("Database not available".to_string()); + }; + + repo.revoke_api_key(&key_id) + .await + .map_err(|e| format!("Failed to revoke API key: {}", e))?; + info!("[OAuth] Revoked API key {}", key_id); + Ok(()) +} + /// Open a URL without flashing a terminal window (Windows-specific) #[cfg(target_os = "windows")] fn open_url_no_flash(url: &str) -> Result<(), String> { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5cde1a72..0b995284 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -984,6 +984,10 @@ pub fn run() { commands::approve_oauth_client, commands::update_oauth_client, commands::delete_oauth_client, + commands::register_api_key_client, + commands::create_client_api_key, + commands::list_client_api_keys, + commands::revoke_client_api_key, commands::open_url, // Per-client grants for the rootless fallback path commands::get_oauth_client_grants, diff --git a/apps/desktop/src/lib/api/gateway.ts b/apps/desktop/src/lib/api/gateway.ts index b7c2737b..da96fda8 100644 --- a/apps/desktop/src/lib/api/gateway.ts +++ b/apps/desktop/src/lib/api/gateway.ts @@ -151,10 +151,7 @@ export async function restartGateway(opts?: { /** * Export config for a client. */ -export async function exportConfig( - format: ExportFormat, - clientId?: string -): Promise { +export async function exportConfig(format: ExportFormat, clientId?: string): Promise { return invoke('export_config', { format, clientId }); } @@ -181,7 +178,11 @@ export async function connectServer(serverId: string): Promise { * @param spaceId - The space ID (required for proper space isolation) * @param logout - If true, also delete stored credentials (OAuth tokens) */ -export async function disconnectServer(serverId: string, spaceId: string, logout?: boolean): Promise { +export async function disconnectServer( + serverId: string, + spaceId: string, + logout?: boolean +): Promise { return invoke('disconnect_server', { serverId, spaceId, logout }); } @@ -199,13 +200,13 @@ export type RegistrationType = 'cimd' | 'dcr' | 'preregistered'; /** * Inbound client (unified OAuth + MCP model) - * + * * Represents apps connecting TO McpMux (e.g., Cursor, VS Code, Claude Desktop). * Supports three MCP registration approaches: * - CIMD: Client ID Metadata Documents (client_id is a URL) * - DCR: Dynamic Client Registration (server generates client_id) * - Preregistered: Server pre-configures client_id - * + * * Per RFC 7591, clients self-identify via metadata they provide. * Use `logo_uri`, `software_id`, and `client_name` for client identification. */ @@ -216,20 +217,20 @@ export interface OAuthClient { client_alias: string | null; redirect_uris: string[]; scope: string | null; - + // Approval status - true if user has explicitly approved this client approved: boolean; - + // RFC 7591 Client Metadata (use these for client identification) - logo_uri?: string | null; // URL for client's logo - client_uri?: string | null; // URL of client's homepage - software_id?: string | null; // Unique identifier (e.g., "com.cursor.app") - software_version?: string | null; // Client software version - + logo_uri?: string | null; // URL for client's logo + client_uri?: string | null; // URL of client's homepage + software_id?: string | null; // Unique identifier (e.g., "com.cursor.app") + software_version?: string | null; // Client software version + // CIMD-specific fields (only used when registration_type='cimd') - metadata_url?: string | null; // URL where metadata was fetched - metadata_cached_at?: string | null; // When we last fetched - metadata_cache_ttl?: number | null; // Cache duration in seconds + metadata_url?: string | null; // URL where metadata was fetched + metadata_cached_at?: string | null; // When we last fetched + metadata_cache_ttl?: number | null; // Cache duration in seconds last_seen: string | null; created_at: string; @@ -302,10 +303,7 @@ export async function deleteOAuthClient(clientId: string): Promise { * means the rootless fallback would deny — consumer should render the * "no defaults configured" empty state. */ -export async function getOAuthClientGrants( - clientId: string, - spaceId: string -): Promise { +export async function getOAuthClientGrants(clientId: string, spaceId: string): Promise { return invoke('get_oauth_client_grants', { clientId, spaceId }); } @@ -340,6 +338,59 @@ export async function revokeOAuthClientFeatureSet( }); } +// ============================================================================= +// API-key clients (manually registered, host-issued credentials) +// ============================================================================= +// +// A pre-approved inbound client authenticated by a long-lived API key. Skips +// the browser-consent deep link, so headless/remote clients can connect with +// just the key — the secure path when the gateway is exposed over the network. + +/** A newly-registered API-key client. `apiKey` is shown ONCE — store it now. */ +export interface RegisteredApiKeyClient { + clientId: string; + clientName: string; + /** The full key — shown once; afterwards only its hash is kept. */ + apiKey: string; + keyPrefix: string; +} + +/** API-key metadata for display (never the secret). */ +export interface ApiKeyInfo { + keyId: string; + keyPrefix: string; + label: string | null; + revoked: boolean; + lastUsedAt: string | null; + createdAt: string; +} + +/** + * Register a pre-approved client authenticated by an API key. The returned key + * is shown once and never retrievable again. + */ +export async function registerApiKeyClient(name: string): Promise { + return invoke('register_api_key_client', { name }); +} + +/** Issue an additional API key for an existing client (rotation). Shown once. */ +export async function createClientApiKey( + clientId: string, + label?: string | null +): Promise { + return invoke('create_client_api_key', { clientId, label: label ?? null }); +} + +/** List a client's API keys (metadata only — never the secret). */ +export async function listClientApiKeys(clientId: string): Promise { + return invoke('list_client_api_keys', { clientId }); +} + +/** Revoke an API key (it can never authenticate again). */ +export async function revokeClientApiKey(keyId: string): Promise { + return invoke('revoke_client_api_key', { keyId }); +} + /** * Result of bulk server connection. */ @@ -394,7 +445,7 @@ export async function refreshOAuthTokensOnStartup(): Promise { /** * Open a URL using the system's default handler. - * + * * This is needed for custom protocol URLs (like `cursor://`) that * the webview's opener plugin may not be allowed to open directly. */ From bc47fdd1ab13d1233a258b21be43775ff0997e89 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 29 Jun 2026 10:41:02 +0800 Subject: [PATCH 04/19] feat(ui): Register API-key client modal (Clients tab) Adds a modal to mint a pre-approved API-key client from the Clients tab: enter a name, generate a key shown once (mcpk_ + 256 bits), copy it, and an explainer of how the client authenticates with `Authorization: Bearer `. The "Lock to a Space" selector is deferred to P2 (Strategy Y) along with the rest of the locked_space wiring; P1 registers an unconfined API-key client. Signed-off-by: Mohammod Al Amin Ashik --- .../src/features/clients/ClientsPage.tsx | 36 ++- .../clients/RegisterApiKeyClientModal.tsx | 209 ++++++++++++++++++ 2 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index 84e15a32..c2c625c0 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -23,6 +23,7 @@ import { Check, Globe, ShieldOff, + KeyRound, } from 'lucide-react'; import { ConnectIDEs } from '@/components/ConnectIDEs'; import type { GatewayStatus, OAuthClient } from '@/lib/api/gateway'; @@ -55,6 +56,7 @@ import { usePendingClientId, useSetPendingClientId, } from '@/stores'; +import { RegisterApiKeyClientModal } from './RegisterApiKeyClientModal'; // Bundled icons for well-known AI clients. const CLIENT_ICON_ASSETS: Record = { @@ -124,6 +126,7 @@ export default function ClientsPage() { const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [selected, setSelected] = useState(null); + const [showRegister, setShowRegister] = useState(false); const [editAlias, setEditAlias] = useState(''); const [isSaving, setIsSaving] = useState(false); const [gatewayStatus, setGatewayStatus] = useState({ @@ -283,10 +286,21 @@ export default function ClientsPage() { } actions={ - +
+ + +
} /> @@ -409,6 +423,16 @@ export default function ClientsPage() { )} + {showRegister && ( + setShowRegister(false)} + onRegistered={(client) => { + success(`Registered "${client.clientName}" with an API key.`); + void refreshClients(); + }} + /> + )} + {ConfirmDialogElement} @@ -582,7 +606,9 @@ function SidePanel({ When this client reports a folder as an MCP root, mcpmux uses the matching Workspace 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{' '} + + Connect apps to this folder + {' '} to auto-write its config with a workspace header.

+
+ +
+ + {result ? 'API key created' : 'Register client (API key)'} + + + {result + ? 'Copy the key now — this is the only time it will be shown.' + : 'A pre-authorised client that connects with an API key instead of browser approval. Use this for headless, CI, or remote clients reaching the gateway over the network.'} + + + + + {result ? ( + <> +
+ +
+ + {result.apiKey} + + +
+
+ +
+ +

+ Store this key in your client now. McpMux keeps only a hash and{' '} + cannot show it again. If you lose it, revoke the key and create a + new one. +

+
+ +
+

+ How the client authenticates +

+ + Authorization: Bearer {result.keyPrefix}… + +
+ +
+ +
+ + ) : ( + <> +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !isSubmitting) void handleGenerate(); + }} + placeholder="e.g. CI runner, my-laptop, prod-bot" + className="w-full rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-3.5 py-2.5 text-sm transition-all focus:border-[rgb(var(--accent))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]/40" + /> +
+ +
+ +

+ The key is generated on this machine, shown once, and stored only as a SHA-256 + hash. The client then sends it as a Bearer token — no approval prompt needed. +

+
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+ + )} +
+ + + ); +} From 557653e35c71d9f6164f5597fcafd9c78a6760d9 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 19:58:10 +0800 Subject: [PATCH 05/19] feat(ui): manage a client's API keys in the side panel Phase 1. For preregistered (API-key) clients, the Apps side panel now lists the client's keys (prefix + last-used, never the secret) with per-key revoke and a "New key" rotation button that reveals the freshly-minted key once. Wired to list_client_api_keys / create_client_api_key / revoke_client_api_key. Signed-off-by: Mohammod Al Amin Ashik --- .../features/clients/ClientApiKeysSection.tsx | 180 ++++++++++++++++++ .../src/features/clients/ClientsPage.tsx | 9 + 2 files changed, 189 insertions(+) create mode 100644 apps/desktop/src/features/clients/ClientApiKeysSection.tsx diff --git a/apps/desktop/src/features/clients/ClientApiKeysSection.tsx b/apps/desktop/src/features/clients/ClientApiKeysSection.tsx new file mode 100644 index 00000000..dbea0c15 --- /dev/null +++ b/apps/desktop/src/features/clients/ClientApiKeysSection.tsx @@ -0,0 +1,180 @@ +/** + * API keys for a preregistered (API-key) client — rendered in the client side + * panel. Lists the client's keys (prefix + metadata, never the secret), and + * lets the user revoke a key or mint a new one (rotation). A freshly-minted key + * is shown ONCE inline. + */ + +import { useEffect, useState } from 'react'; +import { AlertTriangle, Check, Copy, Loader2, Plus, Trash2 } from 'lucide-react'; +import { Button } from '@mcpmux/ui'; +import { + createClientApiKey, + listClientApiKeys, + revokeClientApiKey, + type ApiKeyInfo, + type RegisteredApiKeyClient, +} from '@/lib/api/gateway'; + +interface ClientApiKeysSectionProps { + clientId: string; + onError: (title: string, body?: string) => void; + onSuccess: (title: string, body?: string) => void; +} + +export function ClientApiKeysSection({ clientId, onError, onSuccess }: ClientApiKeysSectionProps) { + const [keys, setKeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [revokingId, setRevokingId] = useState(null); + const [newKey, setNewKey] = useState(null); + const [copied, setCopied] = useState(false); + + const load = async () => { + setIsLoading(true); + try { + setKeys(await listClientApiKeys(clientId)); + } catch (e) { + onError('Failed to load API keys', e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clientId]); + + const handleCreate = async () => { + setIsCreating(true); + try { + const issued = await createClientApiKey(clientId); + setNewKey(issued); + setCopied(false); + await load(); + } catch (e) { + onError('Failed to create key', e instanceof Error ? e.message : String(e)); + } finally { + setIsCreating(false); + } + }; + + const handleCopy = async () => { + if (!newKey) return; + try { + await navigator.clipboard.writeText(newKey.apiKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard can be unavailable; the field is selectable as a fallback. + } + }; + + const handleRevoke = async (keyId: string) => { + setRevokingId(keyId); + try { + await revokeClientApiKey(keyId); + onSuccess('Key revoked', 'It can no longer authenticate.'); + await load(); + } catch (e) { + onError('Failed to revoke key', e instanceof Error ? e.message : String(e)); + } finally { + setRevokingId(null); + } + }; + + const liveKeys = keys.filter((k) => !k.revoked); + + return ( +
+
+

+ API keys +

+ +
+ + {newKey && ( +
+
+ +

+ Copy this key now — it won't be shown again. +

+
+
+ + {newKey.apiKey} + + +
+
+ )} + + {isLoading ? ( +
+ +
+ ) : liveKeys.length === 0 ? ( +

+ No active keys. Create one so this client can authenticate. +

+ ) : ( +
    + {liveKeys.map((k) => ( +
  • +
    + {k.keyPrefix}… +

    + {k.lastUsedAt + ? `Last used ${new Date(k.lastUsedAt).toLocaleDateString()}` + : 'Never used'} +

    +
    + +
  • + ))} +
+ )} + +

+ This client authenticates with an API key as a Bearer token. Keys are stored hashed — revoke + a leaked one and mint a new key. +

+
+ ); +} diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index c2c625c0..2d5138fb 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -57,6 +57,7 @@ import { useSetPendingClientId, } from '@/stores'; import { RegisterApiKeyClientModal } from './RegisterApiKeyClientModal'; +import { ClientApiKeysSection } from './ClientApiKeysSection'; // Bundled icons for well-known AI clients. const CLIENT_ICON_ASSETS: Record = { @@ -595,6 +596,14 @@ function SidePanel({

+ {client.registration_type === 'preregistered' && ( + + )} +
From d2844bbcf541a61012195cf9c0fadf3db3c1343c Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 21:10:02 +0800 Subject: [PATCH 06/19] test(gateway): api-key auth path through the real middleware Boots /mcp behind the real mcp_oauth_middleware (auth required), pre-registers a Preregistered client + key over the same DB, and proves: a live Bearer key authenticates and injects the owning client id; an unknown key 401s; a revoked key 401s. Signed-off-by: Mohammod Al Amin Ashik --- .../tests/streamable_http/api_key_auth.rs | 249 ++++++++++++++++++ tests/rust/tests/streamable_http/mod.rs | 1 + 2 files changed, 250 insertions(+) create mode 100644 tests/rust/tests/streamable_http/api_key_auth.rs diff --git a/tests/rust/tests/streamable_http/api_key_auth.rs b/tests/rust/tests/streamable_http/api_key_auth.rs new file mode 100644 index 00000000..745648c2 --- /dev/null +++ b/tests/rust/tests/streamable_http/api_key_auth.rs @@ -0,0 +1,249 @@ +//! End-to-end proof that the gateway authenticates a request bearing a +//! host-issued **API key** through the REAL `mcp_oauth_middleware`: +//! - a live key in `Authorization: Bearer mcpk_…` is accepted (200) and the +//! middleware injects the owning client's id, +//! - an unknown key is rejected (401), +//! - a revoked key is rejected (401). +//! +//! This is the headless/remote auth path that needs no interactive consent — +//! the secure way to connect when the gateway is exposed over the network. + +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::{ + InboundClient, InboundClientRepository, RegistrationType, 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 authenticated and assigned the right 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, + client_repo: Arc, + client_id: String, + api_key: String, + key_id: String, + ct: CancellationToken, +} + +impl Harness { + /// Boot a gateway exposing `/mcp` behind the REAL oauth middleware with auth + /// REQUIRED, and pre-register a Preregistered client + one API key over the + /// same database the middleware validates against. + async fn start() -> 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"); + + // Register a Preregistered, approved client + an API key over the SAME + // database Arc the gateway's middleware reads from, so a write here is + // visible to the middleware's own repo instance. + let client_repo = Arc::new(InboundClientRepository::new(database.clone())); + let client_id = format!("mcp_{}", &Uuid::new_v4().simple().to_string()[..8]); + let now = chrono::Utc::now().to_rfc3339(); + let client = InboundClient { + client_id: client_id.clone(), + registration_type: RegistrationType::Preregistered, + client_name: "headless-bot".to_string(), + client_alias: None, + redirect_uris: vec![], + grant_types: vec![], + response_types: vec![], + token_endpoint_auth_method: "none".to_string(), + scope: None, + approved: true, + logo_uri: None, + client_uri: None, + software_id: None, + software_version: None, + metadata_url: None, + metadata_cached_at: None, + metadata_cache_ttl: None, + last_seen: None, + created_at: now.clone(), + updated_at: now, + reports_roots: false, + roots_capability_known: false, + }; + client_repo.save_client(&client).await.expect("save client"); + let key_id = Uuid::new_v4().to_string(); + let api_key = format!("mcpk_{}", Uuid::new_v4().simple()); + let prefix: String = api_key.chars().take(13).collect(); + client_repo + .create_api_key(&key_id, &client_id, &api_key, &prefix, None, None) + .await + .expect("create api key"); + + 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()); + gw_state.set_auth_disabled(false); // auth REQUIRED — the key must carry it + 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"), + client_repo, + client_id, + api_key, + key_id, + ct, + } + } + + async fn post_with_bearer(&self, token: &str) -> reqwest::Response { + reqwest::Client::new() + .post(&self.url) + .header("content-type", "application/json") + .header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")) + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#) + .send() + .await + .expect("request") + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.ct.cancel(); + } +} + +#[tokio::test] +async fn api_key_authenticates_and_injects_client_id() { + let h = Harness::start().await; + let resp = h.post_with_bearer(&h.api_key).await; + assert_eq!( + resp.status(), + reqwest::StatusCode::OK, + "a live API key must authenticate against an auth-required gateway" + ); + let body = resp.text().await.unwrap(); + assert_eq!( + body, h.client_id, + "the middleware injects the key's owning client id" + ); +} + +#[tokio::test] +async fn unknown_api_key_is_rejected() { + let h = Harness::start().await; + let resp = h.post_with_bearer("mcpk_not_a_real_key").await; + assert_eq!( + resp.status(), + reqwest::StatusCode::UNAUTHORIZED, + "an unknown key must be rejected" + ); +} + +#[tokio::test] +async fn revoked_api_key_is_rejected() { + let h = Harness::start().await; + // Sanity: it authenticates before revocation. + assert_eq!( + h.post_with_bearer(&h.api_key).await.status(), + reqwest::StatusCode::OK + ); + h.client_repo + .revoke_api_key(&h.key_id) + .await + .expect("revoke"); + let resp = h.post_with_bearer(&h.api_key).await; + assert_eq!( + resp.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a revoked key must be rejected" + ); +} diff --git a/tests/rust/tests/streamable_http/mod.rs b/tests/rust/tests/streamable_http/mod.rs index ea942c25..cdba3fc8 100644 --- a/tests/rust/tests/streamable_http/mod.rs +++ b/tests/rust/tests/streamable_http/mod.rs @@ -5,6 +5,7 @@ //! - Server-initiated notifications (list_changed via SSE) //! - Proper protocol negotiation +mod api_key_auth; mod auth_disable; mod auth_oauth_e2e; mod gateway_notifications; From fb67e0166da3d030b161d033664d5c4adb4ba1d7 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 20:57:16 +0800 Subject: [PATCH 07/19] feat: generalized id mappings + lock-confine resolver (P2) Mappings are now keyed by a folder PATH or an arbitrary ID string (BindingType; migration 021). The FeatureSet resolver implements the full precedence: - locked client -> Space is ALWAYS the locked one; the header only selects a FeatureSet *within* it (a foreign-Space header is ignored -> locked Starter). - unlocked -> header (path/id) > clientId-keyed binding > default-Space Starter. Adds binding_type to the domain + repo (find_by_id_key + path-scoped find_exact_for_roots), get/set_locked_space, and migration 022 for inbound_clients.locked_space_id (fixes P1 lock-to-space, which referenced a column that did not yet exist). The binding Tauri commands accept id mappings. Covered by storage + resolver-precedence tests (31 resolver cases green, incl. id-binding, clientId routing, and all three lock-confine cases). Signed-off-by: Mohammod Al Amin Ashik --- .../src/commands/workspace_binding.rs | 55 ++++-- crates/mcpmux-core/src/domain/mod.rs | 2 +- .../src/domain/workspace_binding.rs | 45 +++++ crates/mcpmux-core/src/repository/mod.rs | 6 + .../src/services/feature_set_resolver.rs | 104 ++++++++++- crates/mcpmux-storage/src/database.rs | 10 ++ .../src/migrations/021_binding_type.sql | 11 ++ .../022_inbound_client_locked_space.sql | 9 + .../workspace_binding_repository.rs | 75 +++++++- .../tests/integration/feature_set_resolver.rs | 167 ++++++++++++++++++ 10 files changed, 458 insertions(+), 26 deletions(-) create mode 100644 crates/mcpmux-storage/src/migrations/021_binding_type.sql create mode 100644 crates/mcpmux-storage/src/migrations/022_inbound_client_locked_space.sql diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index f62cc07e..4879d131 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -8,8 +8,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use mcpmux_core::{ - validate_workspace_root as validate_root, DomainEvent, FeatureSet, FeatureSetType, MemberMode, - MemberType, ServerFeature, WorkspaceBinding, WorkspaceRootValidation, + validate_workspace_root as validate_root, BindingType, DomainEvent, FeatureSet, FeatureSetType, + MemberMode, MemberType, ServerFeature, WorkspaceBinding, WorkspaceRootValidation, }; use serde::{Deserialize, Serialize}; use tauri::State; @@ -54,6 +54,8 @@ async fn emit_binding_changed( pub struct WorkspaceBindingDto { pub id: String, pub workspace_root: String, + /// `path` (folder, normalized) or `id` (arbitrary exact-match key). + pub binding_type: String, pub space_id: String, pub feature_set_ids: Vec, pub created_at: String, @@ -65,6 +67,7 @@ impl From for WorkspaceBindingDto { Self { id: b.id.to_string(), workspace_root: b.workspace_root, + binding_type: b.binding_type.as_str().to_string(), space_id: b.space_id.to_string(), feature_set_ids: b.feature_set_ids, created_at: b.created_at.to_rfc3339(), @@ -83,6 +86,10 @@ pub struct WorkspaceBindingInput { pub workspace_root: String, pub space_id: String, pub feature_set_ids: Vec, + /// `path` (default — folder, normalized + validated) or `id` (arbitrary + /// exact-match key, taken verbatim). Optional for backward compatibility. + #[serde(default)] + pub binding_type: Option, } fn parse_space_id(input: &WorkspaceBindingInput) -> Result { @@ -229,6 +236,26 @@ fn normalize_and_validate(raw: &str) -> Result { } } +/// Resolve the storage key + type from the input. `path` bindings are +/// normalized + validated (rejecting relative paths, filesystem roots, …); +/// `id` bindings take the raw string verbatim (any non-empty label a headless +/// client sends in `X-Mcpmux-Workspace`, e.g. a client id or machine name). +fn resolve_key_and_type(input: &WorkspaceBindingInput) -> Result<(String, BindingType), String> { + match input.binding_type.as_deref() { + Some("id") => { + let key = input.workspace_root.trim(); + if key.is_empty() { + return Err("Mapping id cannot be empty".into()); + } + Ok((key.to_string(), BindingType::Id)) + } + _ => Ok(( + normalize_and_validate(&input.workspace_root)?, + BindingType::Path, + )), + } +} + /// Create a binding. Path is normalized + validated server-side so the UI /// can pass raw input (Windows paths, file:// URIs, trailing slashes). #[tauri::command] @@ -239,9 +266,9 @@ pub async fn create_workspace_binding( ) -> Result { let space_id = parse_space_id(&input)?; let feature_set_ids = validate_fs_list(&input)?; - let normalized = normalize_and_validate(&input.workspace_root)?; + let (key, binding_type) = resolve_key_and_type(&input)?; - // Reject a duplicate folder up front with a readable message. The schema + // Reject a duplicate key up front with a readable message. The schema // already enforces `UNIQUE(workspace_root)`, but that surfaces an opaque // SQLite constraint error — this gives the UI something a user can act on. let existing = state @@ -249,13 +276,16 @@ pub async fn create_workspace_binding( .list() .await .map_err(|e| e.to_string())?; - if existing.iter().any(|b| b.workspace_root == normalized) { + if existing.iter().any(|b| b.workspace_root == key) { return Err(format!( - "A mapping already exists for {normalized}. Edit the existing mapping instead of adding a second one." + "A mapping already exists for {key}. Edit the existing mapping instead of adding a second one." )); } - let binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids); + let binding = match binding_type { + BindingType::Id => WorkspaceBinding::new_id(key.clone(), space_id, feature_set_ids), + BindingType::Path => WorkspaceBinding::new_multi(key.clone(), space_id, feature_set_ids), + }; state .workspace_binding_repository @@ -292,9 +322,9 @@ pub async fn update_workspace_binding( let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; let space_id = parse_space_id(&input)?; let feature_set_ids = validate_fs_list(&input)?; - let normalized = normalize_and_validate(&input.workspace_root)?; + let (key, binding_type) = resolve_key_and_type(&input)?; - // If the edit moved the folder onto a path another mapping already owns, + // If the edit moved the mapping onto a key another mapping already owns, // reject with a readable message rather than tripping the DB UNIQUE // constraint. Exclude this binding's own row. let all = state @@ -304,10 +334,10 @@ pub async fn update_workspace_binding( .map_err(|e| e.to_string())?; if all .iter() - .any(|b| b.id != id_uuid && b.workspace_root == normalized) + .any(|b| b.id != id_uuid && b.workspace_root == key) { return Err(format!( - "Another mapping already uses {normalized}. Pick a different folder." + "Another mapping already uses {key}. Pick a different key." )); } @@ -321,7 +351,8 @@ pub async fn update_workspace_binding( let updated = WorkspaceBinding { id: existing.id, - workspace_root: normalized, + workspace_root: key, + binding_type, space_id, feature_set_ids, created_at: existing.created_at, diff --git a/crates/mcpmux-core/src/domain/mod.rs b/crates/mcpmux-core/src/domain/mod.rs index 83d97def..092d6c0f 100644 --- a/crates/mcpmux-core/src/domain/mod.rs +++ b/crates/mcpmux-core/src/domain/mod.rs @@ -39,5 +39,5 @@ pub use server_log::*; pub use space::*; pub use workspace_binding::{ longest_matching_base, normalize_workspace_root, path_is_within, validate_workspace_root, - WorkspaceBinding, WorkspaceRootValidation, + BindingType, WorkspaceBinding, WorkspaceRootValidation, }; diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs index 8260dfc8..60a33079 100644 --- a/crates/mcpmux-core/src/domain/workspace_binding.rs +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -25,6 +25,38 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// How a binding's `workspace_root` key is matched against a request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum BindingType { + /// A normalized absolute folder path (the original behaviour). Matched + /// case-/separator-insensitively via [`normalize_workspace_root`]. + #[default] + Path, + /// An arbitrary exact-match string — a client id, machine name, or any + /// label a headless/remote client sends in `X-Mcpmux-Workspace`. Matched + /// verbatim (no path normalization). + Id, +} + +impl BindingType { + pub fn as_str(&self) -> &'static str { + match self { + BindingType::Path => "path", + BindingType::Id => "id", + } + } + + /// Parse from storage; unknown values fall back to `Path` (the default for + /// pre-`binding_type` rows). + pub fn parse(s: &str) -> Self { + match s { + "id" => BindingType::Id, + _ => BindingType::Path, + } + } +} + /// A binding between a normalized workspace root and the FeatureSet(s) it /// resolves to. `feature_set_ids` MAY be empty — an empty list is a valid /// "no Space tools" mapping (the folder still routes to this Space; built-in @@ -33,6 +65,8 @@ use uuid::Uuid; pub struct WorkspaceBinding { pub id: Uuid, pub workspace_root: String, + /// Whether `workspace_root` is a filesystem path or an arbitrary id key. + pub binding_type: BindingType, pub space_id: Uuid, /// Order matters for UI rendering only — the resolver treats them as /// a set. Stored in the `workspace_binding_feature_sets` junction @@ -63,12 +97,23 @@ impl WorkspaceBinding { Self { id: Uuid::new_v4(), workspace_root: workspace_root.into(), + binding_type: BindingType::Path, space_id, feature_set_ids, created_at: now, updated_at: now, } } + + /// Construct an **id-keyed** binding: `key` is matched verbatim (exact + /// string, no path normalization) rather than as a folder. Used to route + /// headless/remote clients by a client id or an arbitrary label. + pub fn new_id(key: impl Into, space_id: Uuid, feature_set_ids: Vec) -> Self { + Self { + binding_type: BindingType::Id, + ..Self::new_multi(key, space_id, feature_set_ids) + } + } } // ============================================================================ diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index 5d73e2de..a5afe96e 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -276,6 +276,12 @@ pub trait WorkspaceBindingRepository: Send + Sync { &self, candidate_roots: &[String], ) -> RepoResult>; + + /// Resolve an **id-keyed** binding by exact-string match (no path + /// normalization). Used to route headless/remote clients by a client id or + /// an arbitrary label sent in `X-Mcpmux-Workspace`. Only `BindingType::Id` + /// bindings are considered, so a folder path can never collide with a label. + async fn find_by_id_key(&self, key: &str) -> RepoResult>; } /// Credential repository trait (local-only, never synced) diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs index 7bad3a75..4ebace39 100644 --- a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -95,7 +95,8 @@ use std::time::Duration; use anyhow::Result; use mcpmux_core::{ - FeatureSetRepository, SpaceBaseDirRepository, SpaceRepository, WorkspaceBindingRepository, + FeatureSetRepository, SpaceBaseDirRepository, SpaceRepository, WorkspaceBinding, + WorkspaceBindingRepository, }; use mcpmux_storage::InboundClientRepository; use serde::Serialize; @@ -281,6 +282,65 @@ impl FeatureSetResolverService { }) } + /// Resolve a binding from header/root candidate keys: a **path** binding + /// (exact normalized match) or, failing that, an **id** binding (exact + /// verbatim match — a client id or arbitrary label). First hit wins. Path + /// and id bindings live in disjoint namespaces in the repo, so this never + /// double-matches. + async fn mapping_binding_for_roots( + &self, + roots: &[String], + ) -> Result> { + if let Some(b) = self.binding_repo.find_exact_for_roots(roots).await? { + return Ok(Some(b)); + } + for r in roots { + if let Some(b) = self.binding_repo.find_by_id_key(r).await? { + return Ok(Some(b)); + } + } + Ok(None) + } + + /// Resolve a client locked to Space `locked`. The Space is fixed to + /// `locked`; the header/roots may still pick the FeatureSet, but only when + /// their binding lives in `locked`. A header whose binding resolves to a + /// different Space — or no header at all — falls back to `locked`'s Starter. + /// A locked client never touches the roots-pending or client-grant tiers. + async fn resolve_locked( + &self, + session_id: Option<&str>, + locked: Uuid, + ) -> Result { + if let Some(sid) = session_id { + if let Some(roots) = self.session_roots.get(sid) { + if !roots.is_empty() { + if let Some(binding) = self.mapping_binding_for_roots(&roots).await? { + if binding.space_id == locked { + debug!( + %locked, + workspace_root = %binding.workspace_root, + "[FeatureSetResolver] locked client — header binding within locked Space", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: binding.feature_set_ids, + space_id: Some(locked), + source: ResolutionSource::WorkspaceBinding, + }); + } + debug!( + %locked, + binding_space = %binding.space_id, + "[FeatureSetResolver] locked client — header binding in a different Space; ignored", + ); + } + } + } + } + // No header, or its binding is outside the locked Space → locked Starter. + self.default_fallback(locked).await + } + /// Borrow the session-roots registry. The notifier uses this to GC /// dead sessions out of the registry when reaping the corresponding /// peer entries — keeping both stores in sync. @@ -311,6 +371,23 @@ impl FeatureSetResolverService { } }; + // Lock-confine: a client locked to Space L only ever resolves to L. The + // header/roots may still pick a FeatureSet *within* L; a binding that + // resolves to a different Space — or no header — yields L's Starter. + // Bypasses the roots-pending and grant tiers entirely. + if let Some(cid) = client_id { + if let Some(locked) = self.client_repo.get_locked_space(cid).await? { + match locked.parse::() { + Ok(locked_uuid) => return self.resolve_locked(session_id, locked_uuid).await, + Err(e) => warn!( + client_id = %cid, + locked_space = %locked, + "[FeatureSetResolver] client locked to unparseable space id: {e}", + ), + } + } + } + // Tier 1 / 1b / 1c — branches on roots-capable + roots-arrived state. if let Some(sid) = session_id { let roots = self.session_roots.get(sid); @@ -345,11 +422,9 @@ impl FeatureSetResolverService { // (no ancestor inheritance). if has_roots { let reported_roots = roots.expect("has_roots implies Some"); - if let Some(binding) = self - .binding_repo - .find_exact_for_roots(&reported_roots) - .await? - { + // Exact binding match: a path binding (normalized folder) or an + // id binding (verbatim label / client-id sent in the header). + if let Some(binding) = self.mapping_binding_for_roots(&reported_roots).await? { debug!( workspace_root = %binding.workspace_root, space_id = %binding.space_id, @@ -433,6 +508,23 @@ impl FeatureSetResolverService { // (the desktop UI's preview HTTP path lands here too). Consult the // per-client grant table. if let Some(cid) = client_id { + // clientId-keyed mapping (auto-created for API-key clients): when no + // header/roots selected a binding above, route by the client's own + // id. Editor clients have no such binding and fall through to grants. + if let Some(binding) = self.binding_repo.find_by_id_key(cid).await? { + debug!( + client_id = %cid, + workspace_root = %binding.workspace_root, + space_id = %binding.space_id, + "[FeatureSetResolver] resolved via clientId mapping", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: binding.feature_set_ids, + space_id: Some(binding.space_id), + source: ResolutionSource::WorkspaceBinding, + }); + } + // Propagate storage errors instead of treating them as "no // grants": a transient DB failure must surface as a request // error, not a silent deny (which would also record a `None` diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index 93fc27d7..1b5dffd7 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -133,6 +133,16 @@ const MIGRATIONS: &[Migration] = &[ name: "inbound_client_api_keys", sql: include_str!("migrations/020_inbound_client_api_keys.sql"), }, + Migration { + version: 21, + name: "binding_type", + sql: include_str!("migrations/021_binding_type.sql"), + }, + Migration { + version: 22, + name: "inbound_client_locked_space", + sql: include_str!("migrations/022_inbound_client_locked_space.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/021_binding_type.sql b/crates/mcpmux-storage/src/migrations/021_binding_type.sql new file mode 100644 index 00000000..90379850 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/021_binding_type.sql @@ -0,0 +1,11 @@ +-- Migration 021: workspace binding type (path vs id) +-- +-- Generalizes mappings beyond filesystem folders. A binding's `workspace_root` +-- is now a routing KEY that is either: +-- * 'path' — a normalized absolute folder path (the original behaviour), or +-- * 'id' — an arbitrary exact-match string (a client id, machine name, or +-- any label a headless/remote client sends in X-Mcpmux-Workspace). +-- Existing rows are folder paths, so they default to 'path' (backward +-- compatible). Path keys are normalized + case-folded before comparison; +-- id keys are matched verbatim. +ALTER TABLE workspace_bindings ADD COLUMN binding_type TEXT NOT NULL DEFAULT 'path'; diff --git a/crates/mcpmux-storage/src/migrations/022_inbound_client_locked_space.sql b/crates/mcpmux-storage/src/migrations/022_inbound_client_locked_space.sql new file mode 100644 index 00000000..afd91a31 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/022_inbound_client_locked_space.sql @@ -0,0 +1,9 @@ +-- Migration 022: lock an inbound client to a single Space +-- +-- An API-key (or any pre-registered) client may be confined to one Space. When +-- set, the FeatureSet resolver always resolves this client to `locked_space_id`, +-- ignoring an X-Mcpmux-Workspace header that points at a *different* Space (the +-- header may still select a FeatureSet *within* the locked Space). NULL = +-- unlocked (the default) — the client routes freely by header / clientId +-- mapping / default Space. +ALTER TABLE inbound_clients ADD COLUMN locked_space_id TEXT; diff --git a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs index 25f07268..c9848103 100644 --- a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs +++ b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs @@ -32,7 +32,7 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mcpmux_core::{WorkspaceBinding, WorkspaceBindingRepository}; +use mcpmux_core::{BindingType, WorkspaceBinding, WorkspaceBindingRepository}; use rusqlite::params; use tokio::sync::Mutex; use uuid::Uuid; @@ -67,10 +67,12 @@ impl SqliteWorkspaceBindingRepository { let space_id_str: String = row.get(2)?; let created_at: String = row.get(3)?; let updated_at: String = row.get(4)?; + let binding_type: String = row.get(5)?; Ok(WorkspaceBinding { id: id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), workspace_root, + binding_type: BindingType::parse(&binding_type), space_id: space_id_str.parse().unwrap_or_else(|_| Uuid::nil()), feature_set_ids: Vec::new(), // filled in by caller created_at: Self::parse_datetime(&created_at), @@ -149,7 +151,8 @@ impl SqliteWorkspaceBindingRepository { Ok(()) } - const SELECT_COLS: &'static str = "id, workspace_root, space_id, created_at, updated_at"; + const SELECT_COLS: &'static str = + "id, workspace_root, space_id, created_at, updated_at, binding_type"; /// Fetch bindings + their FeatureSet lists in two queries. /// `where_clause` is appended to the binding SELECT (use `""` for none); @@ -221,14 +224,15 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let tx = conn.unchecked_transaction()?; tx.execute( "INSERT INTO workspace_bindings - (id, workspace_root, space_id, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5)", + (id, workspace_root, space_id, created_at, updated_at, binding_type) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ binding.id.to_string(), binding.workspace_root, binding.space_id.to_string(), binding.created_at.to_rfc3339(), binding.updated_at.to_rfc3339(), + binding.binding_type.as_str(), ], )?; Self::rewrite_fs_for_binding(&tx, &binding.id.to_string(), &binding.feature_set_ids)?; @@ -248,13 +252,14 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let tx = conn.unchecked_transaction()?; let rows_affected = tx.execute( "UPDATE workspace_bindings - SET workspace_root = ?2, space_id = ?3, updated_at = ?4 + SET workspace_root = ?2, space_id = ?3, updated_at = ?4, binding_type = ?5 WHERE id = ?1", params![ binding.id.to_string(), binding.workspace_root, binding.space_id.to_string(), binding.updated_at.to_rfc3339(), + binding.binding_type.as_str(), ], )?; @@ -292,14 +297,29 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let bindings = self.list().await?; // Exact match only — no ancestor/prefix inheritance. A folder resolves - // to a binding for THAT exact root, or to nothing. + // to a binding for THAT exact root, or to nothing. Only PATH bindings + // participate here; id-keyed bindings are matched via `find_by_id_key` + // so a folder root can never collide with an arbitrary id label. for root in candidate_roots { - if let Some(b) = bindings.iter().find(|b| &b.workspace_root == root) { + if let Some(b) = bindings + .iter() + .find(|b| b.binding_type == BindingType::Path && &b.workspace_root == root) + { return Ok(Some(b.clone())); } } Ok(None) } + + async fn find_by_id_key(&self, key: &str) -> Result> { + if key.is_empty() { + return Ok(None); + } + let bindings = self.list().await?; + Ok(bindings + .into_iter() + .find(|b| b.binding_type == BindingType::Id && b.workspace_root == key)) + } } #[cfg(test)] @@ -506,4 +526,45 @@ mod tests { .expect("exact match"); assert_eq!(hit.workspace_root, exact); } + + #[tokio::test] + async fn test_id_binding_matches_by_exact_key_not_as_path() { + // An id-keyed binding is matched verbatim via find_by_id_key and is + // invisible to the path-based find_exact_for_roots (and vice versa) so a + // folder root and an arbitrary id label can never collide. + let (repo, space_id, fs_id) = fixture().await; + let key = "mcp_abc12345"; // e.g. a client id + let binding = WorkspaceBinding::new_id(key, space_id, vec![fs_id.clone()]); + repo.create(&binding).await.unwrap(); + + // Exact id lookup hits and round-trips the type + key. + let got = repo.find_by_id_key(key).await.unwrap().expect("id match"); + assert_eq!(got.binding_type, BindingType::Id); + assert_eq!(got.workspace_root, key); + assert_eq!(got.feature_set_ids, vec![fs_id.clone()]); + + // The path resolver ignores id bindings. + assert!(repo + .find_exact_for_roots(&[key.to_string()]) + .await + .unwrap() + .is_none()); + + // A path binding is invisible to the id lookup, but visible to the + // path resolver. + let path_root = if cfg!(windows) { + "d:\\idtest" + } else { + "/idtest" + }; + repo.create(&WorkspaceBinding::new(path_root, space_id, fs_id)) + .await + .unwrap(); + assert!(repo.find_by_id_key(path_root).await.unwrap().is_none()); + assert!(repo + .find_exact_for_roots(&[path_root.to_string()]) + .await + .unwrap() + .is_some()); + } } diff --git a/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs index b9bf33e5..c934a81f 100644 --- a/tests/rust/tests/integration/feature_set_resolver.rs +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -808,3 +808,170 @@ async fn pinned_header_root_without_binding_falls_back_to_space_default() { assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); assert_eq!(r.space_id, Some(f.space_id)); } + +// --------------------------------------------------------------------------- +// Generalized mappings (id-keyed bindings) + clientId routing + lock-confine +// (P2). Precedence (unlocked): header > clientId-binding > Space default. +// Locked: Space is ALWAYS the locked one; the header only picks the FeatureSet +// when its binding lives in that Space, else the locked Space's Starter. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn id_binding_routes_by_header_value() { + // A header that is an arbitrary label (not a folder) routes via an id-keyed + // binding: the pinned header shadows roots and matches the id verbatim. + let f = Fixture::new().await; + f.binding_repo + .create(&WorkspaceBinding::new_id( + "team-x", + f.space_id, + vec![f.fs_a_id.clone()], + )) + .await + .unwrap(); + f.session_roots.set_pinned("s", "team-x"); + let r = f + .resolver + .resolve(Some("s"), Some("client-1")) + .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 client_id_binding_routes_when_no_header() { + // No header/roots: an unlocked client routes by its own clientId via an + // id-binding keyed by the client id (auto-created on registration). + let f = Fixture::new().await; + f.make_client("mcp_abc").await; + f.binding_repo + .create(&WorkspaceBinding::new_id( + "mcp_abc", + f.space_id, + vec![f.fs_b_id.clone()], + )) + .await + .unwrap(); + // Explicitly rootless so we skip the pending grace and reach the clientId tier. + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some("mcp_abc")) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.feature_set_ids, vec![f.fs_b_id]); +} + +#[tokio::test] +async fn header_beats_client_id_binding_when_both_present() { + // Unlocked precedence: an explicit header outranks the clientId binding. + let f = Fixture::new().await; + f.make_client("mcp_abc").await; + f.binding_repo + .create(&WorkspaceBinding::new_id( + "mcp_abc", + f.space_id, + vec![f.fs_b_id.clone()], + )) + .await + .unwrap(); + f.binding_repo + .create(&WorkspaceBinding::new_id( + "team-x", + f.space_id, + vec![f.fs_a_id.clone()], + )) + .await + .unwrap(); + f.session_roots.set_pinned("s", "team-x"); + let r = f + .resolver + .resolve(Some("s"), Some("mcp_abc")) + .await + .unwrap(); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id]); // header's FS, not clientId's +} + +#[tokio::test] +async fn locked_client_uses_header_fs_when_binding_is_in_locked_space() { + // A locked client whose header binding lives IN the locked Space uses that + // binding's FeatureSet — the Space stays locked, the FeatureSet is selectable. + let f = Fixture::new().await; + f.make_client("locked-2").await; + f.client_repo + .set_locked_space("locked-2", Some(&f.space_id.to_string())) + .await + .unwrap(); + f.binding_repo + .create(&WorkspaceBinding::new_id( + "inhouse", + f.space_id, + vec![f.fs_a_id.clone()], + )) + .await + .unwrap(); + f.session_roots.set_pinned("s", "inhouse"); + let r = f + .resolver + .resolve(Some("s"), Some("locked-2")) + .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 locked_client_ignores_foreign_header_and_uses_locked_starter() { + // A client locked to Space L, sending a header whose binding lives in a + // DIFFERENT Space, is confined to L and falls back to L's Starter. + let f = Fixture::new().await; + f.make_client("locked-1").await; + let other_base = if cfg!(windows) { "d:\\other" } else { "/other" }; + let (other_space, other_starter) = f.make_space_with_base_dir("Other", other_base).await; + f.client_repo + .set_locked_space("locked-1", Some(&f.space_id.to_string())) + .await + .unwrap(); + // Header binding points at the OTHER space. + f.binding_repo + .create(&WorkspaceBinding::new_id( + "foreign", + other_space, + vec![other_starter], + )) + .await + .unwrap(); + f.session_roots.set_pinned("s", "foreign"); + let r = f + .resolver + .resolve(Some("s"), Some("locked-1")) + .await + .unwrap(); + // Confined to the locked (default) Space; the foreign header is ignored. + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id]); +} + +#[tokio::test] +async fn locked_client_with_no_header_gets_locked_space_starter() { + let f = Fixture::new().await; + f.make_client("locked-3").await; + f.client_repo + .set_locked_space("locked-3", Some(&f.space_id.to_string())) + .await + .unwrap(); + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some("locked-3")) + .await + .unwrap(); + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id]); +} From b43ee0bf1d00ee50d140b1100fdbe4472ff695ec Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 29 Jun 2026 10:50:35 +0800 Subject: [PATCH 08/19] feat(P2): restore lock-to-Space wiring deferred from P1 (Strategy Y) Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src-tauri/src/commands/oauth.rs | 19 ++++++- .../clients/RegisterApiKeyClientModal.tsx | 50 +++++++++++++++++-- apps/desktop/src/lib/api/gateway.ts | 12 +++-- .../repositories/inbound_client_repository.rs | 30 +++++++++++ 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index 0f45cd13..bc939e76 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -943,6 +943,7 @@ pub async fn delete_oauth_client( pub struct RegisteredApiKeyClient { pub client_id: String, pub client_name: String, + pub locked_space_id: Option, pub api_key: String, pub key_prefix: String, } @@ -973,12 +974,13 @@ fn generate_api_key() -> (String, String, String) { (key_id, plaintext, key_prefix) } -/// Register a new pre-approved client authenticated by an API key. The returned -/// `api_key` is shown once and never stored. +/// Register a new pre-approved client authenticated by an API key, optionally +/// locked to a Space. The returned `api_key` is shown once and never stored. #[tauri::command] pub async fn register_api_key_client( gateway_state: State<'_, Arc>>, name: String, + locked_space_id: Option, ) -> Result { let app_state = gateway_state.read().await; let Some(ref gw_state) = app_state.gateway_state else { @@ -1024,6 +1026,12 @@ pub async fn register_api_key_client( .await .map_err(|e| format!("Failed to create client: {}", e))?; + if let Some(ref space) = locked_space_id { + repo.set_locked_space(&client_id, Some(space)) + .await + .map_err(|e| format!("Failed to lock client to space: {}", e))?; + } + let (key_id, plaintext, key_prefix) = generate_api_key(); repo.create_api_key(&key_id, &client_id, &plaintext, &key_prefix, None, None) .await @@ -1037,6 +1045,7 @@ pub async fn register_api_key_client( Ok(RegisteredApiKeyClient { client_id, client_name: trimmed.to_string(), + locked_space_id, api_key: plaintext, key_prefix, }) @@ -1079,9 +1088,15 @@ pub async fn create_client_api_key( .await .map_err(|e| format!("Failed to create API key: {}", e))?; + let locked_space_id = repo + .get_locked_space(&client_id) + .await + .map_err(|e| format!("Failed to read client: {}", e))?; + Ok(RegisteredApiKeyClient { client_id, client_name: client.client_name, + locked_space_id, api_key: plaintext, key_prefix, }) diff --git a/apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx b/apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx index f55ff91d..e207143e 100644 --- a/apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx +++ b/apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx @@ -10,10 +10,11 @@ * never display it again — if lost, revoke it and issue a new one. */ -import { useState } from 'react'; -import { AlertTriangle, Check, Copy, KeyRound, Loader2, ShieldCheck, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { AlertTriangle, Check, Copy, KeyRound, Loader2, Lock, ShieldCheck, X } from 'lucide-react'; import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@mcpmux/ui'; import { registerApiKeyClient, type RegisteredApiKeyClient } from '@/lib/api/gateway'; +import { listSpaces, type Space } from '@/lib/api/spaces'; interface RegisterApiKeyClientModalProps { onClose: () => void; @@ -26,11 +27,23 @@ export function RegisterApiKeyClientModal({ onRegistered, }: RegisterApiKeyClientModalProps) { const [name, setName] = useState(''); + const [lockedSpaceId, setLockedSpaceId] = useState(''); + const [spaces, setSpaces] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); const [copied, setCopied] = useState(false); + useEffect(() => { + listSpaces() + .then(setSpaces) + .catch(() => setSpaces([])); + }, []); + + const lockedSpaceName = result?.lockedSpaceId + ? (spaces.find((s) => s.id === result.lockedSpaceId)?.name ?? 'a Space') + : null; + const handleGenerate = async () => { const trimmed = name.trim(); if (!trimmed) { @@ -40,7 +53,7 @@ export function RegisterApiKeyClientModal({ setIsSubmitting(true); setError(null); try { - const client = await registerApiKeyClient(trimmed); + const client = await registerApiKeyClient(trimmed, lockedSpaceId || null); setResult(client); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -130,6 +143,13 @@ export function RegisterApiKeyClientModal({ Authorization: Bearer {result.keyPrefix}… + {lockedSpaceName && ( +

+ + Locked to {lockedSpaceName} — this key can + only ever reach that Space. +

+ )}
@@ -159,6 +179,30 @@ export function RegisterApiKeyClientModal({ />
+
+ + +

+ Locking confines this client to one Space — a leaked key can never reach the + others. Leave unlocked to route it later from the Workspaces tab. +

+
+

diff --git a/apps/desktop/src/lib/api/gateway.ts b/apps/desktop/src/lib/api/gateway.ts index da96fda8..9eba1d3f 100644 --- a/apps/desktop/src/lib/api/gateway.ts +++ b/apps/desktop/src/lib/api/gateway.ts @@ -350,6 +350,7 @@ export async function revokeOAuthClientFeatureSet( export interface RegisteredApiKeyClient { clientId: string; clientName: string; + lockedSpaceId: string | null; /** The full key — shown once; afterwards only its hash is kept. */ apiKey: string; keyPrefix: string; @@ -366,11 +367,14 @@ export interface ApiKeyInfo { } /** - * Register a pre-approved client authenticated by an API key. The returned key - * is shown once and never retrievable again. + * Register a pre-approved client authenticated by an API key, optionally locked + * to a space. The returned key is shown once and never retrievable again. */ -export async function registerApiKeyClient(name: string): Promise { - return invoke('register_api_key_client', { name }); +export async function registerApiKeyClient( + name: string, + lockedSpaceId?: string | null +): Promise { + return invoke('register_api_key_client', { name, lockedSpaceId: lockedSpaceId ?? null }); } /** Issue an additional API key for an existing client (rotation). Shown once. */ diff --git a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs index f20b353a..30d00023 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs @@ -707,6 +707,36 @@ impl InboundClientRepository { Ok(()) } + /// Set (or clear, with `None`) the Space a client is locked to. A locked + /// client is confined to that Space during resolution (see the gateway + /// FeatureSet resolver). + pub async fn set_locked_space(&self, client_id: &str, space_id: Option<&str>) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + let db = self.db.lock().await; + let conn = db.connection(); + conn.execute( + "UPDATE inbound_clients SET locked_space_id = ?1, updated_at = ?2 WHERE client_id = ?3", + params![space_id, now, client_id], + )?; + Ok(()) + } + + /// The Space a client is locked to, if any. + pub async fn get_locked_space(&self, client_id: &str) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + let result = conn.query_row( + "SELECT locked_space_id FROM inbound_clients WHERE client_id = ?1", + params![client_id], + |r| r.get::<_, Option>(0), + ); + match result { + Ok(v) => Ok(v), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + /// Save a token record pub async fn save_token(&self, record: &TokenRecord) -> Result<()> { let db = self.db.lock().await; From a69fe2fa1c187123c45ba63e84a2b9b2cac77367 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 21:00:59 +0800 Subject: [PATCH 09/19] feat(P2): auto-map API-key clients + binding_type in the TS API register_api_key_client now best-effort auto-creates a clientId-keyed id mapping to the (locked or default) Space's Starter, so a new client routes sensibly out of the box and the mapping is visible + editable in the Mapping tab. Also exposes binding_type on the WorkspaceBinding TS type + create/update input. Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src-tauri/src/commands/oauth.rs | 48 +++++++++++++++++++ apps/desktop/src/lib/api/workspaceBindings.ts | 8 ++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index bc939e76..6f21298f 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -37,6 +37,7 @@ use tracing::{debug, error, info, warn}; use url::Url; use super::gateway::GatewayAppState; +use crate::state::AppState; // ============================================================================ // Deep Link Handling @@ -979,6 +980,7 @@ fn generate_api_key() -> (String, String, String) { #[tauri::command] pub async fn register_api_key_client( gateway_state: State<'_, Arc>>, + app: State<'_, AppState>, name: String, locked_space_id: Option, ) -> Result { @@ -1042,6 +1044,18 @@ pub async fn register_api_key_client( trimmed, client_id ); + // Best-effort: auto-create a clientId-keyed mapping → the (locked or + // default) Space's Starter, so the client routes sensibly out of the box + // and the mapping is visible + editable in the Mapping tab. A failure here + // must not undo the registration — without an explicit mapping the resolver + // still falls back to the default Starter. + if let Err(e) = auto_map_api_key_client(&app, &client_id, locked_space_id.as_deref()).await { + warn!( + "[OAuth] auto-map for {} failed (non-fatal): {}", + client_id, e + ); + } + Ok(RegisteredApiKeyClient { client_id, client_name: trimmed.to_string(), @@ -1051,6 +1065,40 @@ pub async fn register_api_key_client( }) } +/// Auto-create a clientId-keyed `id` mapping pointing at the (locked or +/// default) Space's Starter FeatureSet, so a freshly-registered API-key client +/// routes somewhere sensible by default and the operator can retarget it from +/// the Mapping tab. +async fn auto_map_api_key_client( + app: &AppState, + client_id: &str, + locked_space_id: Option<&str>, +) -> Result<(), String> { + let space_id = match locked_space_id { + Some(s) => uuid::Uuid::parse_str(s).map_err(|e| e.to_string())?, + None => { + app.space_service + .get_default() + .await + .map_err(|e| e.to_string())? + .ok_or("no default Space configured")? + .id + } + }; + let starter = app + .feature_set_repository + .get_starter_for_space(&space_id.to_string()) + .await + .map_err(|e| e.to_string())? + .ok_or("Space has no Starter FeatureSet")?; + let binding = + mcpmux_core::WorkspaceBinding::new_id(client_id.to_string(), space_id, vec![starter.id]); + app.workspace_binding_repository + .create(&binding) + .await + .map_err(|e| e.to_string()) +} + /// Issue an additional API key for an existing client (rotation). Returns the /// new key plaintext once. #[tauri::command] diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts index ebe8cdf2..8c463096 100644 --- a/apps/desktop/src/lib/api/workspaceBindings.ts +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -10,6 +10,8 @@ import { invoke } from '@tauri-apps/api/core'; export interface WorkspaceBinding { id: string; workspace_root: string; + /** `path` (a normalized folder) or `id` (an arbitrary exact-match key). */ + binding_type: 'path' | 'id'; space_id: string; /** * Non-empty by construction. Order is the operator-chosen rendering @@ -26,6 +28,8 @@ export interface WorkspaceBindingInput { workspace_root: string; space_id: string; feature_set_ids: string[]; + /** `path` (default — folder, normalized) or `id` (verbatim exact-match key). */ + binding_type?: 'path' | 'id'; } /** List every binding (sorted by workspace_root). */ @@ -69,9 +73,7 @@ export async function validateWorkspaceRoot(path: string): Promise { } /** List bindings whose target Space is the given one. */ -export async function listWorkspaceBindingsForSpace( - spaceId: string -): Promise { +export async function listWorkspaceBindingsForSpace(spaceId: string): Promise { return invoke('list_workspace_bindings_for_space', { spaceId }); } From 5fd4230fb58d74c22831fae16e3bd9f9e5d2367a Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 21:28:24 +0800 Subject: [PATCH 10/19] feat(P2): create + edit id mappings in the Mapping tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binding form gains a Folder / ID toggle: an "ID" mapping takes any verbatim label (a client id, machine name, …) instead of a folder path — no path validation — and carries binding_type through create + edit, so an auto-mapped clientId binding is editable without being re-validated as a filesystem path. Signed-off-by: Mohammod Al Amin Ashik --- .../features/workspaces/WorkspacesPage.tsx | 539 ++++++++---------- 1 file changed, 239 insertions(+), 300 deletions(-) diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 0c677aa6..f7ec6372 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -23,14 +23,7 @@ import { Wrench, X, } from 'lucide-react'; -import { - Button, - Card, - CardContent, - useToast, - ToastContainer, - useConfirm, -} from '@mcpmux/ui'; +import { Button, Card, CardContent, useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; import { clearUnmappedReportedRoots, createWorkspaceBinding, @@ -45,11 +38,7 @@ import { type WorkspaceBindingInput, type WorkspaceEffectiveFeatures, } from '@/lib/api/workspaceBindings'; -import { - isStarterFeatureSet, - listFeatureSets, - type FeatureSet, -} from '@/lib/api/featureSets'; +import { isStarterFeatureSet, listFeatureSets, type FeatureSet } from '@/lib/api/featureSets'; import { WorkspaceInstallPanel } from './WorkspaceInstallPanel'; import { WorkspaceSetupWizard } from './WorkspaceSetupWizard'; import { useSpaces, usePendingWorkspaceNew, useSetPendingWorkspaceNew } from '@/stores'; @@ -220,11 +209,9 @@ export function WorkspacesPage() { if (filter === 'mapped' && !e.binding) return false; if (filter === 'unmapped' && e.kind !== 'unmapped-live') return false; if (!q) return true; - const spaceName = e.binding ? spaceById.get(e.binding.space_id)?.name ?? '' : ''; + const spaceName = e.binding ? (spaceById.get(e.binding.space_id)?.name ?? '') : ''; const fsNames = e.binding - ? e.binding.feature_set_ids - .map((id) => fsById.get(id)?.name ?? '') - .join(' ') + ? e.binding.feature_set_ids.map((id) => fsById.get(id)?.name ?? '').join(' ') : ''; return ( e.root.toLowerCase().includes(q) || @@ -247,7 +234,7 @@ export function WorkspacesPage() { }, [entries]); const selectedEntry: Entry | null = - selected?.mode === 'entry' ? entries.find((e) => e.id === selected.id) ?? null : null; + selected?.mode === 'entry' ? (entries.find((e) => e.id === selected.id) ?? null) : null; const selectedIsNew = selected?.mode === 'new'; const panelOpen = selected !== null; @@ -311,32 +298,27 @@ export function WorkspacesPage() { cleared > 0 ? "You'll be asked to map them again next time." : undefined ); } catch (e) { - showError( - 'Could not clear unmapped folders', - e instanceof Error ? e.message : String(e) - ); + showError('Could not clear unmapped folders', e instanceof Error ? e.message : String(e)); } }; return ( -

-
-
-
+
+
+
+

Workspaces

-

- Map a folder to the tools it should get. When you open that - folder in a connected app — Cursor, VS Code, Claude — McpMux - serves exactly the tools you chose for it. Folders you - haven't mapped fall back to your default Starter set, so - they work out of the box — map one only when it should see - something different. +

+ Map a folder to the tools it should get. When you open that folder in a connected + app — Cursor, VS Code, Claude — McpMux serves exactly the tools you chose for it. + Folders you haven't mapped fall back to your default Starter set, so they work + out of the box — map one only when it should see something different.

-
+
-
-
- +
+
+ setSearchQuery(e.target.value)} - className="w-full pl-12 pr-4 py-3 text-base bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all" + className="focus:ring-primary-500 focus:border-primary-500 w-full rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] py-3 pl-12 pr-4 text-base transition-all focus:outline-none focus:ring-2" data-testid="workspace-binding-search" />
@@ -391,7 +373,7 @@ export function WorkspacesPage() { className="whitespace-nowrap text-amber-600 hover:bg-amber-50 hover:text-amber-700 dark:text-amber-400 dark:hover:bg-amber-900/20" data-testid="workspaces-clear-unmapped" > - + Clear unmapped )} @@ -401,17 +383,17 @@ export function WorkspacesPage() { {error && (
-
+
{error}
)}
-
+
{isLoading ? ( -
- +
+
) : filtered.length === 0 ? ( setSelected({ mode: 'new' })} /> ) : ( -
+
{filtered.map((entry) => { - const isSelected = - selected?.mode === 'entry' && selected.id === entry.id; + const isSelected = selected?.mode === 'entry' && selected.id === entry.id; // Mapped entries show their bound Space + FeatureSet names. // Unmapped entries read "Not mapped" — they fall back to the // default Starter set rather than to an explicit binding. @@ -431,9 +412,7 @@ export function WorkspacesPage() { ? spaceById.get(entry.binding.space_id)?.name : undefined; const fsNames = entry.binding - ? entry.binding.feature_set_ids.map( - (id) => fsById.get(id)?.name ?? id - ) + ? entry.binding.feature_set_ids.map((id) => fsById.get(id)?.name ?? id) : []; return (
setSelected(null)} /> {selectedIsNew ? ( @@ -548,7 +527,7 @@ function SegmentedFilter({ options: Array<{ value: T; label: string; count?: number }>; }) { return ( -
+
{options.map((o) => { const active = o.value === value; return ( @@ -559,7 +538,7 @@ function SegmentedFilter({ data-testid={`workspace-filter-${o.value}`} aria-pressed={active} className={[ - 'inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all', + 'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all', active ? 'bg-[rgb(var(--background))] text-[rgb(var(--foreground))] shadow-sm' : 'text-[rgb(var(--muted))] hover:text-[rgb(var(--foreground))]', @@ -568,7 +547,7 @@ function SegmentedFilter({ {o.label} {typeof o.count === 'number' && ( void; }) { const tone = - entry.kind === 'unmapped-live' - ? 'amber' - : entry.kind === 'mapped-live' - ? 'emerald' - : 'neutral'; + entry.kind === 'unmapped-live' ? 'amber' : entry.kind === 'mapped-live' ? 'emerald' : 'neutral'; const t = CARD_TONES[tone]; const name = folderName(entry.root); return ( - {entry.isLive ? ( - - ) : ( - - )} + {entry.isLive ? : }
{entry.isLive && ( {name} -

+

{entry.root}

@@ -704,7 +672,7 @@ function EntryCard({ {entry.binding ? (
- + {fsNames.length > 1 && ( {fsNames.length} @@ -752,27 +720,21 @@ function Pill({ : 'bg-[rgb(var(--surface))] text-[rgb(var(--muted))] border-[rgb(var(--border-subtle))]'; return ( {children} ); } -function Chip({ - children, - tone, -}: { - children: React.ReactNode; - tone: 'primary' | 'neutral'; -}) { +function Chip({ children, tone }: { children: React.ReactNode; tone: 'primary' | 'neutral' }) { const styles = tone === 'primary' ? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 border-primary-200 dark:border-primary-800/60' : 'bg-[rgb(var(--surface))] border-[rgb(var(--border-subtle))] text-[rgb(var(--foreground))]'; return ( {children} @@ -804,8 +766,7 @@ const SECTION_TONES: Record = { primary: { gradientOpen: 'bg-gradient-to-r from-primary-50 to-primary-100/50 dark:from-primary-900/20 dark:to-primary-800/10', - iconQuiet: - 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400', + iconQuiet: 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400', iconActive: 'bg-primary-500 text-white shadow-sm shadow-primary-500/30', badgeOpen: 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 border border-primary-300/70 dark:border-primary-700/70', @@ -813,8 +774,7 @@ const SECTION_TONES: Record = { purple: { gradientOpen: 'bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/15', - iconQuiet: - 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400', + iconQuiet: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400', iconActive: 'bg-purple-500 text-white shadow-sm shadow-purple-500/30', badgeOpen: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-300/70 dark:border-purple-700/70', @@ -848,41 +808,37 @@ function CollapsibleSection({ return (
{open && ( -
+
{children}
)} @@ -958,14 +912,8 @@ function InspectorPanel({ : isMapped ? 'edit' : 'create-from-live'; - const title = isNew - ? 'New mapping' - : isMapped - ? 'Workspace mapping' - : 'Map this folder'; - const subtitle = isNew - ? 'Choose the tools a folder should get.' - : entry?.root ?? ''; + const title = isNew ? 'New mapping' : isMapped ? 'Workspace mapping' : 'Map this folder'; + const subtitle = isNew ? 'Choose the tools a folder should get.' : (entry?.root ?? ''); // Auto-save status drives the small pill in the Mapping section header. const [saveStatus, setSaveStatus] = useState({ kind: 'idle' }); @@ -975,22 +923,24 @@ function InspectorPanel({ const [effectiveTotal, setEffectiveTotal] = useState(null); return ( -
-
+
+
-
-
+
+
-
-
+
+
{!isNew && entry?.isLive && Live} {!isNew && entry && !isMapped && Unmapped} - {!isNew && entry && isMapped && !entry.isLive && Offline} + {!isNew && entry && isMapped && !entry.isLive && ( + Offline + )}
-

{title}

+

{title}

{subtitle} @@ -999,7 +949,7 @@ function InspectorPanel({

-
+
} tone="primary" @@ -1024,9 +974,7 @@ function InspectorPanel({ (id) => featureSets.find((f) => f.id === id)?.name ?? id ) ) || '—' - } from ${ - spaces.find((s) => s.id === entry.binding!.space_id)?.name ?? '—' - }` + } from ${spaces.find((s) => s.id === entry.binding!.space_id)?.name ?? '—'}` : 'Edit what this folder sees, then press Apply.' } defaultOpen={isNew || !isMapped} @@ -1070,24 +1018,21 @@ function InspectorPanel({ badge={effectiveTotal ?? undefined} testId="workspace-effective-features-section" > - + )}
{entry?.binding && ( -
+
@@ -1103,7 +1048,7 @@ function SaveStatusPill({ status }: { status: SaveStatus }) { if (status.kind === 'saving') { return ( Saving @@ -1113,7 +1058,7 @@ function SaveStatusPill({ status }: { status: SaveStatus }) { if (status.kind === 'saved') { return ( Saved @@ -1122,7 +1067,7 @@ function SaveStatusPill({ status }: { status: SaveStatus }) { } return ( @@ -1160,9 +1105,7 @@ function buildServerGroups(data: WorkspaceEffectiveFeatures): ServerGroup[] { let g = map.get(item.server_id); if (!g) { const totals = data.server_totals[item.server_id]; - const server_total = totals - ? totals.tools + totals.prompts + totals.resources - : 0; + const server_total = totals ? totals.tools + totals.prompts + totals.resources : 0; g = { server_id: item.server_id, server_alias: item.server_alias ?? item.server_id, @@ -1298,8 +1241,8 @@ function EffectiveFeaturesContent({ } if (error) { return ( -
- +
+ {error}
); @@ -1313,12 +1256,12 @@ function EffectiveFeaturesContent({
{/* Resolution summary — bold pills showing what this folder resolves to, plus a progress bar for availability. */} -
-
+
+
Resolves to - + {formatFsList(data.feature_sets.map((fs) => fs.name)) || '—'} in @@ -1332,10 +1275,10 @@ function EffectiveFeaturesContent({ : 'No binding matches this folder, so it falls back to the default Starter set shown here. Map it to give this folder a different set.' } className={[ - 'ml-auto text-[10px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wider border', + 'ml-auto rounded-full border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider', data.source === 'binding' - ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border-purple-300/70 dark:border-purple-700/70' - : 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border-amber-300/70 dark:border-amber-700/70', + ? 'border-purple-300/70 bg-purple-100 text-purple-700 dark:border-purple-700/70 dark:bg-purple-900/30 dark:text-purple-300' + : 'border-amber-300/70 bg-amber-100 text-amber-700 dark:border-amber-700/70 dark:bg-amber-900/30 dark:text-amber-300', ].join(' ')} > {data.source === 'binding' ? 'binding' : 'unbound'} @@ -1346,7 +1289,7 @@ function EffectiveFeaturesContent({ are connected, leans amber when some are dim. */}
- + {availableCount} of {totalCount} @@ -1367,7 +1310,7 @@ function EffectiveFeaturesContent({ )}
-
+
- +
+

No features configured in this feature set yet.

) : ( -
+
{groups.map((g) => (
-
+
{open ? ( - + ) : ( - + )} - -
-
+ +
+
{prefix && ( - - {prefix}. - + {prefix}. )} - - {displayName} - + {displayName} {group.mapped}/{denominator} @@ -1479,12 +1414,12 @@ function ServerGroupRow({ {issue && ( {issue.label} @@ -1493,7 +1428,7 @@ function ServerGroupRow({
{/* Per-server progress bar — same treatment as FeatureSetPanel's server rows so the visual language is consistent. */} -
+
0 - ? `${(availableCount / group.mapped) * 100}%` - : '0%', + width: group.mapped > 0 ? `${(availableCount / group.mapped) * 100}%` : '0%', }} />
@@ -1518,7 +1450,7 @@ function ServerGroupRow({
{open && ( -
+
@@ -1547,33 +1479,33 @@ function FeatureSubGroup({
{getFeatureTypeIcon(label)} -
-
- +
+
+ {item.display_name || item.feature_name} {label} {!item.available && ( - + unavailable )}
{item.description && ( -

+

{item.description}

)} @@ -1587,11 +1519,11 @@ function FeatureSubGroup({ function getFeatureTypeIcon(type: 'tool' | 'prompt' | 'resource') { switch (type) { case 'tool': - return ; + return ; case 'prompt': - return ; + return ; case 'resource': - return ; + return ; } } @@ -1676,6 +1608,10 @@ function BindingForm({ // their members into one allow set). Order is preserved so the operator // can rank a "primary" FS first; the resolver itself doesn't care. const [fsIds, setFsIds] = useState(initial?.feature_set_ids ?? []); + // A mapping is keyed by a folder path OR an arbitrary id/label. The type is + // chosen at create time and fixed thereafter (an id never becomes a folder). + const [bindingType, setBindingType] = useState<'path' | 'id'>(initial?.binding_type ?? 'path'); + const isId = bindingType === 'id'; const [fsSearch, setFsSearch] = useState(''); const [submitting, setSubmitting] = useState(false); const isEdit = mode === 'edit'; @@ -1708,6 +1644,11 @@ function BindingForm({ setRootValidation({ state: 'ok', normalized: root }); return; } + if (isId) { + // Id keys are matched verbatim — skip filesystem-path validation. + setRootValidation(root.trim() ? { state: 'ok', normalized: root.trim() } : { state: 'idle' }); + return; + } if (!root.trim()) { setRootValidation({ state: 'idle' }); return; @@ -1724,15 +1665,11 @@ function BindingForm({ .catch((e: unknown) => { if (validationSeq.current !== seq) return; const reason = typeof e === 'string' ? e : String(e); - setRootValidation( - reason === '' - ? { state: 'idle' } - : { state: 'error', reason } - ); + setRootValidation(reason === '' ? { state: 'idle' } : { state: 'error', reason }); }); }, 180); return () => clearTimeout(handle); - }, [root, rootEditable]); + }, [root, rootEditable, isId]); useEffect(() => { if (mode === 'create') rootRef.current?.focus(); @@ -1779,17 +1716,14 @@ function BindingForm({ }, [availableFs]); const toggleFs = (id: string) => { - setFsIds((prev) => - prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] - ); + setFsIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; const trimmedRoot = root.trim(); // The canonical form the server will store. We prefer the validator's // normalized output (drive-letter case, slash direction, trailing slash // all settled) so the duplicate check matches exactly what a save writes. - const effectiveRoot = - rootValidation.state === 'ok' ? rootValidation.normalized : trimmedRoot; + const effectiveRoot = rootValidation.state === 'ok' ? rootValidation.normalized : trimmedRoot; // Has this folder already been mapped? Compare against every saved mapping // (case-insensitively, the app's notion of "same folder"), excluding the @@ -1830,15 +1764,19 @@ function BindingForm({ const handleSubmit = async () => { if (!root.trim()) { - onError('Pick a folder first.'); + onError(isId ? 'Enter an id or label.' : 'Pick a folder first.'); return; } - if (rootValidation.state === 'error') { + if (!isId && rootValidation.state === 'error') { onError(rootValidation.reason); return; } if (duplicate) { - onError(`That folder is already mapped. Open the existing mapping to change it.`); + onError( + isId + ? 'That id is already mapped. Open its existing mapping to change it.' + : `That folder is already mapped. Open the existing mapping to change it.` + ); return; } if (!spaceId) { @@ -1856,6 +1794,7 @@ function BindingForm({ workspace_root: root.trim(), space_id: spaceId, feature_set_ids: fsIds, + binding_type: bindingType, }); onSaveStatusChange?.({ kind: 'saved' }); savedTimerRef.current = setTimeout(() => { @@ -1887,15 +1826,34 @@ function BindingForm({ {/* Plain-language primer for anyone who's never seen McpMux. Explains the whole flow in two sentences before the fields. */}
- - What is a mapping? - {' '} - Pick a folder, then choose the tools it should get. Whenever you open - that folder in a connected app — Cursor, VS Code, Claude — McpMux hands - it exactly the tools you choose here, and nothing else. + What is a mapping?{' '} + {isId + ? 'Enter an id or label (a client id, machine name, or any string), then choose the tools it gets. A headless or remote client that sends this exact value in the X-Mcpmux-Workspace header receives exactly those tools.' + : 'Pick a folder, then choose the tools it should get. Whenever you open that folder in a connected app — Cursor, VS Code, Claude — McpMux hands it exactly the tools you choose here, and nothing else.'}
- + {mode === 'create' && ( +
+ {(['path', 'id'] as const).map((t) => ( + + ))} +
+ )} + +
setRoot(e.target.value)} readOnly={!rootEditable} - placeholder="Browse for a folder, or paste an absolute path" + placeholder={ + isId + ? 'Any exact-match label — a client id, machine name, etc.' + : 'Browse for a folder, or paste an absolute path' + } className={[ - 'flex-1 min-w-0 px-3 py-2 rounded-lg text-sm font-mono focus:outline-none focus:ring-2', + 'min-w-0 flex-1 rounded-lg px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2', !rootEditable - ? 'bg-[rgb(var(--background))] border border-[rgb(var(--border-subtle))] text-[rgb(var(--muted))] cursor-not-allowed focus:ring-primary-500' + ? 'focus:ring-primary-500 cursor-not-allowed border border-[rgb(var(--border-subtle))] bg-[rgb(var(--background))] text-[rgb(var(--muted))]' : rootValidation.state === 'error' - ? 'bg-[rgb(var(--background))] border border-red-500/60 focus:ring-red-500 focus:border-red-500' - : 'bg-[rgb(var(--background))] border border-[rgb(var(--border))] focus:ring-primary-500 focus:border-primary-500', + ? 'border border-red-500/60 bg-[rgb(var(--background))] focus:border-red-500 focus:ring-red-500' + : 'focus:ring-primary-500 focus:border-primary-500 border border-[rgb(var(--border))] bg-[rgb(var(--background))]', ].join(' ')} data-testid="workspace-binding-root-input" /> - {rootEditable && ( + {rootEditable && !isId && (
{duplicate ? (

- + - This folder is already mapped. Open its existing mapping to change - what it sees instead of adding a second one. + This folder is already mapped. Open its existing mapping to change what it sees + instead of adding a second one.

+ ) : isId ? ( +

+ Matched exactly (case-sensitive). A client sends this value in the{' '} + X-Mcpmux-Workspace header. +

) : ( - + )}
@@ -1982,19 +1945,13 @@ function BindingForm({
1 - ? `Feature set (${fsIds.length} selected)` - : 'Feature set' - } + label={fsIds.length > 1 ? `Feature set (${fsIds.length} selected)` : 'Feature set'} hint="A feature set is a curated list of tools, prompts, and resources from that Space — exactly what this folder is allowed to use. Pick one, or combine several into a single set." > {!spaceId ? ( -

- Pick a Space first. -

+

Pick a Space first.

) : availableFs.length === 0 ? ( -

+

No feature sets in that Space yet.

) : ( @@ -2002,19 +1959,19 @@ function BindingForm({ className="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))]" data-testid="workspace-binding-fs" > -
+
setFsSearch(e.target.value)} placeholder={`Search ${availableFs.length} feature set${availableFs.length === 1 ? '' : 's'}…`} - className="w-full px-2.5 py-1.5 text-xs bg-[rgb(var(--surface))] border border-[rgb(var(--border-subtle))] rounded focus:outline-none focus:ring-2 focus:ring-primary-500" + className="focus:ring-primary-500 w-full rounded border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2" data-testid="workspace-binding-fs-search" />
-
+
{filteredFs.length === 0 ? ( -

+

No feature sets match “{fsSearch}”.

) : ( @@ -2027,7 +1984,7 @@ function BindingForm({ type="button" onClick={() => toggleFs(f.id)} className={[ - 'w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded text-left text-sm transition-colors', + 'flex w-full items-center gap-2.5 rounded px-2.5 py-1.5 text-left text-sm transition-colors', isSelected ? 'bg-primary-500/10 hover:bg-primary-500/15' : 'hover:bg-[rgb(var(--surface-hover))]', @@ -2036,30 +1993,25 @@ function BindingForm({ >
{isSelected ? ( - + ) : null}
{f.icon && ( - - {f.icon} - + {f.icon} )} -
+
-

{f.name}

+

{f.name}

{isStarterFeatureSet(f) && ( starter @@ -2067,14 +2019,14 @@ function BindingForm({ )}
{f.description && ( -

+

{f.description}

)}
{order !== null && fsIds.length > 1 && ( {order} @@ -2086,7 +2038,7 @@ function BindingForm({ )}
{fsSearch && filteredFs.length > 0 && filteredFs.length < availableFs.length && ( -
+
{filteredFs.length} of {availableFs.length} shown
)} @@ -2099,13 +2051,12 @@ function BindingForm({ state. In edit mode the button stays disabled until something actually changes. An empty feature-set selection is valid and savable. */} -
+
{spaceId && fsIds.length === 0 && ( // Empty is allowed — explain what it means rather than blocking.

- No feature sets selected — this folder gets no tools{' '} - from this Space. Built-in servers still apply per Space (see Built-in - Servers). + No feature sets selected — this folder gets no tools from this Space. + Built-in servers still apply per Space (see Built-in Servers).

)} {isEdit && dirty && !duplicate && ( @@ -2123,9 +2074,9 @@ function BindingForm({ data-testid="workspace-binding-submit" > {submitting ? ( - + ) : ( - + )} {submitLabel} @@ -2164,8 +2115,8 @@ function RootValidationHint({ if (!editable) { return (

- This folder was reported by the app that's open in it, so the path - is fixed — just choose its tools below. + This folder was reported by the app that's open in it, so the path is fixed — just + choose its tools below.

); } @@ -2179,35 +2130,24 @@ function RootValidationHint({ } if (state.state === 'checking') { return ( -

+

Checking…

); } if (state.state === 'error') { - return ( -

- {state.reason} -

- ); + return

{state.reason}

; } // ok const changed = state.normalized !== originalValue.trim(); if (!changed) { - return ( -

- Ready to save. -

- ); + return

Ready to save.

; } return (

Will be saved as{' '} - - {state.normalized} - - . + {state.normalized}.

); } @@ -2223,7 +2163,7 @@ function FormField({ }) { return (
-
); } @@ -2284,11 +2224,11 @@ function EmptyState({ }) { if (hasFilter && hasAny) { return ( - + - -

No workspaces match

-

+ +

No workspaces match

+

Try adjusting the search or filter.

@@ -2296,19 +2236,18 @@ function EmptyState({ ); } return ( - + -
- +
+
-

No folders mapped yet

-

- When you open a folder in a connected app, it shows up here so you can - choose its tools. You can also map a folder ahead of time — add one - now to get started. +

No folders mapped yet

+

+ When you open a folder in a connected app, it shows up here so you can choose its tools. + You can also map a folder ahead of time — add one now to get started.

From 3b65c84a20e9de87149a2936a54a3f77a4aa2b74 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 22:17:43 +0800 Subject: [PATCH 11/19] fix: drop the auto-mapped clientId binding when a client is deleted delete_oauth_client now best-effort removes the id-binding keyed by the client_id, so deleting an API-key client doesn't leave an orphan " -> Starter" mapping behind in the Mapping tab. No-op for DCR clients (they have no such binding). Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src-tauri/src/commands/oauth.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index 6f21298f..73972f07 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -901,6 +901,7 @@ pub async fn update_oauth_client( #[tauri::command] pub async fn delete_oauth_client( gateway_state: State<'_, Arc>>, + app: State<'_, AppState>, client_id: String, ) -> Result<(), String> { let app_state = gateway_state.read().await; @@ -922,6 +923,23 @@ pub async fn delete_oauth_client( info!("[OAuth] Deleted client: {}", client_id); + // Best-effort: remove the auto-mapped clientId id-binding so a deleted + // client doesn't leave an orphan " → Starter" mapping behind in + // the Mapping tab. Only API-key clients have such a binding; for DCR + // clients this is a no-op. + if let Ok(Some(b)) = app + .workspace_binding_repository + .find_by_id_key(&client_id) + .await + { + if let Err(e) = app.workspace_binding_repository.delete(&b.id).await { + warn!( + "[OAuth] failed to remove clientId mapping for {}: {}", + client_id, e + ); + } + } + // Emit domain event state.emit_domain_event(mcpmux_core::DomainEvent::ClientDeleted { client_id }); From fd4fcad4b87b25eeff0d88f597589510a5cce044 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 23:07:04 +0800 Subject: [PATCH 12/19] feat(P2): make id-mapping creation reachable via the create wizard The Folder/ID toggle now lives in WorkspaceSetupWizard (the actual create path, reached from both "New mapping" and the Home "Set up a folder" card) instead of the inspector's edit-only form, where mode==='create' never rendered so the toggle was dead. ID mode takes a free-text label (step 1) and shows the X-Mcpmux-Workspace value to copy instead of the app-config install panel (step 2), persisting binding_type='id'. The Home folder CTA opens this wizard in folder mode (the default), so it follows the new mapping design. Removes the now-dead toggle from the inspector's BindingForm (edit keeps its id-handling). Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src/features/home/HomePage.tsx | 9 +- .../workspaces/WorkspaceSetupWizard.tsx | 229 ++++++++++++------ .../features/workspaces/WorkspacesPage.tsx | 26 +- 3 files changed, 167 insertions(+), 97 deletions(-) diff --git a/apps/desktop/src/features/home/HomePage.tsx b/apps/desktop/src/features/home/HomePage.tsx index ada34911..e73c159f 100644 --- a/apps/desktop/src/features/home/HomePage.tsx +++ b/apps/desktop/src/features/home/HomePage.tsx @@ -163,8 +163,9 @@ 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. + * the gateway globally; this routes into the Mapping walkthrough (the create + * wizard, in folder mode) to map a specific project and write its per-folder + * config. */ function SetUpFolderCard() { const navigateTo = useNavigateTo(); @@ -179,7 +180,7 @@ function SetUpFolderCard() { data-testid="home-setup-folder" className="group flex w-full items-center gap-3 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--card))] p-4 text-left shadow transition-all duration-200 hover:-translate-y-0.5 hover:border-[rgb(var(--border))] hover:shadow-md" > - + @@ -275,7 +276,7 @@ export function HomePage() { pending-approval nudge. */} - {/* Per-folder setup — opens the Workspaces walkthrough. */} + {/* Per-folder setup — opens the Mapping walkthrough (folder mode). */} {/* Stat tiles — each is a shortcut into the page that manages it. */} diff --git a/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx b/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx index d0e88f57..f81ab160 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, ArrowRight, Check, + Copy, FolderOpen, FolderSearch, Layers, @@ -53,6 +54,11 @@ export function WorkspaceSetupWizard({ onError: (msg: string) => void; }) { const [step, setStep] = useState<1 | 2 | 3>(1); + // A mapping is keyed by a folder PATH (the default) or an arbitrary ID/label + // (a client id, machine name, … — for headless/remote clients). `folder` + // holds whichever value the user enters. + const [bindingType, setBindingType] = useState<'path' | 'id'>('path'); + const isId = bindingType === 'id'; const [folder, setFolder] = useState(''); const [validating, setValidating] = useState(false); const [saving, setSaving] = useState(false); @@ -127,6 +133,7 @@ export function WorkspaceSetupWizard({ workspace_root: folder, space_id: spaceId, feature_set_ids: Array.from(fsIds), + binding_type: bindingType, }); // The parent transitions to the new mapping's inspector (which shows its // effective features) — don't close here, or that view would be lost. @@ -136,11 +143,13 @@ export function WorkspaceSetupWizard({ } }; - const TITLES = ['Choose a folder', 'Connect your apps', 'Choose its tools'] as const; + const TITLES = isId + ? (['Choose an id', 'How clients connect', 'Choose its tools'] as const) + : (['Choose a folder', 'Connect your apps', 'Choose its tools'] as const); return (
{/* Header + progress */} @@ -148,7 +157,7 @@ export function WorkspaceSetupWizard({
- Set up a folder · Step {step} of 3 + {isId ? 'Set up an ID mapping' : 'Set up a folder'} · Step {step} of 3

{TITLES[step - 1]}

@@ -175,80 +184,154 @@ export function WorkspaceSetupWizard({
{step === 1 && (
-

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

- + {/* Folder vs ID — a folder routes editors by the path they open; an + id routes a headless/remote client by an exact label it sends. */} +
+ {(['path', 'id'] as const).map((t) => ( + + ))} +
- {folder && ( -
- {alreadyMapped ? ( - - ) : ( - + {isId ? ( + <> +

+ Enter an id or label — a client id, machine name, or any string. A headless or + remote client that sends this exact value in the{' '} + X-Mcpmux-Workspace header gets the + tools you choose next. +

+ setFolder(e.target.value)} + placeholder="e.g. a client id or machine name" + className="focus:ring-primary-500 w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2" + data-testid="wizard-id-input" + /> + {alreadyMapped && ( +

+ That id is already mapped — edit it from the Mapping list instead. +

)} - - {folder} - -
- )} - {alreadyMapped && ( -

- This folder is already mapped — edit it from the Workspaces list instead. -

- )} + + ) : ( + <> +

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

+ - {unmappedRoots.length > 0 && ( -
-
- - Detected workspaces -
-
- {unmappedRoots.slice(0, 6).map((r, i) => ( - - ))} -
-
+ {folder && ( +
+ {alreadyMapped ? ( + + ) : ( + + )} + + {folder} + +
+ )} + {alreadyMapped && ( +

+ This folder is already mapped — edit it from the Workspaces list instead. +

+ )} + + {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. -

+ {isId ? ( +
+

+ A headless or remote client routes here by sending this id in the{' '} + X-Mcpmux-Workspace header. There's + no folder to auto-write app config for — copy the value into your client. +

+
+ + {folder || '—'} + + +
+
+ ) : ( + <> + +

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

+ + )}
)} @@ -299,9 +382,9 @@ export function WorkspaceSetupWizard({ type="checkbox" checked={fsIds.has(fs.id)} onChange={() => toggleFs(fs.id)} - className="h-4 w-4 flex-shrink-0 accent-primary-500" + className="accent-primary-500 h-4 w-4 flex-shrink-0" /> - + {fs.name} {isStarterFeatureSet(fs) && ( @@ -354,7 +437,11 @@ export function WorkspaceSetupWizard({ disabled={saving || fsIds.size === 0 || !folder} data-testid="wizard-finish" > - {saving ? : } + {saving ? ( + + ) : ( + + )} Finish )} diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index f7ec6372..9835791b 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -1610,7 +1610,10 @@ function BindingForm({ const [fsIds, setFsIds] = useState(initial?.feature_set_ids ?? []); // A mapping is keyed by a folder path OR an arbitrary id/label. The type is // chosen at create time and fixed thereafter (an id never becomes a folder). - const [bindingType, setBindingType] = useState<'path' | 'id'>(initial?.binding_type ?? 'path'); + // Mapping type is chosen in the create wizard and fixed thereafter; here + // (edit / create-from-live) we only read it so an id mapping isn't + // re-validated as a filesystem path. + const bindingType = initial?.binding_type ?? 'path'; const isId = bindingType === 'id'; const [fsSearch, setFsSearch] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -1832,27 +1835,6 @@ function BindingForm({ : 'Pick a folder, then choose the tools it should get. Whenever you open that folder in a connected app — Cursor, VS Code, Claude — McpMux hands it exactly the tools you choose here, and nothing else.'}
- {mode === 'create' && ( -
- {(['path', 'id'] as const).map((t) => ( - - ))} -
- )} -
Date: Sun, 28 Jun 2026 23:10:52 +0800 Subject: [PATCH 13/19] test: expect binding_type='path' in the wizard's onCreate payload The folder flow now sends binding_type; the exact-match assertion needed it. Signed-off-by: Mohammod Al Amin Ashik --- tests/ts/components/WorkspaceSetupWizard.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ts/components/WorkspaceSetupWizard.test.tsx b/tests/ts/components/WorkspaceSetupWizard.test.tsx index d19241e9..ccc0d675 100644 --- a/tests/ts/components/WorkspaceSetupWizard.test.tsx +++ b/tests/ts/components/WorkspaceSetupWizard.test.tsx @@ -81,6 +81,7 @@ describe('WorkspaceSetupWizard', () => { workspace_root: '/proj/app', space_id: 's1', feature_set_ids: ['fs_starter'], + binding_type: 'path', }); // The parent navigates to the new mapping's inspector (effective features); // the wizard itself does not close. From eb5b5df0eb2efd60f9e3ed4db77845c048e7a032 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 23:22:12 +0800 Subject: [PATCH 14/19] test(storage): cover applying migrations to an existing older DB Adds a schema-presence guard (inbound_client_api_keys table + binding_type + locked_space_id columns exist after a migrate) and reproduces the field upgrade path that surfaced "no such table": a DB rolled back to pre-020, reopened, must re-apply 020/021/022 and recreate the table. Our other migration tests only ever used a fresh in-memory DB, so this migrate-an-existing-DB path was uncovered. Signed-off-by: Mohammod Al Amin Ashik --- tests/rust/tests/database/migrations.rs | 79 +++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/rust/tests/database/migrations.rs b/tests/rust/tests/database/migrations.rs index e8ea4aac..c3afa417 100644 --- a/tests/rust/tests/database/migrations.rs +++ b/tests/rust/tests/database/migrations.rs @@ -151,3 +151,82 @@ fn test_018_rewrites_stale_starter_description_only() { "operator-customized copy must be preserved" ); } + +// --------------------------------------------------------------------------- +// Upgrade path — applying NEW migrations to an EXISTING (older) on-disk DB. +// +// Every other test here uses a FRESH in-memory DB, where all migrations run at +// once — so they never catch a migration that fails to apply when a real user +// opens a database created by a previous release. These two do. +// --------------------------------------------------------------------------- + +fn table_exists(db: &Database, name: &str) -> bool { + db.connection() + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name=?1", + [name], + |r| r.get::<_, bool>(0), + ) + .unwrap_or(false) +} + +fn column_exists(db: &Database, table: &str, column: &str) -> bool { + let sql = format!("SELECT COUNT(*) > 0 FROM pragma_table_info('{table}') WHERE name=?1"); + db.connection() + .query_row(&sql, [column], |r| r.get::<_, bool>(0)) + .unwrap_or(false) +} + +#[test] +fn test_new_schema_objects_exist_after_migration() { + // A fresh migrate must produce every object the API-key + mapping features + // depend on — a regression guard against a migration being dropped or broken. + let db = Database::open_in_memory().expect("open"); + assert!( + table_exists(&db, "inbound_client_api_keys"), + "migration 020 must create inbound_client_api_keys" + ); + assert!( + column_exists(&db, "workspace_bindings", "binding_type"), + "migration 021 must add workspace_bindings.binding_type" + ); + assert!( + column_exists(&db, "inbound_clients", "locked_space_id"), + "migration 022 must add inbound_clients.locked_space_id" + ); +} + +#[test] +fn test_pending_migrations_apply_to_an_existing_older_database() { + // Reproduce the real upgrade that surfaced "no such table: + // inbound_client_api_keys" in the field: a DB created before 020/021/022 + // existed, reopened by a newer build. The pending migrations MUST apply. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mcpmux.db"); + + // 1. Fully migrate, then roll the schema back to a pre-020 state. + { + let db = Database::open(&path).expect("open"); + db.connection() + .execute_batch( + "DELETE FROM schema_migrations WHERE version >= 20; + DROP TABLE IF EXISTS inbound_client_api_keys; + ALTER TABLE workspace_bindings DROP COLUMN binding_type; + ALTER TABLE inbound_clients DROP COLUMN locked_space_id;", + ) + .expect("roll schema back to pre-020"); + assert!( + !table_exists(&db, "inbound_client_api_keys"), + "precondition: the rolled-back DB is missing the table" + ); + } + + // 2. Reopen — run_migrations() must re-apply 020/021/022. + let db = Database::open(&path).expect("reopen older DB"); + assert!( + table_exists(&db, "inbound_client_api_keys"), + "reopening an older DB must re-create inbound_client_api_keys" + ); + assert!(column_exists(&db, "workspace_bindings", "binding_type")); + assert!(column_exists(&db, "inbound_clients", "locked_space_id")); +} From 5f4e3860b47471c6c6657226432abcca97a6c373 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 29 Jun 2026 10:57:17 +0800 Subject: [PATCH 15/19] fix(P2): locked client honors in-Space retargeted clientId mapping Signed-off-by: Mohammod Al Amin Ashik --- .../src/services/feature_set_resolver.rs | 46 ++++++++++-- .../tests/integration/feature_set_resolver.rs | 72 +++++++++++++++++++ 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs index 4ebace39..e7ef5776 100644 --- a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -303,13 +303,15 @@ impl FeatureSetResolverService { } /// Resolve a client locked to Space `locked`. The Space is fixed to - /// `locked`; the header/roots may still pick the FeatureSet, but only when - /// their binding lives in `locked`. A header whose binding resolves to a - /// different Space — or no header at all — falls back to `locked`'s Starter. - /// A locked client never touches the roots-pending or client-grant tiers. + /// `locked`; the header/roots — or the client's own retargeted clientId + /// mapping — may still pick the FeatureSet, but only when that binding lives + /// in `locked`. A binding that resolves to a different Space (or no binding + /// at all) falls back to `locked`'s Starter. A locked client never touches + /// the roots-pending or client-grant tiers. async fn resolve_locked( &self, session_id: Option<&str>, + client_id: Option<&str>, locked: Uuid, ) -> Result { if let Some(sid) = session_id { @@ -337,7 +339,35 @@ impl FeatureSetResolverService { } } } - // No header, or its binding is outside the locked Space → locked Starter. + // A locked client may still have its clientId-keyed `id` mapping + // retargeted from the Mapping tab. Honor that mapping's FeatureSet — but + // only when it stays within the locked Space, preserving + // lock-confinement. A mapping pointing out of the Space (or none) falls + // through to the locked Starter. + if let Some(cid) = client_id { + if let Some(binding) = self.binding_repo.find_by_id_key(cid).await? { + if binding.space_id == locked { + debug!( + %locked, + client_id = %cid, + "[FeatureSetResolver] locked client — clientId mapping within locked Space", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: binding.feature_set_ids, + space_id: Some(locked), + source: ResolutionSource::WorkspaceBinding, + }); + } + debug!( + %locked, + client_id = %cid, + binding_space = %binding.space_id, + "[FeatureSetResolver] locked client — clientId mapping in a different Space; ignored", + ); + } + } + + // No header/mapping within the locked Space → locked Starter. self.default_fallback(locked).await } @@ -378,7 +408,11 @@ impl FeatureSetResolverService { if let Some(cid) = client_id { if let Some(locked) = self.client_repo.get_locked_space(cid).await? { match locked.parse::() { - Ok(locked_uuid) => return self.resolve_locked(session_id, locked_uuid).await, + Ok(locked_uuid) => { + return self + .resolve_locked(session_id, client_id, locked_uuid) + .await + } Err(e) => warn!( client_id = %cid, locked_space = %locked, diff --git a/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs index c934a81f..d56b0a84 100644 --- a/tests/rust/tests/integration/feature_set_resolver.rs +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -975,3 +975,75 @@ async fn locked_client_with_no_header_gets_locked_space_starter() { assert_eq!(r.source, ResolutionSource::SpaceDefault); assert_eq!(r.feature_set_ids, vec![f.starter_fs_id]); } + +#[tokio::test] +async fn locked_client_honors_in_space_retargeted_client_id_mapping() { + // A locked client with no header: its clientId-keyed mapping was retargeted + // (from the auto-created Starter) to another FeatureSet that still lives in + // the locked Space. The resolver must honor that mapping — the Space stays + // locked, but the operator's FeatureSet choice is respected. + let f = Fixture::new().await; + f.make_client("locked-4").await; + f.client_repo + .set_locked_space("locked-4", Some(&f.space_id.to_string())) + .await + .unwrap(); + // Retargeted clientId mapping → a non-Starter FS within the locked Space. + f.binding_repo + .create(&WorkspaceBinding::new_id( + "locked-4", + f.space_id, + vec![f.fs_a_id.clone()], + )) + .await + .unwrap(); + // No header; explicitly rootless so we reach the clientId tier. + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some("locked-4")) + .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 locked_client_ignores_out_of_space_client_id_mapping() { + // A locked client whose clientId mapping points at a DIFFERENT Space must be + // confined to the locked Space: the out-of-Space mapping is ignored and the + // client falls back to the locked Space's Starter (lock-confinement holds). + let f = Fixture::new().await; + f.make_client("locked-5").await; + let other_base = if cfg!(windows) { + "d:\\elsewhere" + } else { + "/elsewhere" + }; + let (other_space, other_starter) = f.make_space_with_base_dir("Elsewhere", other_base).await; + f.client_repo + .set_locked_space("locked-5", Some(&f.space_id.to_string())) + .await + .unwrap(); + // clientId mapping points OUT of the locked Space. + f.binding_repo + .create(&WorkspaceBinding::new_id( + "locked-5", + other_space, + vec![other_starter], + )) + .await + .unwrap(); + // No header; explicitly rootless so we reach the clientId tier. + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some("locked-5")) + .await + .unwrap(); + // Confined to the locked (default) Space; the foreign mapping is ignored. + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id]); +} From 6a88abce4d8b503ec7201ff12d6e4fd5749a4a2b Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 21:04:12 +0800 Subject: [PATCH 16/19] feat(P3): rename Workspaces tab to "Mapping" + keep binding_type on edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nav label/hint and the Apps-page link now read "Mapping" — the tab routes apps to tools by folder OR id. Also fixes toInput() to carry binding_type so editing an id mapping isn't re-validated as a filesystem path. Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src/features/clients/ClientsPage.tsx | 2 +- apps/desktop/src/lib/api/workspaceBindings.ts | 2 ++ apps/desktop/src/lib/navigation.ts | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index 2d5138fb..601111fc 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -281,7 +281,7 @@ export default function ClientsPage() { onClick={() => navigateTo('workspaces')} className="font-medium text-[rgb(var(--accent))] hover:underline" > - Workspaces + Mapping {' '} per folder, not per app. diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts index 8c463096..bdd0f177 100644 --- a/apps/desktop/src/lib/api/workspaceBindings.ts +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -106,6 +106,8 @@ export function toInput(b: WorkspaceBinding): WorkspaceBindingInput { workspace_root: b.workspace_root, space_id: b.space_id, feature_set_ids: b.feature_set_ids, + // Preserve the type so editing an id mapping doesn't re-validate as a path. + binding_type: b.binding_type, }; } diff --git a/apps/desktop/src/lib/navigation.ts b/apps/desktop/src/lib/navigation.ts index 82c44184..a7f660b3 100644 --- a/apps/desktop/src/lib/navigation.ts +++ b/apps/desktop/src/lib/navigation.ts @@ -93,10 +93,10 @@ export const NAV_ZONES: NavZone[] = [ }, { key: 'workspaces', - label: 'Workspaces', + label: 'Mapping', icon: FolderOpen, testId: 'nav-workspaces', - hint: 'Folder → tools mappings', + hint: 'Route apps to tools — by folder or id', }, { key: 'featuresets', From 770893f8a8eab4778730d64fca9001b644dfae7e Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 21:06:45 +0800 Subject: [PATCH 17/19] feat(P3): non-localhost consent note on the OAuth authorize page When the gateway is bound beyond loopback (network access on), /oauth/authorize now shows a note that desktop consent only completes on the host, pointing remote/headless clients to register an API-key client instead. Gated on network_bind, so local-only setups are unchanged. Signed-off-by: Mohammod Al Amin Ashik --- crates/mcpmux-gateway/src/server/handlers.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/mcpmux-gateway/src/server/handlers.rs b/crates/mcpmux-gateway/src/server/handlers.rs index bc86bc4d..3eb2123a 100644 --- a/crates/mcpmux-gateway/src/server/handlers.rs +++ b/crates/mcpmux-gateway/src/server/handlers.rs @@ -478,6 +478,17 @@ pub async fn oauth_authorize( // authorization for the desktop UI, which renders it as text via React. let display_name_html = html_escape_text(&display_name); + // When the gateway is exposed beyond loopback, a client that reached this + // page from another machine can't complete the desktop consent (the + // mcpmux:// deep link fires only on the host). Surface the API-key path so a + // remote user isn't left at a dead end. + let network_bind = state.read().await.network_bind; + let network_note = if network_bind { + r#"
Connecting from another machine? This approval only completes on the computer running McpMux. For a remote or headless client, register an API-key client in McpMux (Clients tab) and connect with that key — no browser approval needed.
"# + } else { + "" + }; + // HTML page that triggers the deep link // The page shows a brief message while the app opens // Industry standard: Don't auto-close, let user close after approval @@ -589,6 +600,8 @@ pub async fn oauth_authorize( Complete authorization in {app_name}

+ {network_note} +
{display_name_html}
wants to connect
From 0a6f84c0d829e15b16e01ba0538faab354d69f13 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 21:34:28 +0800 Subject: [PATCH 18/19] test(e2e): register API-key client flow + fix nav-rename selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TC-CL-003 (WDIO): open Apps, register a client, generate, and assert the mcpk_ key is shown once. Fixes specs the Workspaces→Mapping nav rename broke: clients.wdio now asserts "Mapping"; navigation + user-flows click the "Mapping" nav directly (their old has-text("Spaces").last() had incidentally matched the "Workspaces" button via substring). Signed-off-by: Mohammod Al Amin Ashik --- tests/e2e/specs/clients.wdio.ts | 32 ++++++++++++++++++++++++++++-- tests/e2e/specs/navigation.spec.ts | 5 +++-- tests/e2e/specs/user-flows.spec.ts | 5 +++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/e2e/specs/clients.wdio.ts b/tests/e2e/specs/clients.wdio.ts index 0d7a0987..84e22b3f 100644 --- a/tests/e2e/specs/clients.wdio.ts +++ b/tests/e2e/specs/clients.wdio.ts @@ -24,8 +24,8 @@ describe('Connections - Page shell', () => { // Heading has been renamed. expect(pageSource.includes('Apps')).toBe(true); - // The page routes users to Workspaces for any routing questions. - expect(pageSource.includes('Workspaces')).toBe(true); + // The page routes users to the Mapping tab for any routing questions. + expect(pageSource.includes('Mapping')).toBe(true); }); it('TC-CL-002: Open side panel and verify legacy routing controls are gone', async () => { @@ -57,4 +57,32 @@ describe('Connections - Page shell', () => { expect(pageSource.includes("Let's hook up your first IDE")).toBe(true); } }); + + it('TC-CL-003: Register an API-key client and reveal the key once', async () => { + // The desktop app auto-starts the gateway, so register_api_key_client can + // mint a key. Open the Apps tab, register a client, and confirm the + // generated mcpk_ key is shown exactly once. + const connectionsBtn = await byTestId('nav-clients'); + await connectionsBtn.click(); + await browser.pause(1000); + + const registerBtn = await byTestId('register-api-key-client-btn'); + await registerBtn.click(); + await browser.pause(800); + + const nameInput = await byTestId('register-api-key-name'); + await nameInput.setValue('e2e-headless-bot'); + + const generateBtn = await byTestId('register-api-key-generate'); + await generateBtn.click(); + await browser.pause(1500); + + await browser.saveScreenshot('./tests/e2e/screenshots/cl-03-api-key-created.png'); + + const keyEl = await byTestId('register-api-key-value'); + await expect(keyEl).toBeDisplayed(); + const keyText = await keyEl.getText(); + // Shown once, prefixed mcpk_ (never the stored hash). + expect(keyText.startsWith('mcpk_')).toBe(true); + }); }); diff --git a/tests/e2e/specs/navigation.spec.ts b/tests/e2e/specs/navigation.spec.ts index 34778502..601efc92 100644 --- a/tests/e2e/specs/navigation.spec.ts +++ b/tests/e2e/specs/navigation.spec.ts @@ -30,8 +30,9 @@ test.describe('Navigation', () => { await page.locator('nav button:has-text("Discover")').click({ force: true }); await expect(page.locator('h1:has-text("Discover")')).toBeVisible(); - // Spaces (use last() to avoid space switcher) - await page.locator('nav button:has-text("Spaces")').last().click({ force: true }); + // Mapping (the workspace→tools mapping tab; nav label was renamed from + // "Workspaces", but the page heading is still "Workspaces"). + await page.locator('nav button:has-text("Mapping")').click({ force: true }); await expect(page.locator('h1:has-text("Workspaces")')).toBeVisible(); // FeatureSets diff --git a/tests/e2e/specs/user-flows.spec.ts b/tests/e2e/specs/user-flows.spec.ts index 2754e465..012b9397 100644 --- a/tests/e2e/specs/user-flows.spec.ts +++ b/tests/e2e/specs/user-flows.spec.ts @@ -44,8 +44,9 @@ test.describe('Complete User Flows', () => { await page.locator('nav button:has-text("Discover")').click(); await expect(page.locator('h1:has-text("Discover")')).toBeVisible(); - // Spaces (use last() to avoid space switcher) - await page.locator('nav button:has-text("Spaces")').last().click(); + // Mapping (the workspace→tools mapping tab; nav label was renamed from + // "Workspaces", but the page heading is still "Workspaces"). + await page.locator('nav button:has-text("Mapping")').click(); await expect(page.locator('h1:has-text("Workspaces")')).toBeVisible(); // FeatureSets From 7bf4104a32adb8d76c7242694faa458585f45ef0 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 23:24:13 +0800 Subject: [PATCH 19/19] refactor(ui): rename the "Apps" tab back to "Clients" Nav label + hint, the page title + subtitle, and the Home stat tile + "See Clients" CTA. Updates the E2E specs that navigated/asserted by "Apps" (clients.spec, clients.wdio, app.wdio). Signed-off-by: Mohammod Al Amin Ashik --- .../src/features/clients/ClientsPage.tsx | 6 ++--- apps/desktop/src/features/home/HomePage.tsx | 6 ++--- apps/desktop/src/lib/navigation.ts | 4 ++-- tests/e2e/specs/app.wdio.ts | 2 +- tests/e2e/specs/clients.spec.ts | 22 +++++++++---------- tests/e2e/specs/clients.wdio.ts | 2 +- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index 601111fc..b976f8ac 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -271,12 +271,12 @@ export default function ClientsPage() {
- The AI apps connected through your gateway. Which tools each one gets (which Space, - which FeatureSet) is configured in{' '} + The AI clients connected through your gateway. Which tools each one gets (which + Space, which FeatureSet) is configured in{' '}