From da15db2c1d2bd40d4d38bf9b9571898f0bb07b5e Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Thu, 18 Jun 2026 17:46:00 +0800 Subject: [PATCH 1/4] feat(spaces): base-dir storage foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for per-Space base directories (scope a workspace root to a Space by folder prefix). No behavior change yet — just the primitive + storage. - Domain: path_is_within / longest_matching_base (segment-boundary aware, Windows + POSIX, longest-prefix wins) + the SpaceBaseDir entity. - Storage: migration 019 space_base_dirs (UNIQUE path = one owner per folder, FK cascade) + SpaceBaseDirRepository (list/add/remove + find_space_for_root longest-prefix lookup). - Tests: 4 domain prefix tests + 4 repo tests (CRUD, uniqueness, longest prefix, cascade). Signed-off-by: Mohammod Al Amin Ashik --- crates/mcpmux-core/src/domain/mod.rs | 3 +- crates/mcpmux-core/src/domain/space.rs | 18 +++ .../src/domain/workspace_binding.rs | 95 +++++++++++++ crates/mcpmux-core/src/repository/mod.rs | 28 +++- crates/mcpmux-storage/src/database.rs | 5 + .../src/migrations/019_space_base_dirs.sql | 22 +++ crates/mcpmux-storage/src/repositories/mod.rs | 2 + .../repositories/space_base_dir_repository.rs | 125 ++++++++++++++++++ tests/rust/tests/database/mod.rs | 1 + tests/rust/tests/database/space_base_dir.rs | 103 +++++++++++++++ 10 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 crates/mcpmux-storage/src/migrations/019_space_base_dirs.sql create mode 100644 crates/mcpmux-storage/src/repositories/space_base_dir_repository.rs create mode 100644 tests/rust/tests/database/space_base_dir.rs diff --git a/crates/mcpmux-core/src/domain/mod.rs b/crates/mcpmux-core/src/domain/mod.rs index 36b721e8..83d97def 100644 --- a/crates/mcpmux-core/src/domain/mod.rs +++ b/crates/mcpmux-core/src/domain/mod.rs @@ -38,5 +38,6 @@ pub use server_feature::*; pub use server_log::*; pub use space::*; pub use workspace_binding::{ - normalize_workspace_root, validate_workspace_root, WorkspaceBinding, WorkspaceRootValidation, + longest_matching_base, normalize_workspace_root, path_is_within, validate_workspace_root, + WorkspaceBinding, WorkspaceRootValidation, }; diff --git a/crates/mcpmux-core/src/domain/space.rs b/crates/mcpmux-core/src/domain/space.rs index e88b2582..0e372d14 100644 --- a/crates/mcpmux-core/src/domain/space.rs +++ b/crates/mcpmux-core/src/domain/space.rs @@ -75,6 +75,24 @@ impl Default for Space { } } +/// A base directory claimed by a Space. +/// +/// Any reported workspace root at or under `path` is scoped to `space_id` +/// (see [`crate::domain::path_is_within`]). `path` is stored already-normalized +/// via [`crate::domain::normalize_workspace_root`], and is globally unique — +/// the same folder can't be a base dir of two Spaces. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceBaseDir { + /// Unique identifier for this base-dir row. + pub id: String, + /// The Space that owns this base directory. + pub space_id: String, + /// Normalized absolute path. + pub path: String, + /// Creation timestamp. + pub created_at: DateTime, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs index f88696d7..8260dfc8 100644 --- a/crates/mcpmux-core/src/domain/workspace_binding.rs +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -292,6 +292,52 @@ pub enum WorkspaceRootValidation { Invalid { reason: String }, } +/// True when `root` is the same folder as `base`, or nested inside it — +/// matching on path-segment boundaries so `/work` contains `/work/proj` but +/// NOT `/workspace`. Both arguments must already be normalized via +/// [`normalize_workspace_root`] (same casing + separator conventions) for the +/// comparison to be meaningful. +/// +/// Used to scope a workspace root to a Space by its configured base +/// directories: a reported root at or under a base dir belongs to that Space. +pub fn path_is_within(root: &str, base: &str) -> bool { + if base.is_empty() || root.is_empty() { + return false; + } + if root == base { + return true; + } + // Separator comes from the (normalized) base: Windows drive/UNC paths use + // `\`, POSIX uses `/`. + let sep = if base.contains('\\') { '\\' } else { '/' }; + if base.ends_with(sep) { + // `base` is already a filesystem/drive root (`c:\`, `\\`, `/`) — its + // trailing separator IS the boundary, so a plain prefix check is right. + root.starts_with(base) + } else { + // Otherwise require the next char to be the separator so we only match + // whole segments (`/work` ⊄ `/workspace`). + let mut prefix = String::with_capacity(base.len() + 1); + prefix.push_str(base); + prefix.push(sep); + root.starts_with(&prefix) + } +} + +/// Of all `bases`, return the **longest** one that contains `root` (most +/// specific wins when base dirs nest — `/work/client` beats `/work`), or +/// `None` when none contain it. Inputs must be normalized. Equal-length bases +/// can't both contain the same root, so length ties never occur in practice. +pub fn longest_matching_base<'a>( + root: &str, + bases: impl IntoIterator, +) -> Option<&'a str> { + bases + .into_iter() + .filter(|b| path_is_within(root, b)) + .max_by_key(|b| b.len()) +} + /// Validate a user-entered workspace root. /// /// Applied on manual add/edit ONLY — roots reported by connected MCP @@ -388,6 +434,55 @@ fn check_windows_reserved_chars(path: &str) -> Result<(), String> { mod tests { use super::*; + // ---- path_is_within / longest_matching_base -------------------------- + + #[test] + fn within_posix_segment_boundaries() { + assert!(path_is_within("/work", "/work")); // same folder + assert!(path_is_within("/work/proj", "/work")); // nested + assert!(path_is_within("/work/proj/deep", "/work")); // deeper + assert!(!path_is_within("/workspace", "/work")); // NOT a segment boundary + assert!(!path_is_within("/other", "/work")); + assert!(!path_is_within("/work", "/work/proj")); // parent is not within child + } + + #[test] + fn within_windows_is_case_and_sep_consistent() { + // Inputs are already-normalized Windows form (lower-case, `\`). + assert!(path_is_within("c:\\work\\proj", "c:\\work")); + assert!(path_is_within("c:\\work", "c:\\work")); + assert!(!path_is_within("c:\\workspace", "c:\\work")); + // Drive root keeps its trailing separator and contains everything on it. + assert!(path_is_within("c:\\proj", "c:\\")); + } + + #[test] + fn within_filesystem_roots() { + assert!(path_is_within("/work", "/")); // POSIX root contains all + assert!(path_is_within("/", "/")); + assert!(!path_is_within("/x", "")); // empty base never matches + assert!(!path_is_within("", "/x")); // empty root never matches + } + + #[test] + fn longest_base_wins_when_nested() { + let bases = ["/work", "/work/client", "/other"]; + assert_eq!( + longest_matching_base("/work/client/app", bases.iter().copied()), + Some("/work/client"), + ); + // A root under only the broad base falls to that one. + assert_eq!( + longest_matching_base("/work/solo", bases.iter().copied()), + Some("/work"), + ); + // No base contains it. + assert_eq!( + longest_matching_base("/elsewhere", bases.iter().copied()), + None, + ); + } + // ---- normalize ------------------------------------------------------- #[test] diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index c8cc9e97..5d73e2de 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -8,7 +8,7 @@ use uuid::Uuid; use crate::domain::{ Client, Credential, CredentialType, FeatureSet, FeatureSetMember, InstalledServer, MemberMode, - OutboundOAuthRegistration, ServerFeature, Space, WorkspaceBinding, + OutboundOAuthRegistration, ServerFeature, Space, SpaceBaseDir, WorkspaceBinding, }; /// Result type for repository operations @@ -39,6 +39,32 @@ pub trait SpaceRepository: Send + Sync { async fn set_default(&self, id: &Uuid) -> RepoResult<()>; } +/// Per-Space base-directory repository. +/// +/// Manages the folders a Space claims. A reported workspace root at or under a +/// base dir is scoped to that Space (longest-prefix wins when base dirs nest). +#[async_trait] +pub trait SpaceBaseDirRepository: Send + Sync { + /// Every base dir across all Spaces. + async fn list_all(&self) -> RepoResult>; + + /// Base dirs for one Space. + async fn list_by_space(&self, space_id: &Uuid) -> RepoResult>; + + /// Add a base dir to a Space. `path` MUST already be normalized via + /// [`crate::domain::normalize_workspace_root`]. Returns an error if the + /// path is already claimed by any Space (one owner per path). + async fn add(&self, space_id: &Uuid, path: &str) -> RepoResult; + + /// Remove a base dir by its row id. + async fn remove(&self, id: &str) -> RepoResult<()>; + + /// The Space whose base dir is the longest prefix of `root` (already + /// normalized), or `None` when no base dir contains it. Most-specific + /// (longest) base dir wins when several nest. + async fn find_space_for_root(&self, root: &str) -> RepoResult>; +} + /// InstalledServer repository trait #[async_trait] pub trait InstalledServerRepository: Send + Sync { diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index e6336d5a..14a32c2a 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -123,6 +123,11 @@ const MIGRATIONS: &[Migration] = &[ name: "starter_is_default_fallback_copy", sql: include_str!("migrations/018_starter_is_default_fallback_copy.sql"), }, + Migration { + version: 19, + name: "space_base_dirs", + sql: include_str!("migrations/019_space_base_dirs.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/019_space_base_dirs.sql b/crates/mcpmux-storage/src/migrations/019_space_base_dirs.sql new file mode 100644 index 00000000..f0a218cc --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/019_space_base_dirs.sql @@ -0,0 +1,22 @@ +-- Migration 019: per-Space base directories. +-- +-- A Space can claim one or more base directories. Any workspace root reported +-- by a connected client that sits at or under a base dir is "scoped" to that +-- Space: an unmapped root falls back to that Space's Starter (not the global +-- default), and the meta-tools / mapping popup restrict to that Space. +-- +-- `path` is the NORMALIZED workspace root (see `normalize_workspace_root`: +-- lower-cased drive letter on Windows, `\` separators, no trailing slash). +-- UNIQUE(path) enforces one owner per exact path — the same folder can't be a +-- base dir of two Spaces. Nesting across Spaces is allowed and resolved by +-- longest-prefix at lookup time. ON DELETE CASCADE drops a Space's base dirs +-- with it. + +CREATE TABLE IF NOT EXISTS space_base_dirs ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + path TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_space_base_dirs_space ON space_base_dirs(space_id); diff --git a/crates/mcpmux-storage/src/repositories/mod.rs b/crates/mcpmux-storage/src/repositories/mod.rs index 4489d196..9b1947db 100644 --- a/crates/mcpmux-storage/src/repositories/mod.rs +++ b/crates/mcpmux-storage/src/repositories/mod.rs @@ -8,6 +8,7 @@ mod inbound_mcp_client_repository; mod installed_server_repository; mod outbound_oauth_client_repository; mod server_feature_repository; +mod space_base_dir_repository; mod space_builtin_config_repository; mod space_repository; mod workspace_binding_repository; @@ -25,6 +26,7 @@ pub use outbound_oauth_client_repository::SqliteOutboundOAuthRepository; pub use server_feature_repository::{ FeatureType, ServerFeature, ServerFeatureRepository, SqliteServerFeatureRepository, }; +pub use space_base_dir_repository::SqliteSpaceBaseDirRepository; pub use space_builtin_config_repository::SqliteSpaceBuiltinConfigRepository; pub use space_repository::SqliteSpaceRepository; pub use workspace_binding_repository::SqliteWorkspaceBindingRepository; diff --git a/crates/mcpmux-storage/src/repositories/space_base_dir_repository.rs b/crates/mcpmux-storage/src/repositories/space_base_dir_repository.rs new file mode 100644 index 00000000..708b4d8f --- /dev/null +++ b/crates/mcpmux-storage/src/repositories/space_base_dir_repository.rs @@ -0,0 +1,125 @@ +//! SQLite implementation of SpaceBaseDirRepository. + +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mcpmux_core::{path_is_within, SpaceBaseDir, SpaceBaseDirRepository}; +use rusqlite::params; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::Database; + +/// SQLite-backed implementation of [`SpaceBaseDirRepository`]. +pub struct SqliteSpaceBaseDirRepository { + db: Arc>, +} + +impl SqliteSpaceBaseDirRepository { + pub fn new(db: Arc>) -> Self { + Self { db } + } + + fn parse_datetime(s: &str) -> DateTime { + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return dt.with_timezone(&Utc); + } + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return dt.and_utc(); + } + Utc::now() + } + + /// Columns selected for every read. Order must match `map_row`. + const COLUMNS: &'static str = "id, space_id, path, created_at"; + + fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SpaceBaseDir { + id: row.get(0)?, + space_id: row.get(1)?, + path: row.get(2)?, + created_at: Self::parse_datetime(&row.get::<_, String>(3)?), + }) + } +} + +#[async_trait] +impl SpaceBaseDirRepository for SqliteSpaceBaseDirRepository { + async fn list_all(&self) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + let mut stmt = conn.prepare(&format!( + "SELECT {} FROM space_base_dirs ORDER BY path ASC", + Self::COLUMNS + ))?; + let rows = stmt + .query_map([], Self::map_row)? + .collect::>>()?; + Ok(rows) + } + + async fn list_by_space(&self, space_id: &Uuid) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + let mut stmt = conn.prepare(&format!( + "SELECT {} FROM space_base_dirs WHERE space_id = ? ORDER BY path ASC", + Self::COLUMNS + ))?; + let rows = stmt + .query_map(params![space_id.to_string()], Self::map_row)? + .collect::>>()?; + Ok(rows) + } + + async fn add(&self, space_id: &Uuid, path: &str) -> Result { + if path.trim().is_empty() { + anyhow::bail!("Base directory path is empty"); + } + let row = SpaceBaseDir { + id: Uuid::new_v4().to_string(), + space_id: space_id.to_string(), + path: path.to_string(), + created_at: Utc::now(), + }; + let db = self.db.lock().await; + let conn = db.connection(); + conn.execute( + "INSERT INTO space_base_dirs (id, space_id, path, created_at) VALUES (?1, ?2, ?3, ?4)", + params![row.id, row.space_id, row.path, row.created_at.to_rfc3339()], + ) + .map_err(|e| { + // Turn the UNIQUE(path) collision into an actionable message — the + // folder already belongs to some Space (possibly this one). + if e.to_string().to_lowercase().contains("unique") { + anyhow::anyhow!("That folder is already a base directory of a space: {path}") + } else { + anyhow::Error::from(e) + } + })?; + Ok(row) + } + + async fn remove(&self, id: &str) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + conn.execute("DELETE FROM space_base_dirs WHERE id = ?", params![id])?; + Ok(()) + } + + async fn find_space_for_root(&self, root: &str) -> Result> { + if root.trim().is_empty() { + return Ok(None); + } + // Few base dirs in practice — load them and pick the longest prefix + // match in Rust (reuses the byte-proven `path_is_within`). The db lock + // is taken and released inside `list_all`, so there's no double-lock. + let all = self.list_all().await?; + let best = all + .iter() + .filter(|bd| path_is_within(root, &bd.path)) + .max_by_key(|bd| bd.path.len()); + Ok(best.and_then(|bd| bd.space_id.parse::().ok())) + } +} diff --git a/tests/rust/tests/database/mod.rs b/tests/rust/tests/database/mod.rs index 89d2cee6..bf5048bf 100644 --- a/tests/rust/tests/database/mod.rs +++ b/tests/rust/tests/database/mod.rs @@ -15,3 +15,4 @@ mod installed_server; mod migrations; mod outbound_oauth; mod repositories; +mod space_base_dir; diff --git a/tests/rust/tests/database/space_base_dir.rs b/tests/rust/tests/database/space_base_dir.rs new file mode 100644 index 00000000..fc169de9 --- /dev/null +++ b/tests/rust/tests/database/space_base_dir.rs @@ -0,0 +1,103 @@ +//! SpaceBaseDirRepository integration tests. +//! +//! Base dirs scope a workspace root to a Space. These cover CRUD, the +//! one-owner-per-path UNIQUE constraint, longest-prefix lookup, and that base +//! dirs cascade away with their Space. + +use std::sync::Arc; + +use mcpmux_core::repository::{SpaceBaseDirRepository, SpaceRepository}; +use mcpmux_storage::{SqliteSpaceBaseDirRepository, SqliteSpaceRepository}; +use tests::{db::TestDatabase, fixtures}; +use tokio::sync::Mutex; + +fn repos(test_db: TestDatabase) -> (SqliteSpaceRepository, SqliteSpaceBaseDirRepository) { + let db = Arc::new(Mutex::new(test_db.db)); + ( + SqliteSpaceRepository::new(Arc::clone(&db)), + SqliteSpaceBaseDirRepository::new(db), + ) +} + +#[tokio::test] +async fn add_list_and_remove() { + let (space_repo, repo) = repos(TestDatabase::new()); + let space = fixtures::test_space("Work"); + SpaceRepository::create(&space_repo, &space).await.unwrap(); + + let bd = repo.add(&space.id, "/work").await.unwrap(); + assert_eq!(bd.path, "/work"); + assert_eq!(bd.space_id, space.id.to_string()); + + assert_eq!(repo.list_by_space(&space.id).await.unwrap().len(), 1); + assert_eq!(repo.list_all().await.unwrap().len(), 1); + + repo.remove(&bd.id).await.unwrap(); + assert!(repo.list_all().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn path_is_unique_across_spaces() { + let (space_repo, repo) = repos(TestDatabase::new()); + let a = fixtures::test_space("A"); + let b = fixtures::test_space("B"); + SpaceRepository::create(&space_repo, &a).await.unwrap(); + SpaceRepository::create(&space_repo, &b).await.unwrap(); + + repo.add(&a.id, "/shared").await.unwrap(); + // The same folder can't be a base dir of a second space. + let err = repo.add(&b.id, "/shared").await.unwrap_err(); + assert!( + err.to_string().to_lowercase().contains("already"), + "expected a friendly duplicate error, got: {err}" + ); +} + +#[tokio::test] +async fn find_space_for_root_longest_prefix_wins() { + let (space_repo, repo) = repos(TestDatabase::new()); + let work = fixtures::test_space("Work"); + let client = fixtures::test_space("Client"); + SpaceRepository::create(&space_repo, &work).await.unwrap(); + SpaceRepository::create(&space_repo, &client).await.unwrap(); + + repo.add(&work.id, "/work").await.unwrap(); + repo.add(&client.id, "/work/client").await.unwrap(); + + // Under the nested base dir → the most-specific (longest) space. + assert_eq!( + repo.find_space_for_root("/work/client/app").await.unwrap(), + Some(client.id) + ); + // Under only the broad base dir → that space. + assert_eq!( + repo.find_space_for_root("/work/other").await.unwrap(), + Some(work.id) + ); + // Exact base-dir match resolves to its space. + assert_eq!( + repo.find_space_for_root("/work").await.unwrap(), + Some(work.id) + ); + // Not under any base dir. + assert_eq!(repo.find_space_for_root("/elsewhere").await.unwrap(), None); + // Sibling that merely shares a name prefix is NOT a match (segment boundary). + assert_eq!(repo.find_space_for_root("/workspace").await.unwrap(), None); +} + +#[tokio::test] +async fn base_dirs_cascade_on_space_delete() { + let (space_repo, repo) = repos(TestDatabase::new()); + let space = fixtures::test_space("Temp"); + SpaceRepository::create(&space_repo, &space).await.unwrap(); + repo.add(&space.id, "/temp").await.unwrap(); + assert_eq!(repo.list_all().await.unwrap().len(), 1); + + SpaceRepository::delete(&space_repo, &space.id) + .await + .unwrap(); + assert!( + repo.list_all().await.unwrap().is_empty(), + "base dirs should cascade-delete with their space" + ); +} From 76bdf194b5cf0af697ca05a61625ed79b7e3ed6b Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Thu, 18 Jun 2026 20:48:17 +0800 Subject: [PATCH 2/4] feat(gateway): resolver scopes unmapped roots to a Space by base dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unmapped reported root that sits under a Space's base directory now falls back to THAT Space's Starter (scoped), not the global default Space. Exact WorkspaceBindings still win; roots outside every base dir still use the default Space. Because meta-tools resolve "which space" through the resolver, they auto-scope to the matched Space too. - FeatureSetResolverService gains a SpaceBaseDirRepository (wired through the GatewayDependencies, auto-derived from the database like the other repos). - Tier 1b: longest-prefix base-dir match → that Space's Starter. - Integration tests: under-base-dir scopes to that space, outside → default, nested → most-specific space wins, exact binding overrides base-dir scope. Signed-off-by: Mohammod Al Amin Ashik --- .../mcpmux-gateway/src/server/dependencies.rs | 13 +- .../src/server/service_container.rs | 1 + .../src/services/feature_set_resolver.rs | 73 +++++++--- .../tests/integration/effective_features.rs | 3 +- .../tests/integration/feature_set_resolver.rs | 131 +++++++++++++++++- tests/rust/tests/integration/meta_tools.rs | 5 +- .../integration/workspace_binding_events.rs | 6 +- 7 files changed, 204 insertions(+), 28 deletions(-) diff --git a/crates/mcpmux-gateway/src/server/dependencies.rs b/crates/mcpmux-gateway/src/server/dependencies.rs index 9750b17f..8e1a7afc 100644 --- a/crates/mcpmux-gateway/src/server/dependencies.rs +++ b/crates/mcpmux-gateway/src/server/dependencies.rs @@ -10,7 +10,7 @@ use crate::services::ClientMetadataService; use mcpmux_core::{ AppSettingsRepository, CimdMetadataFetcher, CredentialRepository, FeatureSetRepository, InboundMcpClientRepository, InstalledServerRepository, OutboundOAuthRepository, - ServerDiscoveryService, ServerFeatureRepository, ServerLogManager, + ServerDiscoveryService, ServerFeatureRepository, ServerLogManager, SpaceBaseDirRepository, SpaceBuiltinConfigRepository, SpaceRepository, WorkspaceBindingRepository, }; use mcpmux_storage::{Database, InboundClientRepository}; @@ -37,6 +37,9 @@ pub struct GatewayDependencies { pub inbound_mcp_client_repo: Arc, /// Workspace -> FeatureSet bindings for resolver v2. pub workspace_binding_repo: Arc, + /// Per-Space base directories — scope a reported workspace root to a Space + /// by folder prefix (longest match wins). + pub space_base_dir_repo: Arc, /// Per-Space built-in server config (Tool Optimization enablement + tool /// toggles), consulted when advertising the `mcpmux_*` tools per Space. pub builtin_config_repo: Arc, @@ -85,6 +88,9 @@ impl GatewayDependencies { let workspace_binding_repo: Arc = Arc::new( mcpmux_storage::SqliteWorkspaceBindingRepository::new(database.clone()), ); + let space_base_dir_repo: Arc = Arc::new( + mcpmux_storage::SqliteSpaceBaseDirRepository::new(database.clone()), + ); let builtin_config_repo: Arc = Arc::new( mcpmux_storage::SqliteSpaceBuiltinConfigRepository::new(database.clone()), ); @@ -99,6 +105,7 @@ impl GatewayDependencies { inbound_client_repo, inbound_mcp_client_repo, workspace_binding_repo, + space_base_dir_repo, builtin_config_repo, server_discovery, log_manager, @@ -247,6 +254,9 @@ impl DependenciesBuilder { let workspace_binding_repo: Arc = Arc::new( mcpmux_storage::SqliteWorkspaceBindingRepository::new(database.clone()), ); + let space_base_dir_repo: Arc = Arc::new( + mcpmux_storage::SqliteSpaceBaseDirRepository::new(database.clone()), + ); let builtin_config_repo: Arc = Arc::new( mcpmux_storage::SqliteSpaceBuiltinConfigRepository::new(database.clone()), ); @@ -267,6 +277,7 @@ impl DependenciesBuilder { inbound_client_repo, inbound_mcp_client_repo, workspace_binding_repo, + space_base_dir_repo, builtin_config_repo, server_discovery: self .server_discovery diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index 49e403d2..0ce5a4da 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -109,6 +109,7 @@ impl ServiceContainer { session_roots.clone(), deps.inbound_client_repo.clone(), deps.feature_set_repo.clone(), + deps.space_base_dir_repo.clone(), )); // Authorization service is now a thin adapter over the resolver. diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs index eeead3e1..f7bfcf70 100644 --- a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -81,7 +81,9 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; -use mcpmux_core::{FeatureSetRepository, SpaceRepository, WorkspaceBindingRepository}; +use mcpmux_core::{ + FeatureSetRepository, SpaceBaseDirRepository, SpaceRepository, WorkspaceBindingRepository, +}; use mcpmux_storage::InboundClientRepository; use serde::Serialize; use tracing::{debug, warn}; @@ -165,6 +167,10 @@ pub struct FeatureSetResolverService { /// Looks up each Space's Starter FeatureSet for the default fallback /// (Tier 1b / Tier 1c-after-grace / Tier 3). feature_set_repo: Arc, + /// Scopes an unmapped reported root to a Space by base directory — an + /// unmapped folder under a Space's base dir falls back to that Space's + /// Starter instead of the global default Space. + space_base_dir_repo: Arc, /// Grace window for the `PendingRoots` tier — see /// [`DEFAULT_PENDING_ROOTS_GRACE`]. Configurable so tests can force the /// post-grace path deterministically without sleeping. @@ -178,6 +184,7 @@ impl FeatureSetResolverService { session_roots: Arc, client_repo: Arc, feature_set_repo: Arc, + space_base_dir_repo: Arc, ) -> Self { Self { space_repo, @@ -185,10 +192,24 @@ impl FeatureSetResolverService { session_roots, client_repo, feature_set_repo, + space_base_dir_repo, pending_grace: DEFAULT_PENDING_ROOTS_GRACE, } } + /// The Space that claims one of `roots` by base directory, or `None`. Each + /// root's longest-prefix match is taken (via the repo); the first reported + /// root that lands in a Space wins. Used to scope an unmapped folder to its + /// Space rather than always falling back to the global default. + async fn space_for_roots(&self, roots: &[String]) -> Result> { + for r in roots { + if let Some(space_id) = self.space_base_dir_repo.find_space_for_root(r).await? { + return Ok(Some(space_id)); + } + } + Ok(None) + } + /// Override the pending-roots grace window. `Duration::ZERO` makes the /// resolver skip the wait entirely and fall back to the Space default on /// the first pending resolution — used by tests to exercise the @@ -198,36 +219,37 @@ impl FeatureSetResolverService { self } - /// Fall back to the default Space's Starter FeatureSet. Returns + /// Fall back to `space_id`'s Starter FeatureSet. `space_id` is the global + /// default Space for rootless sessions, or a base-dir-scoped Space for an + /// unmapped folder under that Space's base directory. Returns /// [`ResolutionSource::SpaceDefault`] when a Starter exists (the normal /// path — it's builtin and seeded per Space), or, defensively, - /// [`ResolutionSource::Deny`] in the degenerate case where the default - /// Space has no Starter. `space_id` is always the default Space here — - /// unmapped/rootless sessions have no other Space to route to. - async fn default_fallback(&self, default_space_id: Uuid) -> Result { + /// [`ResolutionSource::Deny`] in the degenerate case where the Space has no + /// Starter. + async fn default_fallback(&self, space_id: Uuid) -> Result { if let Some(fs) = self .feature_set_repo - .get_starter_for_space(&default_space_id.to_string()) + .get_starter_for_space(&space_id.to_string()) .await? { debug!( - space_id = %default_space_id, + %space_id, feature_set_id = %fs.id, "[FeatureSetResolver] resolved via SpaceDefault (Starter fallback)", ); return Ok(ResolvedFeatureSet { feature_set_ids: vec![fs.id], - space_id: Some(default_space_id), + space_id: Some(space_id), source: ResolutionSource::SpaceDefault, }); } debug!( - space_id = %default_space_id, - "[FeatureSetResolver] no Starter FeatureSet in default Space — deny", + %space_id, + "[FeatureSetResolver] no Starter FeatureSet in Space — deny", ); Ok(ResolvedFeatureSet { feature_set_ids: vec![], - space_id: Some(default_space_id), + space_id: Some(space_id), source: ResolutionSource::Deny, }) } @@ -295,9 +317,10 @@ impl FeatureSetResolverService { // Tier 1: session reported roots — try an EXACT binding match // (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(&roots.unwrap()) + .find_exact_for_roots(&reported_roots) .await? { debug!( @@ -313,13 +336,23 @@ impl FeatureSetResolverService { }); } // Tier 1b: had roots, no binding. The folder is unmapped, so - // fall back to the default Space's Starter FS — the folder - // works immediately instead of getting nothing. Upstream - // still emits WorkspaceNeedsBinding (it prompts on - // SpaceDefault too) so the user can attach an explicit - // mapping whenever they want something other than the default. - debug!("[FeatureSetResolver] roots reported but no binding matched — SpaceDefault",); - return self.default_fallback(default_space_id).await; + // fall back to a Starter FS — the folder works immediately + // instead of getting nothing. Scope it to the Space whose base + // directory claims the root (longest-prefix), if any; otherwise + // the global default Space. Upstream still emits + // WorkspaceNeedsBinding (it prompts on SpaceDefault too) so the + // user can attach an explicit mapping for something other than + // the default. + let target_space = self + .space_for_roots(&reported_roots) + .await? + .unwrap_or(default_space_id); + debug!( + %target_space, + scoped_by_base_dir = target_space != default_space_id, + "[FeatureSetResolver] roots reported but no binding matched — SpaceDefault", + ); + return self.default_fallback(target_space).await; } // Tier 1c: client declared `roots` but none have ARRIVED yet diff --git a/tests/rust/tests/integration/effective_features.rs b/tests/rust/tests/integration/effective_features.rs index 3c80b800..7a2e3468 100644 --- a/tests/rust/tests/integration/effective_features.rs +++ b/tests/rust/tests/integration/effective_features.rs @@ -27,7 +27,7 @@ use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, Sess use mcpmux_gateway::{FeatureService, PrefixCacheService}; use mcpmux_storage::{ Database, InboundClientRepository, SqliteFeatureSetRepository, SqliteServerFeatureRepository, - SqliteSpaceRepository, SqliteWorkspaceBindingRepository, + SqliteSpaceBaseDirRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use tokio::sync::Mutex; use uuid::Uuid; @@ -114,6 +114,7 @@ impl Ctx { session_roots.clone(), client_repo.clone(), fs_repo.clone(), + Arc::new(SqliteSpaceBaseDirRepository::new(db.clone())), ); let feature_service = FeatureService::new(feature_repo.clone(), fs_repo.clone(), prefix_cache); diff --git a/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs index 88dec7a5..06daf849 100644 --- a/tests/rust/tests/integration/feature_set_resolver.rs +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -21,13 +21,13 @@ use std::sync::Arc; use std::time::Duration; use mcpmux_core::{ - normalize_workspace_root, FeatureSet, FeatureSetRepository, SpaceRepository, WorkspaceBinding, - WorkspaceBindingRepository, + normalize_workspace_root, FeatureSet, FeatureSetRepository, Space, SpaceBaseDirRepository, + SpaceRepository, WorkspaceBinding, WorkspaceBindingRepository, }; use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, SessionRootsRegistry}; use mcpmux_storage::{ Database, InboundClient, InboundClientRepository, RegistrationType, SqliteFeatureSetRepository, - SqliteSpaceRepository, SqliteWorkspaceBindingRepository, + SqliteSpaceBaseDirRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use tokio::sync::Mutex; use uuid::Uuid; @@ -39,6 +39,7 @@ struct Fixture { binding_repo: Arc, fs_repo: Arc, client_repo: Arc, + base_dir_repo: Arc, space_id: Uuid, /// The default Space's auto-seeded Starter FS — the target of every /// `SpaceDefault` fallback. @@ -56,6 +57,8 @@ impl Fixture { let binding_repo: Arc = Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); let client_repo = Arc::new(InboundClientRepository::new(db.clone())); + let base_dir_repo: Arc = + Arc::new(SqliteSpaceBaseDirRepository::new(db.clone())); let default_space = space_repo.get_default().await.unwrap().unwrap(); let space_id = default_space.id; @@ -87,6 +90,7 @@ impl Fixture { session_roots.clone(), client_repo.clone(), fs_repo.clone(), + base_dir_repo.clone(), ); Self { @@ -96,6 +100,7 @@ impl Fixture { binding_repo, fs_repo, client_repo, + base_dir_repo, space_id, starter_fs_id, fs_a_id, @@ -113,10 +118,31 @@ impl Fixture { self.session_roots.clone(), self.client_repo.clone(), self.fs_repo.clone(), + self.base_dir_repo.clone(), ) .with_pending_grace(grace) } + /// Create a second Space with its own Starter and a base directory, so + /// base-dir scoping can be exercised. Returns `(space_id, starter_fs_id)`. + async fn make_space_with_base_dir(&self, name: &str, base_dir: &str) -> (Uuid, String) { + let space = Space::new(name); + let space_id = space.id; + self.space_repo.create(&space).await.unwrap(); + let starter_id = self + .fs_repo + .get_starter_for_space(&space_id.to_string()) + .await + .unwrap() + .expect("new space is seeded with a Starter") + .id; + self.base_dir_repo + .add(&space_id, &normalize_workspace_root(base_dir)) + .await + .unwrap(); + (space_id, starter_id) + } + /// Insert an inbound client row so we can attach grants to it (the /// `client_grants` FK requires the row to exist). async fn make_client(&self, client_id: &str) { @@ -292,6 +318,105 @@ async fn no_inheritance_child_of_bound_parent_falls_back_to_default() { assert_eq!(rp.feature_set_ids, vec![f.fs_a_id]); } +// --------------------------------------------------------------------------- +// SpaceDefault tier — base-directory scoping +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn unmapped_root_under_base_dir_scopes_to_that_space() { + let f = Fixture::new().await; + let (base, root) = if cfg!(windows) { + ("d:\\work", "d:\\work\\proj") + } else { + ("/work", "/work/proj") + }; + let (work_space, work_starter) = f.make_space_with_base_dir("Work", base).await; + + // Session reports a folder UNDER Work's base dir, with no explicit binding. + f.session_roots.set("s", [root]); + f.session_roots.set_roots_capable("s", true); + + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + // Scoped to the Work space's Starter — NOT the global default space's. + assert_eq!(r.space_id, Some(work_space)); + assert_eq!(r.feature_set_ids, vec![work_starter]); + assert_ne!(r.space_id, Some(f.space_id)); +} + +#[tokio::test] +async fn unmapped_root_outside_base_dirs_uses_default_space() { + let f = Fixture::new().await; + let base = if cfg!(windows) { "d:\\work" } else { "/work" }; + f.make_space_with_base_dir("Work", base).await; + + let other = if cfg!(windows) { + "d:\\elsewhere" + } else { + "/elsewhere" + }; + f.session_roots.set("s", [other]); + f.session_roots.set_roots_capable("s", true); + + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + // No base dir claims it → global default space's Starter. + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); +} + +#[tokio::test] +async fn nested_base_dir_most_specific_space_wins() { + let f = Fixture::new().await; + let (work_base, client_base, root) = if cfg!(windows) { + ("d:\\work", "d:\\work\\client", "d:\\work\\client\\app") + } else { + ("/work", "/work/client", "/work/client/app") + }; + f.make_space_with_base_dir("Work", work_base).await; + let (client_space, client_starter) = f.make_space_with_base_dir("Client", client_base).await; + + f.session_roots.set("s", [root]); + f.session_roots.set_roots_capable("s", true); + + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!( + r.space_id, + Some(client_space), + "the most-specific (longest) base dir wins" + ); + assert_eq!(r.feature_set_ids, vec![client_starter]); +} + +#[tokio::test] +async fn exact_binding_overrides_base_dir_scope() { + // A WorkspaceBinding is more specific than a base-dir scope: even though + // the root is under Work's base dir, an explicit binding wins. + let f = Fixture::new().await; + let (base, root) = if cfg!(windows) { + ("d:\\work", "d:\\work\\proj") + } else { + ("/work", "/work/proj") + }; + f.make_space_with_base_dir("Work", base).await; + + f.binding_repo + .create(&WorkspaceBinding::new( + normalize_workspace_root(root), + f.space_id, + f.fs_a_id.clone(), + )) + .await + .unwrap(); + f.session_roots.set("s", [root]); + f.session_roots.set_roots_capable("s", true); + + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id.clone()]); +} + // --------------------------------------------------------------------------- // ClientGrant tier — rootless fallback // --------------------------------------------------------------------------- diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 430eab5d..68042407 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -23,7 +23,7 @@ use mcpmux_gateway::services::{ }; use mcpmux_storage::{ Database, InboundClientRepository, SqliteFeatureSetRepository, - SqliteInboundMcpClientRepository, SqliteServerFeatureRepository, + SqliteInboundMcpClientRepository, SqliteServerFeatureRepository, SqliteSpaceBaseDirRepository, SqliteSpaceBuiltinConfigRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use serde_json::{json, Value}; @@ -108,6 +108,7 @@ impl Fixture { session_roots.clone(), inbound_client_repo.clone(), feature_set_repo.clone(), + Arc::new(SqliteSpaceBaseDirRepository::new(db.clone())), )); let prefix_cache = Arc::new(PrefixCacheService::new()); @@ -1058,6 +1059,7 @@ async fn bare_registry( SessionRootsRegistry::new(), inbound_client_repo.clone(), feature_set_repo.clone(), + Arc::new(SqliteSpaceBaseDirRepository::new(db.clone())), )); let prefix_cache = Arc::new(PrefixCacheService::new()); let feature_service = Arc::new(FeatureService::new( @@ -1169,6 +1171,7 @@ async fn per_space_config_controls_registry_visibility() { SessionRootsRegistry::new(), inbound_client_repo.clone(), feature_set_repo.clone(), + Arc::new(SqliteSpaceBaseDirRepository::new(db.clone())), )); let prefix_cache = Arc::new(PrefixCacheService::new()); let feature_service = Arc::new(FeatureService::new( diff --git a/tests/rust/tests/integration/workspace_binding_events.rs b/tests/rust/tests/integration/workspace_binding_events.rs index ef3a3f8b..df613de0 100644 --- a/tests/rust/tests/integration/workspace_binding_events.rs +++ b/tests/rust/tests/integration/workspace_binding_events.rs @@ -22,8 +22,8 @@ use mcpmux_core::{ }; use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, SessionRootsRegistry}; use mcpmux_storage::{ - Database, InboundClientRepository, SqliteFeatureSetRepository, SqliteSpaceRepository, - SqliteWorkspaceBindingRepository, + Database, InboundClientRepository, SqliteFeatureSetRepository, SqliteSpaceBaseDirRepository, + SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use tokio::sync::Mutex; use uuid::Uuid; @@ -59,6 +59,7 @@ impl Ctx { session_roots.clone(), inbound_client_repo.clone(), fs_repo.clone(), + Arc::new(SqliteSpaceBaseDirRepository::new(db.clone())), ); Self { @@ -159,6 +160,7 @@ async fn binding_to_non_default_space_reroutes_session() { session_roots.clone(), inbound_client_repo.clone(), fs_repo.clone(), + Arc::new(SqliteSpaceBaseDirRepository::new(db.clone())), ); let raw = if cfg!(windows) { From cf8b59c78f5ad9f518dea5abb71e7b4096667ad7 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Thu, 18 Jun 2026 20:56:13 +0800 Subject: [PATCH 3/4] feat(spaces): commands + UI to configure per-space base directories - AppState gains the SpaceBaseDirRepository; Tauri commands list/add/remove_space_base_dir (add validates + normalizes the path and rejects a folder already owned by another space). - TS API: SpaceBaseDir + listSpaceBaseDirs/addSpaceBaseDir/removeSpaceBaseDir. - Spaces page: a folder-tree button per space opens a "Base directories" modal (multi-folder picker + list with remove). Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src-tauri/src/commands/space.rs | 61 +++++- apps/desktop/src-tauri/src/lib.rs | 3 + apps/desktop/src-tauri/src/state/mod.rs | 11 +- .../features/spaces/SpaceBaseDirsModal.tsx | 201 ++++++++++++++++++ .../src/features/spaces/SpacesPage.tsx | 18 +- apps/desktop/src/lib/api/spaces.ts | 32 +++ 6 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/features/spaces/SpaceBaseDirsModal.tsx diff --git a/apps/desktop/src-tauri/src/commands/space.rs b/apps/desktop/src-tauri/src/commands/space.rs index b92f3747..a63a08d2 100644 --- a/apps/desktop/src-tauri/src/commands/space.rs +++ b/apps/desktop/src-tauri/src/commands/space.rs @@ -6,7 +6,7 @@ //! built-in fallback. The desktop UI tracks which space the user is //! viewing in its own Zustand store (frontend-only state). -use mcpmux_core::Space; +use mcpmux_core::{validate_workspace_root, Space, SpaceBaseDir, WorkspaceRootValidation}; use std::sync::Arc; use tauri::{AppHandle, State}; use tokio::sync::RwLock; @@ -249,3 +249,62 @@ pub async fn refresh_tray_menu(app: AppHandle, state: State<'_, AppState>) -> Re .await .map_err(|e| format!("Failed to update tray menu: {}", e)) } + +// --------------------------------------------------------------------------- +// Space base directories — scope a workspace root to a Space by folder prefix. +// A reported root at or under a base dir falls back to that Space's Starter +// (and scopes the meta-tools / mapping popup to it). Takes effect on a +// connected client's next request. +// --------------------------------------------------------------------------- + +/// List a Space's configured base directories. +#[tauri::command] +pub async fn list_space_base_dirs( + space_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + state + .space_base_dir_repository + .list_by_space(&uuid) + .await + .map_err(|e| e.to_string()) +} + +/// Add a base directory to a Space. The path is validated (must be an absolute +/// folder) and normalized before storing; an error is returned if it's already +/// claimed by another Space. +#[tauri::command] +pub async fn add_space_base_dir( + space_id: String, + path: String, + state: State<'_, AppState>, +) -> Result { + let uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + let normalized = match validate_workspace_root(&path) { + WorkspaceRootValidation::Ok { normalized } => normalized, + WorkspaceRootValidation::Empty => return Err("Pick a folder first.".to_string()), + WorkspaceRootValidation::Invalid { reason } => return Err(reason), + }; + + info!( + "[add_space_base_dir] space={} path={} (normalized {})", + space_id, path, normalized + ); + state + .space_base_dir_repository + .add(&uuid, &normalized) + .await + .map_err(|e| e.to_string()) +} + +/// Remove a base directory (by its row id). +#[tauri::command] +pub async fn remove_space_base_dir(id: String, state: State<'_, AppState>) -> Result<(), String> { + state + .space_base_dir_repository + .remove(&id) + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index afd84d05..6cc6b1a7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -860,6 +860,9 @@ pub fn run() { commands::get_space, commands::create_space, commands::delete_space, + commands::list_space_base_dirs, + commands::add_space_base_dir, + commands::remove_space_base_dir, commands::open_space_config_file, commands::read_space_config, commands::save_space_config, diff --git a/apps/desktop/src-tauri/src/state/mod.rs b/apps/desktop/src-tauri/src/state/mod.rs index 262ed981..78050b78 100644 --- a/apps/desktop/src-tauri/src/state/mod.rs +++ b/apps/desktop/src-tauri/src/state/mod.rs @@ -8,12 +8,13 @@ use mcpmux_core::{ GatewayPortService, InboundMcpClientRepository, InstalledServerRepository, LogConfig, OutboundOAuthRepository, ServerDiscoveryService, ServerFeatureRepository as CoreServerFeatureRepository, ServerLogManager, - SpaceBuiltinConfigRepository, SpaceRepository, SpaceService, WorkspaceBindingRepository, + SpaceBaseDirRepository, SpaceBuiltinConfigRepository, SpaceRepository, SpaceService, + WorkspaceBindingRepository, }; use mcpmux_storage::{ Database, FieldEncryptor, SqliteAppSettingsRepository, SqliteCredentialRepository, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository, - SqliteOutboundOAuthRepository, SqliteServerFeatureRepository, + SqliteOutboundOAuthRepository, SqliteServerFeatureRepository, SqliteSpaceBaseDirRepository, SqliteSpaceBuiltinConfigRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use std::path::PathBuf; @@ -49,6 +50,8 @@ pub struct AppState { pub client_repository: Arc, /// Workspace-root -> FeatureSet bindings (resolver v2) pub workspace_binding_repository: Arc, + /// Per-Space base directories (scope a workspace root to a Space by prefix) + pub space_base_dir_repository: Arc, /// Per-Space built-in server config (Tool Optimization enablement + tool toggles) pub space_builtin_config_repository: Arc, /// Server feature repository for discovered MCP features (implements core trait) @@ -109,6 +112,9 @@ impl AppState { let workspace_binding_repository: Arc = Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let space_base_dir_repository: Arc = + Arc::new(SqliteSpaceBaseDirRepository::new(db.clone())); + let space_builtin_config_repository: Arc = Arc::new(SqliteSpaceBuiltinConfigRepository::new(db.clone())); @@ -169,6 +175,7 @@ impl AppState { feature_set_repository, client_repository, workspace_binding_repository, + space_base_dir_repository, space_builtin_config_repository, server_feature_repository, server_feature_repository_core, diff --git a/apps/desktop/src/features/spaces/SpaceBaseDirsModal.tsx b/apps/desktop/src/features/spaces/SpaceBaseDirsModal.tsx new file mode 100644 index 00000000..c7468658 --- /dev/null +++ b/apps/desktop/src/features/spaces/SpaceBaseDirsModal.tsx @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useState } from 'react'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import { FolderPlus, FolderOpen, Loader2, Trash2, X } from 'lucide-react'; +import { Button, useToast, ToastContainer } from '@mcpmux/ui'; +import { + addSpaceBaseDir, + listSpaceBaseDirs, + removeSpaceBaseDir, + type Space, + type SpaceBaseDir, +} from '@/lib/api/spaces'; + +/** + * Manage a Space's base directories. + * + * A base dir scopes any workspace root opened at or under it to this Space: + * an unmapped folder there falls back to this Space's Starter set, and the + * self-optimize meta-tools + mapping popup restrict to this Space. Longest + * match wins when base dirs nest across Spaces, and a folder can belong to + * only one Space. + */ +export function SpaceBaseDirsModal({ + space, + onClose, +}: { + space: Space | null; + onClose: () => void; +}) { + const [dirs, setDirs] = useState([]); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const { toasts, success, error: showError, dismiss } = useToast(); + + const spaceId = space?.id ?? null; + + const load = useCallback(async () => { + if (!spaceId) return; + setLoading(true); + try { + setDirs(await listSpaceBaseDirs(spaceId)); + } catch (e) { + showError('Could not load base directories', e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [spaceId, showError]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const handleAdd = async () => { + if (!spaceId || busy) return; + let picked: string | string[] | null; + try { + picked = await openDialog({ directory: true, multiple: true, title: 'Add base directory' }); + } catch { + return; + } + const paths = Array.isArray(picked) ? picked : picked ? [picked] : []; + if (paths.length === 0) return; + + setBusy(true); + let added = 0; + for (const p of paths) { + try { + await addSpaceBaseDir(spaceId, p); + added++; + } catch (e) { + showError('Could not add folder', e instanceof Error ? e.message : String(e)); + } + } + await load(); + setBusy(false); + if (added > 0) { + success( + added === 1 ? 'Base directory added' : `${added} base directories added`, + 'Folders here are now scoped to this space.' + ); + } + }; + + const handleRemove = async (dir: SpaceBaseDir) => { + if (busy) return; + setBusy(true); + try { + await removeSpaceBaseDir(dir.id); + setDirs((prev) => prev.filter((d) => d.id !== dir.id)); + } catch (e) { + showError('Could not remove folder', e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + if (!space) return null; + + return ( +
+
e.stopPropagation()} + data-testid="space-base-dirs-modal" + > +
+
+
+ {space.icon || '🌐'} +
+
+

