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}
+
+ handleRemove(dir)}
+ disabled={busy}
+ className="flex-shrink-0 rounded-lg p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50 dark:hover:bg-red-900/20"
+ title="Remove base directory"
+ data-testid={`remove-base-dir-${dir.id}`}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {busy ? (
+
+ ) : (
+
+ )}
+ Add folder…
+
+
+
+
+
+ );
+}
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'}
-
+
+ setBaseDirsSpace(space)}
+ className="rounded-lg p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface))] hover:text-[rgb(var(--foreground))]"
+ title="Base directories — scope folders to this space"
+ data-testid={`space-base-dirs-${space.id}`}
+ >
+
+
{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.'}
setSelectedSpaceId(e.target.value)}
- className="w-full appearance-none rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-4 py-3 pr-10 text-sm font-medium text-[rgb(var(--foreground))] hover:border-[rgb(var(--border-hover,var(--accent)))] focus:border-[rgb(var(--accent))] focus:outline-none transition-colors"
+ disabled={payload.space_locked}
+ className="w-full appearance-none rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-4 py-3 pr-10 text-sm font-medium text-[rgb(var(--foreground))] transition-colors hover:border-[rgb(var(--border-hover,var(--accent)))] focus:border-[rgb(var(--accent))] focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
data-testid="workspace-binding-space-picker"
>
{spaces.map((s) => (
diff --git a/crates/mcpmux-core/src/domain/event.rs b/crates/mcpmux-core/src/domain/event.rs
index ab727fb6..60bfa0e6 100644
--- a/crates/mcpmux-core/src/domain/event.rs
+++ b/crates/mcpmux-core/src/domain/event.rs
@@ -365,6 +365,11 @@ pub enum DomainEvent {
session_id: String,
space_id: Uuid,
workspace_root: String,
+ /// The folder is scoped to `space_id` by a Space base directory, so the
+ /// mapping popup locks its Space field to it (the user only picks the
+ /// FeatureSet). `false` for an ordinary unmapped folder, where the user
+ /// may bind it to any Space.
+ space_locked: bool,
},
/// The live set of reported session roots changed (a client connected
@@ -719,6 +724,7 @@ mod tests {
session_id: "sess-1".to_string(),
space_id: Uuid::new_v4(),
workspace_root: "/proj/foo".to_string(),
+ space_locked: false,
};
assert!(!e.affects_mcp_capabilities());
assert!(e.is_ui_only());
@@ -744,6 +750,7 @@ mod tests {
session_id: "s".into(),
space_id: Uuid::nil(),
workspace_root: "/r".into(),
+ space_locked: true,
};
let json = serde_json::to_string(&needs).unwrap();
assert!(json.contains("\"type\":\"workspace_needs_binding\""));
diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs
index dcf428cd..8258ab80 100644
--- a/crates/mcpmux-gateway/src/mcp/handler.rs
+++ b/crates/mcpmux-gateway/src/mcp/handler.rs
@@ -150,12 +150,21 @@ impl McpMuxGatewayHandler {
resolved.space_id,
root_for_prompt,
) {
+ // Lock the popup's Space field when the folder is scoped to
+ // a Space by base directory — the user shouldn't be able to
+ // bind it elsewhere.
+ let space_locked = resolver
+ .scoped_space_for_session(Some(sid))
+ .await
+ .unwrap_or(None)
+ .is_some();
services.gateway_state.read().await.emit_domain_event(
mcpmux_core::DomainEvent::WorkspaceNeedsBinding {
client_id: client_id.to_string(),
session_id: sid.to_string(),
space_id,
workspace_root: root.to_string(),
+ space_locked,
},
);
}
diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs
index f7bfcf70..4beb799c 100644
--- a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs
+++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs
@@ -210,6 +210,20 @@ impl FeatureSetResolverService {
Ok(None)
}
+ /// The Space a session is scoped to by base directory — its reported root
+ /// sits under that Space's base dir — or `None` when it isn't base-dir
+ /// scoped (no session, no roots, or no matching base dir). The meta-tools
+ /// use this to hard-restrict self-optimization to the matched Space.
+ pub async fn scoped_space_for_session(&self, session_id: Option<&str>) -> Result> {
+ let Some(sid) = session_id else {
+ return Ok(None);
+ };
+ let Some(roots) = self.session_roots.get(sid) else {
+ return Ok(None);
+ };
+ self.space_for_roots(&roots).await
+ }
+
/// 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
diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs
index acedad0f..e668afde 100644
--- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs
+++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs
@@ -70,6 +70,30 @@ async fn caller_space_id(call: &MetaToolCall<'_>) -> Result
/// `mcpmux_list_spaces` — writes stay gated by the approval dialog, which names
/// the target Space so cross-Space changes are a conscious user choice.
async fn target_space_id(call: &MetaToolCall<'_>) -> Result {
+ // When the caller's workspace is scoped to a Space by base directory, that
+ // Space is authoritative: the meta-tools see ONLY it (no cross-Space
+ // targeting). An explicit `space_id` that names a different Space is
+ // rejected; omitting it (or naming the scoped Space) resolves to it.
+ if let Some(scoped) = call
+ .ctx
+ .resolver
+ .scoped_space_for_session(call.session_id)
+ .await?
+ {
+ if let Some(s) = opt_str_arg(&call.args, "space_id") {
+ let id = Uuid::parse_str(&s).map_err(|_| {
+ MetaToolError::InvalidArgument(format!("`space_id` is not a UUID: {s}"))
+ })?;
+ if id != scoped {
+ return Err(MetaToolError::InvalidArgument(format!(
+ "This workspace is scoped to space '{scoped}' by its base directory; \
+ it can't target another space ('{id}')."
+ )));
+ }
+ }
+ return Ok(scoped);
+ }
+
match opt_str_arg(&call.args, "space_id") {
Some(s) => {
let id = Uuid::parse_str(&s).map_err(|_| {
@@ -290,7 +314,18 @@ impl MetaTool for ListSpacesTool {
}
async fn call(&self, call: MetaToolCall<'_>) -> Result {
- let spaces = call.ctx.space_repo.list().await?;
+ let mut spaces = call.ctx.space_repo.list().await?;
+ // When the caller's workspace is scoped to a Space by base directory,
+ // expose ONLY that Space — self-optimization must not reach across into
+ // other Spaces' tools.
+ if let Some(scoped) = call
+ .ctx
+ .resolver
+ .scoped_space_for_session(call.session_id)
+ .await?
+ {
+ spaces.retain(|s| s.id == scoped);
+ }
let spaces: Vec<_> = spaces
.iter()
.map(|s| {
diff --git a/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs
index 06daf849..d515f124 100644
--- a/tests/rust/tests/integration/feature_set_resolver.rs
+++ b/tests/rust/tests/integration/feature_set_resolver.rs
@@ -417,6 +417,44 @@ async fn exact_binding_overrides_base_dir_scope() {
assert_eq!(r.feature_set_ids, vec![f.fs_a_id.clone()]);
}
+#[tokio::test]
+async fn scoped_space_for_session_reports_base_dir_match() {
+ let f = Fixture::new().await;
+ let (base, root, outside) = if cfg!(windows) {
+ ("d:\\work", "d:\\work\\proj", "d:\\elsewhere")
+ } else {
+ ("/work", "/work/proj", "/elsewhere")
+ };
+ let (work_space, _) = f.make_space_with_base_dir("Work", base).await;
+
+ // A session whose root is under a base dir IS scoped (the meta-tools use
+ // this to restrict to that one Space).
+ f.session_roots.set("s", [root]);
+ assert_eq!(
+ f.resolver
+ .scoped_space_for_session(Some("s"))
+ .await
+ .unwrap(),
+ Some(work_space)
+ );
+
+ // A root outside every base dir is NOT scoped.
+ f.session_roots.set("s2", [outside]);
+ assert_eq!(
+ f.resolver
+ .scoped_space_for_session(Some("s2"))
+ .await
+ .unwrap(),
+ None
+ );
+
+ // No session / no roots → not scoped.
+ assert_eq!(
+ f.resolver.scoped_space_for_session(None).await.unwrap(),
+ None
+ );
+}
+
// ---------------------------------------------------------------------------
// ClientGrant tier — rootless fallback
// ---------------------------------------------------------------------------
diff --git a/tests/rust/tests/integration/workspace_binding_events.rs b/tests/rust/tests/integration/workspace_binding_events.rs
index df613de0..6450918f 100644
--- a/tests/rust/tests/integration/workspace_binding_events.rs
+++ b/tests/rust/tests/integration/workspace_binding_events.rs
@@ -209,6 +209,7 @@ fn event_json_payloads_are_stable() {
session_id: "s-9".to_string(),
space_id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
workspace_root: "/abs/path".to_string(),
+ space_locked: false,
};
let v: serde_json::Value = serde_json::to_value(&needs).unwrap();
assert_eq!(v["type"], "workspace_needs_binding");
diff --git a/tests/ts/components/WorkspaceBindingPrompt.test.tsx b/tests/ts/components/WorkspaceBindingPrompt.test.tsx
index 6003a585..dfc65c4e 100644
--- a/tests/ts/components/WorkspaceBindingPrompt.test.tsx
+++ b/tests/ts/components/WorkspaceBindingPrompt.test.tsx
@@ -35,12 +35,18 @@ import { WorkspaceBindingSheet } from '@/features/workspaces/WorkspaceBindingShe
const TITLE = /This folder is using your Starter set/i;
/** Invoke the captured `workspace-needs-binding` listener with a payload. */
-function fireNeedsBinding() {
+function fireNeedsBinding(overrides: Record = {}) {
const call = vi.mocked(listen).mock.calls.find((c) => c[0] === 'workspace-needs-binding');
if (!call) throw new Error('workspace-needs-binding listener was not registered');
const cb = call[1] as (e: { payload: unknown }) => unknown | Promise;
return cb({
- payload: { client_id: 'c', session_id: 's', space_id: 's1', workspace_root: '/home/u/proj' },
+ payload: {
+ client_id: 'c',
+ session_id: 's',
+ space_id: 's1',
+ workspace_root: '/home/u/proj',
+ ...overrides,
+ },
});
}
@@ -86,4 +92,24 @@ describe('WorkspaceBindingSheet – mapping prompt toggle', () => {
);
await waitFor(() => expect(screen.queryByText(TITLE)).toBeNull());
});
+
+ it('locks the Space picker when the folder is base-dir scoped', async () => {
+ mockPromptEnabled(true);
+ render( );
+ await fireNeedsBinding({ space_locked: true });
+ await screen.findByText(TITLE);
+
+ const picker = screen.getByTestId('workspace-binding-space-picker') as HTMLSelectElement;
+ expect(picker.disabled).toBe(true);
+ });
+
+ it('leaves the Space picker editable for an ordinary unmapped folder', async () => {
+ mockPromptEnabled(true);
+ render( );
+ await fireNeedsBinding({ space_locked: false });
+ await screen.findByText(TITLE);
+
+ const picker = screen.getByTestId('workspace-binding-space-picker') as HTMLSelectElement;
+ expect(picker.disabled).toBe(false);
+ });
});