From bbfe0a31adf4876ea2658a2179cfddc4b76a16bb Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Thu, 18 Jun 2026 12:25:35 +0800 Subject: [PATCH] feat(gateway): default FeatureSet for unmapped roots + Mapped workspaces filter Unmapped/rootless/unknown sessions now fall back to the default Space's Starter FeatureSet (new `ResolutionSource::SpaceDefault`) instead of being denied, so a freshly-opened folder works out of the box. An explicit WorkspaceBinding is only needed when a folder should see something other than the default. Reverses the prior resolver-v3 "deny unmapped" design. - Scope: Tier 1b (roots reported, no binding) and Tier 3 (rootless / no grants / unknown) both fall back to the default Starter FS. - Grace window (~5s) holds roots-capable sessions at PendingRoots before defaulting, so a client about to report a folder resolves straight to its mapped FS instead of flashing default-then-mapped. After the grace it goes straight to the Space default, never to another client's grants. - Off-switch: empty the Starter (builtin, can't be deleted); the Deny branch is now purely defensive. - Auto-prompt kept: still emits WorkspaceNeedsBinding on SpaceDefault (gated on a folder root, so rootless sessions stay silent). UI copy reframed from "no tools until you map" to "already using your default Starter tools". - Workspaces tab: new "Mapped" filter segment + stable filter test ids. Tests: resolver fallback, grace-lapse-not-grants, empty-Starter off-switch, unbound effective features, Mapped/Unmapped filter. Updated all resolver integration tests for the new 5-arg constructor and SpaceDefault outcomes. Signed-off-by: Mohammod Al Amin Ashik --- .../src/commands/workspace_binding.rs | 20 +- .../src/features/clients/ClientsPage.tsx | 6 +- .../workspaces/WorkspaceBindingSheet.tsx | 6 +- .../features/workspaces/WorkspacesPage.tsx | 26 ++- apps/desktop/src/lib/api/workspaceBindings.ts | 2 +- crates/mcpmux-gateway/src/mcp/handler.rs | 30 ++- .../src/server/service_container.rs | 1 + .../src/services/feature_set_resolver.rs | 188 ++++++++++++++---- .../src/services/session_roots.rs | 27 +++ .../tests/integration/effective_features.rs | 70 ++++++- .../tests/integration/feature_set_resolver.rs | 144 +++++++++++--- tests/rust/tests/integration/meta_tools.rs | 3 + .../integration/workspace_binding_events.rs | 54 +++-- .../WorkspacesMappedFilter.test.tsx | 90 +++++++++ 14 files changed, 539 insertions(+), 128 deletions(-) create mode 100644 tests/ts/components/WorkspacesMappedFilter.test.tsx diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index 111e1d76..f62cc07e 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -445,11 +445,11 @@ pub struct WorkspaceEffectiveFeaturesDto { /// trailing slash, etc.). pub workspace_root: String, /// `binding` when a `WorkspaceBinding` matched the longest prefix of - /// the root; `unbound` when no binding matched. With the new resolver, - /// `unbound` means a live roots-capable session for this folder would - /// be **denied** — the `feature_sets` field below shows the default - /// Space's Default FS purely as a *preview* of what binding the folder - /// to that FS would expose, not as the active routing target. + /// the root; `unbound` when no binding matched. An `unbound` folder is + /// **not** denied — it falls back to the default Space's Starter FS, so + /// the `feature_sets` field below is exactly what a live session for this + /// folder sees right now (the active routing target), until the user + /// attaches an explicit binding to override it. pub source: String, /// `Some(id)` only when `source == "binding"`. pub binding_id: Option, @@ -588,11 +588,11 @@ pub async fn get_workspace_effective_features( b.feature_set_ids, ), None => { - // Source = `unbound` mirrors the new resolver: a live session - // here would be denied. We still surface the default Space's - // Default FS as a *preview* so the UI can render "if you bound - // this folder to , here's what it would see" — it's - // informational, not the active routing target. + // Source = `unbound` mirrors the resolver: an unmapped folder + // falls back to the default Space's Starter FS. This is the + // active routing target a live session here resolves to, not a + // hypothetical preview — the user can attach a binding to give + // the folder something other than the default. let starter_fs = state .feature_set_repository .get_starter_for_space(&default_space.id.to_string()) diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index 7500e0b3..138b528f 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -873,9 +873,9 @@ function RootlessGrantsSection({

- No defaults set — rootless sessions from this client are denied. That's the safe - default. Pick a FeatureSet above only if you trust this client to operate without a - workspace folder. + No per-client defaults set — rootless sessions from this client fall back to your + default Starter set. Pick a FeatureSet above to grant this client a specific set + instead.

)} diff --git a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx index 1121459e..495de614 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx @@ -197,9 +197,9 @@ export function WorkspaceBindingSheet() { Which tools should this folder get?

- You just opened this folder in a connected app. Choose a Space and a - feature set, and every app you open here will get exactly those - tools. + You just opened this folder in a connected app. It's already + using your default Starter tools — pick a Space and feature set to + give it a specific set instead, or keep the default.

diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 14631f70..2bcb57c7 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -92,7 +92,7 @@ export function WorkspacesPage() { const [selected, setSelected] = useState(null); const [searchQuery, setSearchQuery] = useState(''); - const [filter, setFilter] = useState<'all' | 'live' | 'unmapped'>('all'); + const [filter, setFilter] = useState<'all' | 'live' | 'mapped' | 'unmapped'>('all'); const loadData = useCallback(async () => { setError(null); @@ -205,6 +205,7 @@ export function WorkspacesPage() { const q = searchQuery.trim().toLowerCase(); return entries.filter((e) => { if (filter === 'live' && !e.isLive) return false; + if (filter === 'mapped' && !e.binding) return false; if (filter === 'unmapped' && e.kind !== 'unmapped-live') return false; if (!q) return true; const spaceName = e.binding ? spaceById.get(e.binding.space_id)?.name ?? '' : ''; @@ -223,12 +224,14 @@ export function WorkspacesPage() { const counts = useMemo(() => { let live = 0; + let mapped = 0; let unmapped = 0; for (const e of entries) { if (e.isLive) live++; + if (e.binding) mapped++; if (e.kind === 'unmapped-live') unmapped++; } - return { all: entries.length, live, unmapped }; + return { all: entries.length, live, mapped, unmapped }; }, [entries]); const selectedEntry: Entry | null = @@ -316,8 +319,9 @@ export function WorkspacesPage() { Map a folder to the tools it should get. When you open that folder in a connected app — Cursor, VS Code, Claude — McpMux serves exactly the tools you chose for it. Folders you - haven't mapped don't receive your tools until you map - them. + haven't mapped fall back to your default Starter set, so + they work out of the box — map one only when it should see + something different.

@@ -362,6 +366,7 @@ export function WorkspacesPage() { options={[ { value: 'all', label: 'All', count: counts.all }, { value: 'live', label: 'Live', count: counts.live }, + { value: 'mapped', label: 'Mapped', count: counts.mapped }, { value: 'unmapped', label: 'Unmapped', count: counts.unmapped }, ]} /> @@ -408,9 +413,8 @@ export function WorkspacesPage() { const isSelected = selected?.mode === 'entry' && selected.id === entry.id; // Mapped entries show their bound Space + FeatureSet names. - // Unmapped entries deliberately show no preview — the card - // reads "Not mapped" because the folder genuinely gets no - // tools until the user maps it. + // Unmapped entries read "Not mapped" — they fall back to the + // default Starter set rather than to an explicit binding. const resolvedSpaceName = entry.binding ? spaceById.get(entry.binding.space_id)?.name : undefined; @@ -522,6 +526,8 @@ function SegmentedFilter({ key={o.value} type="button" onClick={() => onChange(o.value)} + data-testid={`workspace-filter-${o.value}`} + aria-pressed={active} className={[ 'inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all', active @@ -692,7 +698,7 @@ function EntryCard({ ) : ( - Not mapped — no tools until you map it + Not mapped — using your default Starter tools )}
@@ -980,7 +986,7 @@ function InspectorPanel({ mode === 'create' ? 'Choose the folder and the tools it should get.' : mode === 'create-from-live' - ? 'This folder is open in an app but has no tools yet — map it.' + ? 'This folder is open in an app and using your default Starter tools — map it to give it a specific set instead.' : isMapped && entry?.binding ? `Gives ${ formatFsList( @@ -1280,7 +1286,7 @@ function EffectiveFeaturesContent({ title={ data.source === 'binding' ? 'A workspace binding matched this folder — live sessions reporting it route here.' - : 'No binding matches this folder. A live roots-capable session would be denied; the FeatureSet shown is a preview of what binding here would expose.' + : 'No binding matches this folder, so it falls back to the default Starter set shown here. Map it to give this folder a different set.' } className={[ 'ml-auto text-[10px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wider border', diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts index f1441662..ebe8cdf2 100644 --- a/apps/desktop/src/lib/api/workspaceBindings.ts +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -158,7 +158,7 @@ export interface EffectiveFeatureSetSummary { export interface WorkspaceEffectiveFeatures { workspace_root: string; - /** `binding` when a saved WorkspaceBinding matched; `unbound` when no binding matched — the `feature_sets` field previews the default Space's Default FS but a live session here would be denied. */ + /** `binding` when a saved WorkspaceBinding matched; `unbound` when no binding matched — an unbound folder falls back to the default Space's Starter FS, so `feature_sets` is what a live session here actually sees until the user attaches a binding. */ source: 'binding' | 'unbound'; binding_id: string | null; space_id: string; diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 8f3b0843..dcf428cd 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -82,9 +82,13 @@ impl McpMuxGatewayHandler { } /// Log resolver decision, emit `WorkspaceNeedsBinding` when a session - /// reports roots but no binding matched (`source=Default`), and — when - /// the session's resolved FS *flipped* from a prior value — fire a - /// per-peer `list_changed` so the client re-pulls its tools. + /// reports roots but no binding matched (`source=SpaceDefault` or `Deny`), + /// and — when the session's resolved FS *flipped* from a prior value — + /// fire a per-peer `list_changed` so the client re-pulls its tools. That + /// flip is also what broadcasts the freshly-resolved tools the moment a + /// root is reported: the resolution moves from `PendingRoots` (empty) to + /// either the folder's binding or the Space default, the fingerprint + /// changes, and the peer re-lists. /// /// `notifier` is optional: callers from contexts where peer notification /// doesn't apply (e.g. rootless init paths) can pass `None`. @@ -126,12 +130,20 @@ impl McpMuxGatewayHandler { } } - // Prompt only when the session reported a root but no - // binding matched (`Deny` with a non-empty root_for_prompt). - // PendingRoots / ClientGrant / WorkspaceBinding never - // trigger the prompt. - let should_prompt = - matches!(resolved.source, crate::services::ResolutionSource::Deny); + // Prompt only when the session reported a root that has no + // explicit binding — i.e. it fell back to the Space default + // (`SpaceDefault`), or there was no default FS to fall back to + // (`Deny`). Either way `root_for_prompt` is `Some(..)` for a + // folder-reporting session and `None` for a rootless one, so + // rootless defaults never prompt. PendingRoots / ClientGrant / + // WorkspaceBinding never trigger the prompt. The folder still + // works via the default FS meanwhile; the prompt just offers + // an explicit mapping. + let should_prompt = matches!( + resolved.source, + crate::services::ResolutionSource::Deny + | crate::services::ResolutionSource::SpaceDefault + ); if let (true, Some(sid), Some(space_id), Some(root)) = ( should_prompt, session_id, diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index 61b3fc56..49e403d2 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -108,6 +108,7 @@ impl ServiceContainer { deps.workspace_binding_repo.clone(), session_roots.clone(), deps.inbound_client_repo.clone(), + deps.feature_set_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 044865e5..eeead3e1 100644 --- a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -1,7 +1,7 @@ //! FeatureSet Resolver Service. //! -//! Capability-branched four-tier resolution. The branch point is the MCP -//! `roots` capability declared by the client at `initialize`: +//! Capability-branched resolution. The branch point is the MCP `roots` +//! capability declared by the client at `initialize`: //! //! ```text //! resolve(session_id, client_id): @@ -11,20 +11,51 @@ //! //! // Tier 1b — roots-capable, roots reported, but no binding yet //! if session reported roots AND no binding matched: -//! return ([], , Deny) // emits WorkspaceNeedsBinding upstream +//! return (default_space, [starter_fs], SpaceDefault) // unmapped folder +//! // (also emits WorkspaceNeedsBinding upstream so the user can still map) //! //! // Tier 1c — declared `roots` but they haven't arrived yet //! if session declared `roots` AND none yet in registry: -//! return ([], default_space, PendingRoots) +//! if within the pending-roots grace window: +//! return ([], default_space, PendingRoots) // wait for the root +//! else: +//! return (default_space, [starter_fs], SpaceDefault) // gave up waiting //! //! // Tier 2 — rootless-by-design (Claude.ai web, ChatGPT, …) //! if client has grants in the default space: //! return (default_space, grants, ClientGrant) //! -//! // Tier 3 — no signal at all -//! return ([], default_space, Deny) +//! // Tier 3 — no roots, no grants +//! return (default_space, [starter_fs], SpaceDefault) //! ``` //! +//! # Default fallback (the "every folder needs mapping" fix) +//! +//! When nothing more specific resolves — an unmapped folder (Tier 1b), a +//! rootless client with no grants, or a roots-capable client that never +//! reported a folder (Tier 1c after the grace window) — the resolver falls +//! back to the **default Space's Starter FeatureSet** instead of denying. +//! That makes folders work out of the box: a freshly-opened project gets the +//! Starter tools immediately, and the user only *needs* an explicit +//! [`WorkspaceBinding`](mcpmux_core::WorkspaceBinding) when they want a folder +//! to see something *other* than the default. The Starter FS's membership is +//! the control surface: edit it to change what every unmapped folder sees, or +//! empty it to grant nothing by default. (The Starter is builtin and can't be +//! deleted, so the fallback always has a target.) +//! +//! ## Grace window — avoid "default then mapped" flips +//! +//! A roots-capable client that's *about* to report a folder must resolve +//! straight to that folder's binding (or the default-for-unmapped), never +//! flash the default tools first and then flip. So while a session has +//! declared (or might declare) `roots` and none have arrived yet, the +//! resolver holds at `PendingRoots` (empty) for a short grace window rather +//! than defaulting immediately. Only once the window lapses with no root in +//! sight does it settle on `SpaceDefault`, so a misbehaving client that never +//! reports isn't stranded on meta-tools forever. A roots-capable session +//! **never** falls through to another client's grants — after the grace it +//! goes straight to the Space default, preserving per-session isolation. +//! //! The caller's client identity is used **only** for the rootless fallback — //! every roots-capable session routes via its own reported roots, regardless //! of which OAuth client opened it. This is what makes "two VS Code windows @@ -47,9 +78,10 @@ //! [`SessionRootsRegistry::set_roots_capable`]. use std::sync::Arc; +use std::time::Duration; use anyhow::Result; -use mcpmux_core::{SpaceRepository, WorkspaceBindingRepository}; +use mcpmux_core::{FeatureSetRepository, SpaceRepository, WorkspaceBindingRepository}; use mcpmux_storage::InboundClientRepository; use serde::Serialize; use tracing::{debug, warn}; @@ -57,6 +89,14 @@ use uuid::Uuid; use super::session_roots::SessionRootsRegistry; +/// How long a session that's declared (or might declare) the `roots` +/// capability is held at [`ResolutionSource::PendingRoots`] before the +/// resolver gives up waiting and falls back to the Space default. Sized to +/// comfortably outlast a well-behaved client's `initialize` → +/// `roots/list` round-trip (typically sub-second) so the grace only ever +/// catches clients that declared `roots` but never actually report one. +const DEFAULT_PENDING_ROOTS_GRACE: Duration = Duration::from_secs(5); + /// Why the resolver picked the FS(es) it picked (or didn't pick any). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] @@ -70,9 +110,17 @@ pub enum ResolutionSource { /// Rootless-by-design client. The space-default's per-client /// `client_grants` were applied. ClientGrant, - /// No FeatureSet resolved. Either no roots + no grants, or the session - /// reported roots but no binding matched (the upstream caller emits - /// `WorkspaceNeedsBinding` in that subcase). + /// Fell back to the default Space's Starter FeatureSet because nothing + /// more specific resolved — an unmapped folder, a rootless client with + /// no grants, or a roots-capable client that never reported a folder. + /// For the unmapped-folder subcase the upstream caller still emits + /// `WorkspaceNeedsBinding` so the user can attach an explicit mapping. + SpaceDefault, + /// No FeatureSet resolved at all. Defensive: reached only when there's no + /// default Space, or — degenerately — the default Space somehow has no + /// Starter FeatureSet. The Starter is builtin and seeded with every Space, + /// so this is normally unreachable; to grant nothing by default the user + /// empties the Starter (still `SpaceDefault`, just with no members). Deny, } @@ -114,6 +162,13 @@ pub struct FeatureSetResolverService { /// Reads `client_grants` for the rootless Tier-2 fallback. Stored as a /// concrete repo (storage owns this type and there's only ever one). client_repo: Arc, + /// Looks up each Space's Starter FeatureSet for the default fallback + /// (Tier 1b / Tier 1c-after-grace / Tier 3). + feature_set_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. + pending_grace: Duration, } impl FeatureSetResolverService { @@ -122,15 +177,61 @@ impl FeatureSetResolverService { binding_repo: Arc, session_roots: Arc, client_repo: Arc, + feature_set_repo: Arc, ) -> Self { Self { space_repo, binding_repo, session_roots, client_repo, + feature_set_repo, + pending_grace: DEFAULT_PENDING_ROOTS_GRACE, } } + /// 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 + /// post-grace path without a real delay. + pub fn with_pending_grace(mut self, grace: Duration) -> Self { + self.pending_grace = grace; + self + } + + /// Fall back to the default Space's Starter FeatureSet. 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 { + if let Some(fs) = self + .feature_set_repo + .get_starter_for_space(&default_space_id.to_string()) + .await? + { + debug!( + space_id = %default_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), + source: ResolutionSource::SpaceDefault, + }); + } + debug!( + space_id = %default_space_id, + "[FeatureSetResolver] no Starter FeatureSet in default Space — deny", + ); + Ok(ResolvedFeatureSet { + feature_set_ids: vec![], + space_id: Some(default_space_id), + source: ResolutionSource::Deny, + }) + } + /// Borrow the session-roots registry. The notifier uses this to GC /// dead sessions out of the registry when reaping the corresponding /// peer entries — keeping both stores in sync. @@ -211,14 +312,14 @@ impl FeatureSetResolverService { source: ResolutionSource::WorkspaceBinding, }); } - // Tier 1b: had roots, no binding — deny + upstream emits - // WorkspaceNeedsBinding so the user can choose an FS. - debug!("[FeatureSetResolver] roots reported but no binding matched — deny",); - return Ok(ResolvedFeatureSet { - feature_set_ids: vec![], - space_id: Some(default_space_id), - source: ResolutionSource::Deny, - }); + // 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; } // Tier 1c: client declared `roots` but none have ARRIVED yet @@ -233,16 +334,37 @@ impl FeatureSetResolverService { // and fall through to Tier 2 so a granted-but-folderless client // still gets its tools. if !roots_arrived && !matches!(roots_capable_known, Some(false)) { + // Grace window: hold at PendingRoots (empty) while the client + // still might report a folder, so it resolves straight to + // that folder's binding (or the default-for-unmapped) instead + // of flashing the Space default and then flipping. Stamps + // first-seen on the first pending resolve and measures from + // there. + let elapsed = self.session_roots.elapsed_since_first_seen(sid); + if elapsed < self.pending_grace { + debug!( + session_id = %sid, + capability = ?roots_capable_known, + elapsed_ms = elapsed.as_millis(), + "[FeatureSetResolver] roots-capable (or unknown), roots pending — empty until they arrive", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: vec![], + space_id: Some(default_space_id), + source: ResolutionSource::PendingRoots, + }); + } + // Grace lapsed with no root in sight — settle on the Space + // default rather than stranding the client on meta-tools + // forever. Go STRAIGHT to the default (not via Tier-2 grants): + // a roots-capable session must never pick up another client's + // grants (per-session isolation invariant). debug!( session_id = %sid, capability = ?roots_capable_known, - "[FeatureSetResolver] roots-capable (or unknown), roots pending — empty until they arrive", + "[FeatureSetResolver] pending-roots grace lapsed, no root reported — SpaceDefault", ); - return Ok(ResolvedFeatureSet { - feature_set_ids: vec![], - space_id: Some(default_space_id), - source: ResolutionSource::PendingRoots, - }); + return self.default_fallback(default_space_id).await; } } @@ -275,20 +397,16 @@ impl FeatureSetResolverService { } } - // Tier 3 — no roots, no grants. Deny. - // The mcpmux_* meta tools are still appended unconditionally by the - // request handler, so the LLM can self-bind / ask the user for - // a grant from this state. + // Tier 3 — no roots, no grants. Fall back to the Space default so a + // bare client still gets the Starter tools instead of nothing. The + // mcpmux_* meta tools are appended unconditionally by the request + // handler regardless, so the LLM can always self-bind / ask the user + // for a grant from here. debug!( space_id = %default_space_id, ?client_id, - "[FeatureSetResolver] no roots + no grants — deny", + "[FeatureSetResolver] no roots + no grants — SpaceDefault", ); - - Ok(ResolvedFeatureSet { - feature_set_ids: vec![], - space_id: Some(default_space_id), - source: ResolutionSource::Deny, - }) + self.default_fallback(default_space_id).await } } diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index 858c8652..9cd8694f 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -59,6 +59,13 @@ pub struct SessionRootsRegistry { /// result landed — exactly the bug that left Claude Code's /// VS Code extension showing only the meta tools. probe_lock: DashMap>>, + /// `session_id -> Instant the resolver first saw this session with no + /// roots yet`. Stamped lazily by [`Self::elapsed_since_first_seen`] so + /// the resolver's `PendingRoots` tier can wait a grace window for a root + /// to arrive before falling back to the Space default — preventing a + /// roots-capable client from flashing the default FeatureSet and then + /// flipping to its mapped one the instant its root lands. + first_seen: DashMap, } impl SessionRootsRegistry { @@ -69,9 +76,24 @@ impl SessionRootsRegistry { roots_capable: DashMap::new(), last_probe: DashMap::new(), probe_lock: DashMap::new(), + first_seen: DashMap::new(), }) } + /// Elapsed time since this session was first observed without roots, + /// stamping "now" on the first call. The resolver uses this to bound the + /// `PendingRoots` wait: while the result is below the grace window it + /// keeps waiting for a root; past it, it settles on the Space default. + /// Idempotent — the timestamp is only set once per session and cleared by + /// [`Self::remove`]. + pub fn elapsed_since_first_seen(&self, session_id: &str) -> Duration { + let first = *self + .first_seen + .entry(session_id.to_string()) + .or_insert_with(Instant::now); + first.elapsed() + } + /// Get (or create) the per-session probe lock. The returned Arc is /// what the handler awaits to serialize concurrent probes — see /// [`Self::probe_lock`] for the rationale. @@ -144,6 +166,7 @@ impl SessionRootsRegistry { self.roots_capable.remove(session_id); self.last_probe.remove(session_id); self.probe_lock.remove(session_id); + self.first_seen.remove(session_id); } /// Compare-and-set the session's resolved feature-set id. Returns `true` @@ -228,6 +251,10 @@ impl SessionRootsRegistry { self.map.remove(&sid); self.last_resolution.remove(&sid); self.last_probe.remove(&sid); + // Reset the grace clock too, so the re-probed session waits afresh + // for its root to re-arrive instead of immediately defaulting on a + // stale first-seen timestamp. + self.first_seen.remove(&sid); } dropped.sort(); diff --git a/tests/rust/tests/integration/effective_features.rs b/tests/rust/tests/integration/effective_features.rs index 06ddc227..3c80b800 100644 --- a/tests/rust/tests/integration/effective_features.rs +++ b/tests/rust/tests/integration/effective_features.rs @@ -113,6 +113,7 @@ impl Ctx { binding_repo.clone(), session_roots.clone(), client_repo.clone(), + fs_repo.clone(), ); let feature_service = FeatureService::new(feature_repo.clone(), fs_repo.clone(), prefix_cache); @@ -266,12 +267,27 @@ async fn empty_mapping_yields_zero_effective_tools() { assert!(ctx.effective_tools("sess").await.is_empty()); } -/// A reported root with no mapping resolves to Deny → empty feature-set list -/// → zero effective tools. (The gateway then appends the `mcpmux_*` Tool -/// Optimization tools only when that switch is on — covered in meta_tools.) +/// A reported root with no mapping falls back to the default Space's Starter +/// FS (the "every folder needs mapping" fix) → it sees exactly the Starter's +/// tools, not nothing. We drop one tool into the Starter and confirm the +/// unmapped session resolves to `SpaceDefault` and sees precisely that tool. #[tokio::test(flavor = "multi_thread")] -async fn unbound_session_sees_zero_effective_tools() { +async fn unbound_session_falls_back_to_starter_fs() { let ctx = Ctx::new().await; + + // Put one tool in the default Space's Starter FS so the fallback is + // observable (the seeded Starter is otherwise empty). + let starter = ctx + .fs_repo + .get_starter_for_space(&ctx.space_id_str) + .await + .unwrap() + .expect("default Space has a Starter FS"); + ctx.fs_repo + .add_feature_member(&starter.id, &ctx.gh_issue_id, MemberMode::Include) + .await + .unwrap(); + let root = if cfg!(windows) { "d:\\work\\unmapped" } else { @@ -280,6 +296,52 @@ async fn unbound_session_sees_zero_effective_tools() { ctx.session_roots.set("sess", [root]); ctx.session_roots.set_roots_capable("sess", true); + let resolved = ctx.resolver.resolve(Some("sess"), None).await.unwrap(); + assert_eq!(resolved.source, ResolutionSource::SpaceDefault); + assert_eq!(resolved.feature_set_ids, vec![starter.id]); + assert_eq!( + ctx.effective_tools("sess").await, + vec!["create_issue".to_string()], + ); +} + +/// The "grant nothing by default" off-switch: the Starter is builtin and can't +/// be deleted, but an operator can EMPTY it. An empty Starter still resolves +/// (source `SpaceDefault`), but yields zero effective tools — so unmapped +/// folders see nothing until they're either bound or the Starter is populated. +/// The seeded Starter starts empty, which is exactly this state. +#[tokio::test(flavor = "multi_thread")] +async fn empty_starter_grants_nothing_to_unbound_session() { + let ctx = Ctx::new().await; + + // Sanity-check the precondition: the seeded Starter has no members. + let starter = ctx + .fs_repo + .get_starter_for_space(&ctx.space_id_str) + .await + .unwrap() + .expect("default Space has a Starter FS"); + assert!( + ctx.fs_repo + .get_feature_members(&starter.id) + .await + .unwrap() + .is_empty(), + "seeded Starter should start empty", + ); + + let root = if cfg!(windows) { + "d:\\work\\unmapped-empty" + } else { + "/work/unmapped-empty" + }; + ctx.session_roots.set("sess", [root]); + ctx.session_roots.set_roots_capable("sess", true); + + let resolved = ctx.resolver.resolve(Some("sess"), None).await.unwrap(); + assert_eq!(resolved.source, ResolutionSource::SpaceDefault); + assert_eq!(resolved.feature_set_ids, vec![starter.id]); + // Resolves to the Starter, but it grants nothing. assert!(ctx.effective_tools("sess").await.is_empty()); } diff --git a/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs index e34bac12..88dec7a5 100644 --- a/tests/rust/tests/integration/feature_set_resolver.rs +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -1,17 +1,24 @@ -//! Decision-table tests for the FeatureSet resolver (capability-branched v3). +//! Decision-table tests for the FeatureSet resolver (capability-branched). //! //! Outcomes: //! 1. **WorkspaceBinding** — session reported roots AND a binding matched //! one of them. `space_id` + `feature_set_ids[0]` come from the binding. //! 2. **PendingRoots** — session declared MCP `roots` capability but the -//! list hasn't arrived yet. Empty FS list; resolver fires -//! `list_changed` later when roots populate. +//! list hasn't arrived yet and the grace window hasn't lapsed. Empty FS +//! list; resolver fires `list_changed` later when roots populate. //! 3. **ClientGrant** — rootless-by-design client. Per-client grants //! from the `client_grants` table apply. -//! 4. **Deny** — every other case (roots reported but no binding; no -//! session id and no grants; etc.). Empty FS list. +//! 4. **SpaceDefault** — fell back to the default Space's Starter FS +//! because nothing more specific resolved: an unmapped folder (roots +//! reported, no binding), a rootless client with no grants, or a +//! roots-capable client that never reported a folder once the grace +//! window lapsed. +//! 5. **Deny** — defensive only: no default Space, or (degenerately) the +//! default Space has no Starter FS. The Starter is builtin/seeded so this +//! is normally unreachable. Empty FS list. use std::sync::Arc; +use std::time::Duration; use mcpmux_core::{ normalize_workspace_root, FeatureSet, FeatureSetRepository, SpaceRepository, WorkspaceBinding, @@ -28,9 +35,14 @@ use uuid::Uuid; struct Fixture { resolver: FeatureSetResolverService, session_roots: Arc, + space_repo: Arc, binding_repo: Arc, + fs_repo: Arc, client_repo: Arc, space_id: Uuid, + /// The default Space's auto-seeded Starter FS — the target of every + /// `SpaceDefault` fallback. + starter_fs_id: String, fs_a_id: String, fs_b_id: String, } @@ -48,6 +60,19 @@ impl Fixture { let default_space = space_repo.get_default().await.unwrap().unwrap(); let space_id = default_space.id; + // The default Space is seeded with its Starter FS by migrations; make + // sure it's present so the `SpaceDefault` fallback has a target. + fs_repo + .ensure_builtin_for_space(&space_id.to_string()) + .await + .unwrap(); + let starter_fs_id = fs_repo + .get_starter_for_space(&space_id.to_string()) + .await + .unwrap() + .expect("default space should have a Starter FS") + .id; + let a = FeatureSet::new_custom("A", space_id.to_string()); let b = FeatureSet::new_custom("B", space_id.to_string()); fs_repo.create(&a).await.unwrap(); @@ -61,19 +86,37 @@ impl Fixture { binding_repo.clone(), session_roots.clone(), client_repo.clone(), + fs_repo.clone(), ); Self { resolver, session_roots, + space_repo, binding_repo, + fs_repo, client_repo, space_id, + starter_fs_id, fs_a_id, fs_b_id, } } + /// Build a second resolver over the same repos with a custom grace + /// window — used to exercise the post-grace `SpaceDefault` fallback + /// deterministically (grace = 0) without sleeping. + fn resolver_with_grace(&self, grace: Duration) -> FeatureSetResolverService { + FeatureSetResolverService::new( + self.space_repo.clone(), + self.binding_repo.clone(), + self.session_roots.clone(), + self.client_repo.clone(), + self.fs_repo.clone(), + ) + .with_pending_grace(grace) + } + /// 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) { @@ -115,15 +158,16 @@ fn test_root() -> &'static str { } // --------------------------------------------------------------------------- -// Deny tier +// SpaceDefault tier — the "every folder needs mapping" fallback // --------------------------------------------------------------------------- #[tokio::test] -async fn deny_when_no_session_id_and_no_grants() { +async fn default_when_no_session_id_and_no_grants() { let f = Fixture::new().await; let r = f.resolver.resolve(None, None).await.unwrap(); - assert_eq!(r.source, ResolutionSource::Deny); - assert!(r.feature_set_ids.is_empty()); + // No session, no grants → fall back to the default Space's Starter FS. + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); assert_eq!(r.space_id, Some(f.space_id)); } @@ -143,28 +187,37 @@ async fn pending_when_session_has_no_roots_and_capability_unknown() { } #[tokio::test] -async fn deny_when_session_explicitly_rootless_and_no_grants() { +async fn default_when_session_explicitly_rootless_and_no_grants() { // Explicit Some(false) capability — client told us it doesn't - // support roots — and no client grants. This is the only path where - // the resolver legitimately lands on Deny without a session id. + // support roots — and no client grants. It told us it has no folder, + // so settle straight on the Space default (no grace wait needed). let f = Fixture::new().await; f.session_roots.set_roots_capable("rootless", false); let r = f.resolver.resolve(Some("rootless"), None).await.unwrap(); - assert_eq!(r.source, ResolutionSource::Deny); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); } #[tokio::test] -async fn deny_when_roots_reported_but_no_binding_matches() { +async fn default_when_roots_reported_but_no_binding_matches() { let f = Fixture::new().await; let other = if cfg!(windows) { "d:\\tmp" } else { "/tmp" }; f.session_roots.set("sess", [other]); let r = f.resolver.resolve(Some("sess"), None).await.unwrap(); - // Roots present but no binding → upstream emits WorkspaceNeedsBinding; - // resolver itself reports Deny (no FS to apply). - assert_eq!(r.source, ResolutionSource::Deny); - assert!(r.feature_set_ids.is_empty()); + // Roots present but no binding → the folder is unmapped, so it falls + // back to the default Space's Starter FS (and upstream still emits + // WorkspaceNeedsBinding so the user can attach an explicit mapping). + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); + assert_eq!(r.space_id, Some(f.space_id)); } +// Note: the "no Starter FS → Deny" branch is purely defensive — the Starter +// is builtin and seeded with every Space, so it can't be removed through the +// public API. The user's real "grant nothing by default" lever is *emptying* +// the Starter (it still resolves to SpaceDefault, just with no members); that +// off-switch is proven end-to-end in `effective_features.rs`. + // --------------------------------------------------------------------------- // PendingRoots tier // --------------------------------------------------------------------------- @@ -202,10 +255,11 @@ async fn binding_routes_to_its_target_space_and_fs() { } #[tokio::test] -async fn no_inheritance_child_of_bound_parent_denies() { +async fn no_inheritance_child_of_bound_parent_falls_back_to_default() { // Inheritance is intentionally NOT supported: a session whose reported root // is a CHILD of a bound parent does not pick up the parent's binding. With - // no exact binding of its own, it resolves to Deny. + // no exact binding of its own it's an unmapped folder → SpaceDefault (the + // child does NOT inherit the parent's FS A). let f = Fixture::new().await; let (parent, child) = if cfg!(windows) { ("d:\\work", "d:\\work\\proj") @@ -221,12 +275,14 @@ async fn no_inheritance_child_of_bound_parent_denies() { .await .unwrap(); - // Child reports its root, no exact binding for it → Deny (no inheritance). + // Child reports its root, no exact binding for it → SpaceDefault (no + // inheritance of the parent's FS A). f.session_roots.set("child", [child]); f.session_roots.set_roots_capable("child", true); let r = f.resolver.resolve(Some("child"), None).await.unwrap(); - assert_eq!(r.source, ResolutionSource::Deny); - assert!(r.feature_set_ids.is_empty()); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); + assert_ne!(r.feature_set_ids, vec![f.fs_a_id.clone()]); // The parent's own exact root still resolves to its binding. f.session_roots.set("parent", [parent]); @@ -262,7 +318,7 @@ async fn rootless_client_uses_grants() { } #[tokio::test] -async fn rootless_client_without_grants_denies() { +async fn rootless_client_without_grants_falls_back_to_default() { let f = Fixture::new().await; let client_id = "rootless.example/no-grants"; f.make_client(client_id).await; @@ -272,8 +328,9 @@ async fn rootless_client_without_grants_denies() { .resolve(Some("s"), Some(client_id)) .await .unwrap(); - assert_eq!(r.source, ResolutionSource::Deny); - assert!(r.feature_set_ids.is_empty()); + // Rootless + no grants → Space default rather than nothing. + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); } #[tokio::test] @@ -304,15 +361,16 @@ async fn roots_arrived_empty_falls_through_to_grants() { } #[tokio::test] -async fn roots_arrived_empty_without_grants_denies() { - // Same arrived-empty state but no grants → Deny, NOT PendingRoots, so the - // session settles instead of re-probing `roots/list` forever. +async fn roots_arrived_empty_without_grants_falls_back_to_default() { + // Same arrived-empty state but no grants → SpaceDefault, NOT PendingRoots, + // so the session settles (on the Space default) instead of re-probing + // `roots/list` forever. let f = Fixture::new().await; f.session_roots.set_roots_capable("s", true); f.session_roots.set("s", Vec::::new()); let r = f.resolver.resolve(Some("s"), None).await.unwrap(); - assert_eq!(r.source, ResolutionSource::Deny); - assert!(r.feature_set_ids.is_empty()); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); } #[tokio::test] @@ -338,6 +396,30 @@ async fn capable_session_does_not_fall_through_to_grants() { assert!(r.feature_set_ids.is_empty()); } +#[tokio::test] +async fn pending_roots_grace_lapse_falls_back_to_space_default_not_grants() { + // After the grace window lapses with no root reported, a roots-capable + // session settles on the Space DEFAULT — never on another client's + // grants. This proves both halves of the grace design: + // 1. it stops waiting (→ SpaceDefault, not a perpetual PendingRoots), and + // 2. it preserves per-session isolation (→ NOT ClientGrant, even though + // this client has a grant). + let f = Fixture::new().await; + let resolver = f.resolver_with_grace(Duration::ZERO); + let client_id = "slow.example/client"; + f.make_client(client_id).await; + f.client_repo + .grant_feature_set(client_id, &f.space_id.to_string(), &f.fs_a_id) + .await + .unwrap(); + + f.session_roots.set_roots_capable("s", true); // capable, but no roots ever arrive + let r = resolver.resolve(Some("s"), Some(client_id)).await.unwrap(); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); + assert_ne!(r.feature_set_ids, vec![f.fs_a_id.clone()]); +} + // --------------------------------------------------------------------------- // Session-keyed routing — one client, many concurrent sessions // --------------------------------------------------------------------------- diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 376d6ed6..430eab5d 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -107,6 +107,7 @@ impl Fixture { binding_repo.clone(), session_roots.clone(), inbound_client_repo.clone(), + feature_set_repo.clone(), )); let prefix_cache = Arc::new(PrefixCacheService::new()); @@ -1056,6 +1057,7 @@ async fn bare_registry( binding_repo.clone(), SessionRootsRegistry::new(), inbound_client_repo.clone(), + feature_set_repo.clone(), )); let prefix_cache = Arc::new(PrefixCacheService::new()); let feature_service = Arc::new(FeatureService::new( @@ -1166,6 +1168,7 @@ async fn per_space_config_controls_registry_visibility() { binding_repo.clone(), SessionRootsRegistry::new(), inbound_client_repo.clone(), + feature_set_repo.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 223430f0..ef3a3f8b 100644 --- a/tests/rust/tests/integration/workspace_binding_events.rs +++ b/tests/rust/tests/integration/workspace_binding_events.rs @@ -7,11 +7,12 @@ //! //! 1. `WorkspaceBindingChanged` + `WorkspaceNeedsBinding` round-trip through //! JSON with the shape the Tauri bridge and the frontend consumers expect. -//! 2. The resolver's decision table: roots + no binding → `source = Deny` -//! (the trigger the gateway uses to decide whether to emit the -//! `WorkspaceNeedsBinding` prompt). -//! 3. Creating / updating a binding flips the next resolution from Deny to -//! WorkspaceBinding — the behaviour that justifies firing list_changed. +//! 2. The resolver's decision table: roots + no binding → `source = +//! SpaceDefault` (the folder falls back to the default Starter FS, and the +//! same condition still triggers the `WorkspaceNeedsBinding` prompt). +//! 3. Creating / updating a binding flips the next resolution from +//! SpaceDefault to WorkspaceBinding — the behaviour that justifies firing +//! list_changed. use std::sync::Arc; @@ -57,6 +58,7 @@ impl Ctx { binding_repo.clone(), session_roots.clone(), inbound_client_repo.clone(), + fs_repo.clone(), ); Self { @@ -70,12 +72,13 @@ impl Ctx { } /// After creating a binding for the root the next resolve flips from -/// `Deny` (roots reported, nothing bound — the condition -/// `handler.rs::log_and_notify_resolution` turns into a -/// `WorkspaceNeedsBinding` prompt) to `WorkspaceBinding`. In production the -/// flip is what triggers the `WorkspaceBindingChanged` → `list_changed` -/// broadcast. (The standalone "unbound → Deny" case is covered by the -/// resolver decision-table in `feature_set_resolver.rs`.) +/// `SpaceDefault` (roots reported, nothing bound — the unmapped folder falls +/// back to the default Starter FS, and `handler.rs::log_and_notify_resolution` +/// still turns this into a `WorkspaceNeedsBinding` prompt) to +/// `WorkspaceBinding`. In production the flip is what triggers the +/// `WorkspaceBindingChanged` → `list_changed` broadcast. (The standalone +/// "unbound → SpaceDefault" case is covered by the resolver decision-table in +/// `feature_set_resolver.rs`.) #[tokio::test(flavor = "multi_thread")] async fn creating_binding_flips_next_resolution_source() { let ctx = Ctx::new().await; @@ -92,7 +95,11 @@ async fn creating_binding_flips_next_resolution_source() { ctx.session_roots.set_roots_capable("sess-1", true); let before = ctx.resolver.resolve(Some("sess-1"), None).await.unwrap(); - assert_eq!(before.source, ResolutionSource::Deny); + assert_eq!(before.source, ResolutionSource::SpaceDefault); + // Unmapped folder gets a non-empty fallback FS (the Starter), so the + // fingerprint is `Some(..)` and flips to the bound FS below — that + // change is exactly what fires the per-peer `list_changed`. + assert!(!before.feature_set_ids.is_empty()); let binding = WorkspaceBinding::new(root, ctx.space_id, ctx.fs_custom_id.clone()); ctx.binding_repo.create(&binding).await.unwrap(); @@ -102,12 +109,14 @@ async fn creating_binding_flips_next_resolution_source() { assert_eq!(after.feature_set_ids, vec![ctx.fs_custom_id.clone()]); } -/// Rootless session without client grants resolves to `Deny`. No -/// `WorkspaceNeedsBinding` is appropriate here (rootless = nothing to -/// bind). This pins the rootless-silence contract — if it ever fails, the -/// notifier would start prompting users with no folder context. +/// Rootless session without client grants resolves to `SpaceDefault` (the +/// default Starter FS) — but *silently*: no `WorkspaceNeedsBinding` is +/// appropriate here (rootless = nothing to bind). The handler enforces that +/// silence by passing `root_for_prompt = None` for rootless sessions; this +/// test pins the resolver half — a rootless session never lands on a +/// folder-bearing source. #[tokio::test(flavor = "multi_thread")] -async fn rootless_session_without_grants_denies_silently() { +async fn rootless_session_without_grants_defaults_silently() { let ctx = Ctx::new().await; // Deliberately no roots set; capability stamped as false (rootless). ctx.session_roots.set_roots_capable("rootless", false); @@ -116,7 +125,7 @@ async fn rootless_session_without_grants_denies_silently() { .resolve(Some("rootless"), Some("unknown-client")) .await .unwrap(); - assert_eq!(resolved.source, ResolutionSource::Deny); + assert_eq!(resolved.source, ResolutionSource::SpaceDefault); } /// Binding → different Space should actually route the session to that @@ -149,6 +158,7 @@ async fn binding_to_non_default_space_reroutes_session() { binding_repo.clone(), session_roots.clone(), inbound_client_repo.clone(), + fs_repo.clone(), ); let raw = if cfg!(windows) { @@ -160,11 +170,11 @@ async fn binding_to_non_default_space_reroutes_session() { session_roots.set("sess-X", [raw]); session_roots.set_roots_capable("sess-X", true); - // Before binding: roots reported, no binding → Deny in the default - // space (the resolver still reports a space_id so the upstream prompt - // knows where to scope the binding sheet). + // Before binding: roots reported, no binding → SpaceDefault scoped to the + // default space (the resolver still reports a space_id so the upstream + // prompt knows where to scope the binding sheet). let before = resolver.resolve(Some("sess-X"), None).await.unwrap(); - assert_eq!(before.source, ResolutionSource::Deny); + assert_eq!(before.source, ResolutionSource::SpaceDefault); assert_eq!(before.space_id, Some(default_space.id)); // Create a binding targeting `other` space's Custom FS. diff --git a/tests/ts/components/WorkspacesMappedFilter.test.tsx b/tests/ts/components/WorkspacesMappedFilter.test.tsx new file mode 100644 index 00000000..8ea0ee92 --- /dev/null +++ b/tests/ts/components/WorkspacesMappedFilter.test.tsx @@ -0,0 +1,90 @@ +/** + * Workspaces tab — "Mapped" filter segment. + * + * The Workspaces list unions live-reported roots with saved bindings. The + * segmented filter lets the user narrow to a slice; this covers the new + * "Mapped" segment (folders that have an explicit binding), alongside the + * existing "Unmapped" one, so the two stay mutually exclusive. + * + * `@mcpmux/ui` is aliased to the real source in vitest.config so the real + * controls render. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const { listWorkspaceBindingsMock, listReportedWorkspaceRootsMock } = vi.hoisted(() => ({ + listWorkspaceBindingsMock: vi.fn(), + listReportedWorkspaceRootsMock: vi.fn(), +})); + +vi.mock('@/lib/api/workspaceBindings', () => ({ + listWorkspaceBindings: listWorkspaceBindingsMock, + listReportedWorkspaceRoots: listReportedWorkspaceRootsMock, + clearUnmappedReportedRoots: vi.fn(), + createWorkspaceBinding: vi.fn(), + updateWorkspaceBinding: vi.fn(), + deleteWorkspaceBinding: vi.fn(), + getWorkspaceEffectiveFeatures: vi.fn(), + validateWorkspaceRoot: vi.fn(), +})); + +vi.mock('@/lib/api/featureSets', () => ({ + listFeatureSets: vi + .fn() + .mockResolvedValue([ + { id: 'fs1', name: 'Set One', feature_set_type: 'custom', members: [] }, + ]), + isStarterFeatureSet: vi.fn(() => false), +})); + +vi.mock('@/stores', () => ({ + useSpaces: () => [{ id: 's1', name: 'Space One' }], +})); + +import { WorkspacesPage } from '@/features/workspaces/WorkspacesPage'; + +const MAPPED_ROOT = '/home/u/mapped'; +const UNMAPPED_ROOT = '/home/u/unmapped'; +const MAPPED_TESTID = 'workspace-entry-b1'; +const UNMAPPED_TESTID = `workspace-entry-live:${UNMAPPED_ROOT}`; + +describe('WorkspacesPage – Mapped/Unmapped filter', () => { + beforeEach(() => { + // One folder with a binding (mapped) and one live-reported folder with no + // binding (unmapped). Both are live, so the "Live" filter keeps both. + listWorkspaceBindingsMock.mockResolvedValue([ + { id: 'b1', workspace_root: MAPPED_ROOT, space_id: 's1', feature_set_ids: ['fs1'] }, + ]); + listReportedWorkspaceRootsMock.mockResolvedValue([MAPPED_ROOT, UNMAPPED_ROOT]); + }); + + it('shows both entries under the default "All" filter', async () => { + render(); + expect(await screen.findByTestId(MAPPED_TESTID)).toBeTruthy(); + expect(screen.getByTestId(UNMAPPED_TESTID)).toBeTruthy(); + }); + + it('"Mapped" shows only the bound folder', async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId(MAPPED_TESTID); + + await user.click(screen.getByTestId('workspace-filter-mapped')); + + expect(screen.getByTestId(MAPPED_TESTID)).toBeTruthy(); + await waitFor(() => expect(screen.queryByTestId(UNMAPPED_TESTID)).toBeNull()); + }); + + it('"Unmapped" shows only the folder without a binding', async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId(UNMAPPED_TESTID); + + await user.click(screen.getByTestId('workspace-filter-unmapped')); + + expect(screen.getByTestId(UNMAPPED_TESTID)).toBeTruthy(); + await waitFor(() => expect(screen.queryByTestId(MAPPED_TESTID)).toBeNull()); + }); +});