Base directories

+

+ Folders scoped to {space.name} +

+
+
+ +
+ +
+

+ Any folder you open here (or under it) is scoped to this space — it uses this + space's tools by default, and self-optimize only sees this space. The most specific + base directory wins, and a folder can belong to only one space. +

+ + {loading ? ( +
+ +
+ ) : dirs.length === 0 ? ( +
+ No base directories yet. Add one to scope its folders to this space. +
+ ) : ( +
    + {dirs.map((dir) => ( +
  • + + + {dir.path} + + +
  • + ))} +
+ )} +
+ +
+ +
+
+ +
+ ); +} diff --git a/apps/desktop/src/features/spaces/SpacesPage.tsx b/apps/desktop/src/features/spaces/SpacesPage.tsx index 35d2f924..09d1e389 100644 --- a/apps/desktop/src/features/spaces/SpacesPage.tsx +++ b/apps/desktop/src/features/spaces/SpacesPage.tsx @@ -1,9 +1,10 @@ import { useState } from 'react'; -import { Plus, Trash2, Loader2, Search, Layout, AlertCircle } from 'lucide-react'; +import { Plus, Trash2, Loader2, Search, Layout, AlertCircle, FolderTree } from 'lucide-react'; import { Card, CardContent, Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; import { useAppStore, useSpaces, useIsLoading } from '@/stores'; -import { deleteSpace } from '@/lib/api/spaces'; +import { deleteSpace, type Space } from '@/lib/api/spaces'; import { CreateSpaceModal } from './CreateSpaceModal'; +import { SpaceBaseDirsModal } from './SpaceBaseDirsModal'; export function SpacesPage() { const spaces = useSpaces(); @@ -21,6 +22,8 @@ export function SpacesPage() { // Create Modal State (creation logic lives in the shared CreateSpaceModal) const [showCreateModal, setShowCreateModal] = useState(false); + // The space whose base directories are being managed (null = closed). + const [baseDirsSpace, setBaseDirsSpace] = useState(null); const handleDelete = async (id: string) => { const spaceName = spaces.find((s) => s.id === id)?.name || 'this space'; @@ -160,7 +163,15 @@ export function SpacesPage() { {space.description || 'No description'}

-
+
+ {space.is_default && ( setShowCreateModal(false)} /> + setBaseDirsSpace(null)} />
); diff --git a/apps/desktop/src/lib/api/spaces.ts b/apps/desktop/src/lib/api/spaces.ts index 625df98c..ab0030f0 100644 --- a/apps/desktop/src/lib/api/spaces.ts +++ b/apps/desktop/src/lib/api/spaces.ts @@ -53,3 +53,35 @@ export async function removeServerFromConfig(spaceId: string, serverId: string): export async function openSpaceConfigFile(spaceId: string): Promise { return invoke('open_space_config_file', { spaceId }); } + +/** + * A base directory claimed by a Space. Any workspace root a connected client + * opens at or under `path` is scoped to that Space (longest-prefix wins): an + * unmapped folder there falls back to the Space's Starter set, and the + * meta-tools / mapping popup restrict to that Space. `path` is normalized and + * globally unique (one owner per folder). + */ +export interface SpaceBaseDir { + id: string; + space_id: string; + path: string; + created_at: string; +} + +/** List a Space's base directories. */ +export async function listSpaceBaseDirs(spaceId: string): Promise { + return invoke('list_space_base_dirs', { spaceId }); +} + +/** + * Add a base directory to a Space. `path` is validated (absolute folder) and + * normalized backend-side; rejects a folder already claimed by another Space. + */ +export async function addSpaceBaseDir(spaceId: string, path: string): Promise { + return invoke('add_space_base_dir', { spaceId, path }); +} + +/** Remove a base directory by its row id. */ +export async function removeSpaceBaseDir(id: string): Promise { + return invoke('remove_space_base_dir', { id }); +} From 72e111b34f65061d1cce94a80195f8f537a0778b Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Thu, 18 Jun 2026 21:03:04 +0800 Subject: [PATCH 4/4] feat(gateway): hard-scope meta-tools + lock mapping popup to base-dir Space When a session's reported root is under a Space's base directory, the self-optimize meta-tools see ONLY that Space, and the mapping popup locks its Space field to it. - Resolver: new pub scoped_space_for_session(session_id). - meta-tools: mcpmux_list_spaces returns only the scoped Space; target_space_id resolves to it and rejects an explicit space_id that names a different Space. - WorkspaceNeedsBinding gains space_locked; handler sets it; the sheet disables the Space picker (user only picks the FeatureSet). - Tests: resolver scoped_space_for_session; TS sheet picker lock/unlock. Signed-off-by: Mohammod Al Amin Ashik --- .../desktop/src-tauri/src/commands/gateway.rs | 2 + .../workspaces/WorkspaceBindingSheet.tsx | 11 ++++-- crates/mcpmux-core/src/domain/event.rs | 7 ++++ crates/mcpmux-gateway/src/mcp/handler.rs | 9 +++++ .../src/services/feature_set_resolver.rs | 14 +++++++ .../src/services/meta_tools/tools.rs | 37 +++++++++++++++++- .../tests/integration/feature_set_resolver.rs | 38 +++++++++++++++++++ .../integration/workspace_binding_events.rs | 1 + .../WorkspaceBindingPrompt.test.tsx | 30 ++++++++++++++- 9 files changed, 143 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index 47d37ff2..bd1adc74 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -696,6 +696,7 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val session_id, space_id, workspace_root, + space_locked, } => ( "workspace-needs-binding", serde_json::json!({ @@ -703,6 +704,7 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "session_id": session_id, "space_id": space_id, "workspace_root": workspace_root, + "space_locked": space_locked, }), ), diff --git a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx index cd91ff74..c30327e7 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx @@ -36,6 +36,9 @@ interface WorkspaceNeedsBindingPayload { session_id: string; space_id: string; workspace_root: string; + /** The folder is scoped to `space_id` by a Space base directory — lock the + * Space field to it (the user only picks the feature set). */ + space_locked?: boolean; } /** @@ -250,14 +253,16 @@ export function WorkspaceBindingSheet() { Space

- A profile that groups MCP servers — pick the one this folder draws - its tools from. + {payload.space_locked + ? 'This folder is under a base directory of this space, so it stays in this space — just pick the feature set below.' + : 'A profile that groups MCP servers — pick the one this folder draws its tools from.'}