Skip to content

Commit 760a024

Browse files
committed
feat(workspaces): allow empty mappings (savable "no Space tools")
A workspace mapping may now have zero feature sets — a valid "no Space tools" state: the folder still routes to its Space, but gets no Space tools (built-in servers still apply per Space). Removes the non-empty invariant end to end: - storage: create/update accept empty feature_set_ids (junction left empty); - command: validate_fs_list no longer rejects empty; - domain: WorkspaceBinding doc updated (was "non-empty by construction"); - ui: Apply is enabled with zero feature sets — the empty state is now an informational hint instead of a blocker, and edit mode no longer auto-reseeds a default over an intentionally-emptied selection. Byte-proven: storage test_create_allows_empty_fs_list + effective_features::empty_mapping_yields_zero_effective_tools. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent e23efdb commit 760a024

5 files changed

Lines changed: 79 additions & 53 deletions

File tree

apps/desktop/src-tauri/src/commands/workspace_binding.rs

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,11 @@ impl From<WorkspaceBinding> for WorkspaceBindingDto {
7373
}
7474
}
7575

76-
/// Input for creating or updating a binding. Pass at least one
77-
/// `feature_set_id` in `feature_set_ids` — empty is rejected.
76+
/// Input for creating or updating a binding.
7877
///
79-
/// Order matters for UI rendering only; the resolver merges them.
78+
/// `feature_set_ids` MAY be empty — an empty list means "this folder gets no
79+
/// Space tools" (built-in servers still apply per Space). Order matters for UI
80+
/// rendering only; the resolver merges them.
8081
#[derive(Debug, Deserialize)]
8182
pub struct WorkspaceBindingInput {
8283
pub workspace_root: String,
@@ -88,23 +89,18 @@ fn parse_space_id(input: &WorkspaceBindingInput) -> Result<Uuid, String> {
8889
Uuid::parse_str(&input.space_id).map_err(|e| format!("bad space_id: {e}"))
8990
}
9091

92+
/// Clean + dedup the feature-set list (preserving order). An empty result is
93+
/// valid — it persists as a "no Space tools" binding.
9194
fn validate_fs_list(input: &WorkspaceBindingInput) -> Result<Vec<String>, String> {
92-
let cleaned: Vec<String> = input
95+
let cleaned = input
9396
.feature_set_ids
9497
.iter()
9598
.map(|s| s.trim().to_string())
96-
.filter(|s| !s.is_empty())
97-
.collect();
98-
if cleaned.is_empty() {
99-
return Err("at least one feature_set_id is required".into());
100-
}
99+
.filter(|s| !s.is_empty());
101100
// Dedup while preserving order so the operator's intent ("primary then
102101
// overlay") survives a duplicate they may have accidentally supplied.
103102
let mut seen = HashSet::new();
104-
let deduped: Vec<String> = cleaned
105-
.into_iter()
106-
.filter(|id| seen.insert(id.clone()))
107-
.collect();
103+
let deduped: Vec<String> = cleaned.filter(|id| seen.insert(id.clone())).collect();
108104
Ok(deduped)
109105
}
110106

apps/desktop/src/features/workspaces/WorkspacesPage.tsx

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,20 +1664,23 @@ function BindingForm({
16641664
}, [availableFs, fsSearch]);
16651665

16661666
// When the Space changes, drop selections that aren't in the new Space's
1667-
// FS list. Reseed an empty selection with the default FS so the operator
1668-
// doesn't have to click anything for a "single-FS, default" binding.
1667+
// FS list. In CREATE modes only, reseed an empty selection with the default
1668+
// FS so the operator doesn't have to click anything for the common case.
1669+
// In EDIT mode we never reseed — an intentionally-empty mapping ("no Space
1670+
// tools") must survive reopening.
16691671
useEffect(() => {
16701672
if (availableFs.length === 0) {
16711673
if (fsIds.length > 0) setFsIds([]);
16721674
return;
16731675
}
16741676
const validIds = new Set(availableFs.map((f) => f.id));
16751677
const filtered = fsIds.filter((id) => validIds.has(id));
1676-
if (filtered.length === 0) {
1678+
if (filtered.length !== fsIds.length) {
1679+
// Cross-space cleanup: drop ids that don't belong to this Space.
1680+
setFsIds(filtered);
1681+
} else if (filtered.length === 0 && !initial) {
16771682
const fallback = availableFs.find(isStarterFeatureSet) ?? availableFs[0];
16781683
setFsIds([fallback.id]);
1679-
} else if (filtered.length !== fsIds.length) {
1680-
setFsIds(filtered);
16811684
}
16821685
// eslint-disable-next-line react-hooks/exhaustive-deps
16831686
}, [availableFs]);
@@ -1723,10 +1726,11 @@ function BindingForm({
17231726
);
17241727
}, [isEdit, initial, trimmedRoot, spaceId, fsIds]);
17251728

1729+
// Note: an empty feature-set selection is a VALID mapping ("no Space tools";
1730+
// built-in servers still apply per Space), so it does not block Apply.
17261731
const canSubmit =
17271732
!submitting &&
17281733
!!spaceId &&
1729-
fsIds.length > 0 &&
17301734
(rootValidation.state === 'ok' || !rootEditable) &&
17311735
!duplicate &&
17321736
dirty;
@@ -1748,10 +1752,6 @@ function BindingForm({
17481752
onError('Pick a Space.');
17491753
return;
17501754
}
1751-
if (fsIds.length === 0) {
1752-
onError('Pick at least one feature set.');
1753-
return;
1754-
}
17551755
if (savedTimerRef.current) {
17561756
clearTimeout(savedTimerRef.current);
17571757
savedTimerRef.current = null;
@@ -2004,20 +2004,22 @@ function BindingForm({
20042004
{/* Saving is explicit in every mode now — nothing is written until
20052005
Apply is pressed, so the user can keep deciding without half-saved
20062006
state. In edit mode the button stays disabled until something
2007-
actually changes. */}
2007+
actually changes. An empty feature-set selection is valid and
2008+
savable. */}
20082009
<div className="pt-1 space-y-2">
2009-
{spaceId && availableFs.length > 0 && fsIds.length === 0 ? (
2010-
// Explains *why* Apply is disabledotherwise unselecting every
2011-
// feature set silently greys out the button with no reason.
2012-
<p className="text-[11px] text-amber-600 dark:text-amber-400">
2013-
Select at least one feature set to save — an empty mapping would
2014-
leave this folder with no tools.
2010+
{spaceId && fsIds.length === 0 && (
2011+
// Empty is allowedexplain what it means rather than blocking.
2012+
<p className="text-[11px] text-[rgb(var(--muted))]">
2013+
No feature sets selected — this folder gets <strong>no tools</strong>{' '}
2014+
from this Space. Built-in servers still apply per Space (see Built-in
2015+
Servers).
20152016
</p>
2016-
) : isEdit && dirty && !duplicate ? (
2017+
)}
2018+
{isEdit && dirty && !duplicate && (
20172019
<p className="text-[11px] text-amber-600 dark:text-amber-400">
20182020
Unsaved changes — press <strong>Apply changes</strong> to save.
20192021
</p>
2020-
) : null}
2022+
)}
20212023
<div className="flex items-center gap-2">
20222024
<Button
20232025
variant="primary"

crates/mcpmux-core/src/domain/workspace_binding.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ use serde::{Deserialize, Serialize};
2626
use uuid::Uuid;
2727

2828
/// A binding between a normalized workspace root and the FeatureSet(s) it
29-
/// resolves to. `feature_set_ids` is non-empty by construction — see
30-
/// [`WorkspaceBinding::new`] / `new_multi`.
29+
/// resolves to. `feature_set_ids` MAY be empty — an empty list is a valid
30+
/// "no Space tools" mapping (the folder still routes to this Space; built-in
31+
/// servers apply per Space).
3132
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3233
pub struct WorkspaceBinding {
3334
pub id: Uuid,
@@ -51,9 +52,8 @@ impl WorkspaceBinding {
5152
Self::new_multi(workspace_root, space_id, vec![feature_set_id.into()])
5253
}
5354

54-
/// Construct a binding with one or more FeatureSets. Caller must
55-
/// guarantee `feature_set_ids` is non-empty; the storage layer rejects
56-
/// empties with a validation error.
55+
/// Construct a binding with zero or more FeatureSets. An empty list is
56+
/// allowed and persists as a "no Space tools" mapping.
5757
pub fn new_multi(
5858
workspace_root: impl Into<String>,
5959
space_id: Uuid,

crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,9 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository {
198198
}
199199

200200
async fn create(&self, binding: &WorkspaceBinding) -> Result<()> {
201-
if binding.feature_set_ids.is_empty() {
202-
anyhow::bail!(
203-
"WorkspaceBinding {} must have at least one feature_set_id",
204-
binding.id
205-
);
206-
}
201+
// An empty feature_set_ids is allowed: it means "this folder gets no
202+
// Space tools" (built-in servers still apply per Space). The junction
203+
// simply ends up with zero rows.
207204
let db = self.db.lock().await;
208205
let conn = db.connection();
209206

@@ -225,12 +222,9 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository {
225222
}
226223

227224
async fn update(&self, binding: &WorkspaceBinding) -> Result<()> {
228-
if binding.feature_set_ids.is_empty() {
229-
anyhow::bail!(
230-
"WorkspaceBinding {} must have at least one feature_set_id",
231-
binding.id
232-
);
233-
}
225+
// Empty feature_set_ids is allowed — see `create`. Updating to empty
226+
// clears the junction (the folder keeps the binding but gets no Space
227+
// tools).
234228
let db = self.db.lock().await;
235229
let conn = db.connection();
236230

@@ -394,12 +388,17 @@ mod tests {
394388
}
395389

396390
#[tokio::test]
397-
async fn test_create_rejects_empty_fs_list() {
391+
async fn test_create_allows_empty_fs_list() {
392+
// An empty feature_set_ids is a valid "no Space tools" mapping — the
393+
// folder keeps the binding (so it routes to this Space) but gets no
394+
// Space tools; built-in servers still apply per Space. It round-trips
395+
// as an empty list.
398396
let (repo, space_id, _) = fixture().await;
399397
let root = if cfg!(windows) { "d:\\empty" } else { "/empty" };
400398
let binding = WorkspaceBinding::new_multi(root, space_id, vec![]);
401-
let err = repo.create(&binding).await.unwrap_err();
402-
assert!(err.to_string().contains("at least one feature_set_id"));
399+
repo.create(&binding).await.unwrap();
400+
let got = repo.get(&binding.id).await.unwrap().unwrap();
401+
assert!(got.feature_set_ids.is_empty());
403402
}
404403

405404
#[tokio::test]

tests/rust/tests/integration/effective_features.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use mcpmux_core::{
2222
normalize_workspace_root, FeatureSet, FeatureSetRepository, MemberMode, ServerFeature,
2323
ServerFeatureRepository, SpaceRepository, WorkspaceBinding, WorkspaceBindingRepository,
2424
};
25-
use mcpmux_gateway::services::{FeatureSetResolverService, SessionRootsRegistry};
25+
use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, SessionRootsRegistry};
2626
use mcpmux_gateway::{FeatureService, PrefixCacheService};
2727
use mcpmux_storage::{
2828
Database, InboundClientRepository, SqliteFeatureSetRepository, SqliteServerFeatureRepository,
@@ -176,6 +176,35 @@ async fn mapping_determines_effective_tools_per_session() {
176176
);
177177
}
178178

179+
/// An *empty* mapping (a binding with zero feature sets) is valid: the session
180+
/// routes to the Space (source = WorkspaceBinding) but sees zero Space tools.
181+
/// Built-in servers (gated per Space) are layered on by the request handler and
182+
/// aren't part of get_tools_for_grants.
183+
#[tokio::test(flavor = "multi_thread")]
184+
async fn empty_mapping_yields_zero_effective_tools() {
185+
let ctx = Ctx::new().await;
186+
let root = if cfg!(windows) {
187+
"d:\\work\\none"
188+
} else {
189+
"/work/none"
190+
};
191+
ctx.binding_repo
192+
.create(&WorkspaceBinding::new_multi(
193+
normalize_workspace_root(root),
194+
ctx.space_id,
195+
vec![],
196+
))
197+
.await
198+
.unwrap();
199+
ctx.session_roots.set("sess", [root]);
200+
ctx.session_roots.set_roots_capable("sess", true);
201+
202+
let resolved = ctx.resolver.resolve(Some("sess"), None).await.unwrap();
203+
assert_eq!(resolved.source, ResolutionSource::WorkspaceBinding);
204+
assert!(resolved.feature_set_ids.is_empty());
205+
assert!(ctx.effective_tools("sess").await.is_empty());
206+
}
207+
179208
/// A reported root with no mapping resolves to Deny → empty feature-set list
180209
/// → zero effective tools. (The gateway then appends the `mcpmux_*` Tool
181210
/// Optimization tools only when that switch is on — covered in meta_tools.)

0 commit comments

Comments
 (0)