Skip to content

Commit bbfe0a3

Browse files
committed
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 <maa.ashik00@gmail.com>
1 parent 6868992 commit bbfe0a3

14 files changed

Lines changed: 539 additions & 128 deletions

File tree

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -445,11 +445,11 @@ pub struct WorkspaceEffectiveFeaturesDto {
445445
/// trailing slash, etc.).
446446
pub workspace_root: String,
447447
/// `binding` when a `WorkspaceBinding` matched the longest prefix of
448-
/// the root; `unbound` when no binding matched. With the new resolver,
449-
/// `unbound` means a live roots-capable session for this folder would
450-
/// be **denied** — the `feature_sets` field below shows the default
451-
/// Space's Default FS purely as a *preview* of what binding the folder
452-
/// to that FS would expose, not as the active routing target.
448+
/// the root; `unbound` when no binding matched. An `unbound` folder is
449+
/// **not** denied — it falls back to the default Space's Starter FS, so
450+
/// the `feature_sets` field below is exactly what a live session for this
451+
/// folder sees right now (the active routing target), until the user
452+
/// attaches an explicit binding to override it.
453453
pub source: String,
454454
/// `Some(id)` only when `source == "binding"`.
455455
pub binding_id: Option<String>,
@@ -588,11 +588,11 @@ pub async fn get_workspace_effective_features(
588588
b.feature_set_ids,
589589
),
590590
None => {
591-
// Source = `unbound` mirrors the new resolver: a live session
592-
// here would be denied. We still surface the default Space's
593-
// Default FS as a *preview* so the UI can render "if you bound
594-
// this folder to <FS>, here's what it would see" — it's
595-
// informational, not the active routing target.
591+
// Source = `unbound` mirrors the resolver: an unmapped folder
592+
// falls back to the default Space's Starter FS. This is the
593+
// active routing target a live session here resolves to, not a
594+
// hypothetical preview — the user can attach a binding to give
595+
// the folder something other than the default.
596596
let starter_fs = state
597597
.feature_set_repository
598598
.get_starter_for_space(&default_space.id.to_string())

apps/desktop/src/features/clients/ClientsPage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -873,9 +873,9 @@ function RootlessGrantsSection({
873873
<div className="mt-3 flex items-start gap-2 rounded-lg border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] p-2.5">
874874
<ShieldOff className="mt-0.5 h-4 w-4 flex-shrink-0 text-[rgb(var(--muted))]" />
875875
<p className="text-[11px] text-[rgb(var(--muted))]">
876-
No defaults set — rootless sessions from this client are denied. That&apos;s the safe
877-
default. Pick a FeatureSet above only if you trust this client to operate without a
878-
workspace folder.
876+
No per-client defaults set — rootless sessions from this client fall back to your
877+
default Starter set. Pick a FeatureSet above to grant this client a specific set
878+
instead.
879879
</p>
880880
</div>
881881
)}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,9 @@ export function WorkspaceBindingSheet() {
197197
Which tools should this folder get?
198198
</h2>
199199
<p className="mt-2 text-sm text-[rgb(var(--muted))]">
200-
You just opened this folder in a connected app. Choose a Space and a
201-
feature set, and every app you open here will get exactly those
202-
tools.
200+
You just opened this folder in a connected app. It&apos;s already
201+
using your default Starter tools — pick a Space and feature set to
202+
give it a specific set instead, or keep the default.
203203
</p>
204204

205205
<div className="mt-5 flex items-start gap-3 rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-4 py-3">

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

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export function WorkspacesPage() {
9292

9393
const [selected, setSelected] = useState<Selected | null>(null);
9494
const [searchQuery, setSearchQuery] = useState('');
95-
const [filter, setFilter] = useState<'all' | 'live' | 'unmapped'>('all');
95+
const [filter, setFilter] = useState<'all' | 'live' | 'mapped' | 'unmapped'>('all');
9696

9797
const loadData = useCallback(async () => {
9898
setError(null);
@@ -205,6 +205,7 @@ export function WorkspacesPage() {
205205
const q = searchQuery.trim().toLowerCase();
206206
return entries.filter((e) => {
207207
if (filter === 'live' && !e.isLive) return false;
208+
if (filter === 'mapped' && !e.binding) return false;
208209
if (filter === 'unmapped' && e.kind !== 'unmapped-live') return false;
209210
if (!q) return true;
210211
const spaceName = e.binding ? spaceById.get(e.binding.space_id)?.name ?? '' : '';
@@ -223,12 +224,14 @@ export function WorkspacesPage() {
223224

224225
const counts = useMemo(() => {
225226
let live = 0;
227+
let mapped = 0;
226228
let unmapped = 0;
227229
for (const e of entries) {
228230
if (e.isLive) live++;
231+
if (e.binding) mapped++;
229232
if (e.kind === 'unmapped-live') unmapped++;
230233
}
231-
return { all: entries.length, live, unmapped };
234+
return { all: entries.length, live, mapped, unmapped };
232235
}, [entries]);
233236

234237
const selectedEntry: Entry | null =
@@ -316,8 +319,9 @@ export function WorkspacesPage() {
316319
Map a folder to the tools it should get. When you open that
317320
folder in a connected app — Cursor, VS Code, Claude — McpMux
318321
serves exactly the tools you chose for it. Folders you
319-
haven&apos;t mapped don&apos;t receive your tools until you map
320-
them.
322+
haven&apos;t mapped fall back to your default Starter set, so
323+
they work out of the box — map one only when it should see
324+
something different.
321325
</p>
322326
</div>
323327
<div className="flex-shrink-0 flex items-center gap-2">
@@ -362,6 +366,7 @@ export function WorkspacesPage() {
362366
options={[
363367
{ value: 'all', label: 'All', count: counts.all },
364368
{ value: 'live', label: 'Live', count: counts.live },
369+
{ value: 'mapped', label: 'Mapped', count: counts.mapped },
365370
{ value: 'unmapped', label: 'Unmapped', count: counts.unmapped },
366371
]}
367372
/>
@@ -408,9 +413,8 @@ export function WorkspacesPage() {
408413
const isSelected =
409414
selected?.mode === 'entry' && selected.id === entry.id;
410415
// Mapped entries show their bound Space + FeatureSet names.
411-
// Unmapped entries deliberately show no preview — the card
412-
// reads "Not mapped" because the folder genuinely gets no
413-
// tools until the user maps it.
416+
// Unmapped entries read "Not mapped" — they fall back to the
417+
// default Starter set rather than to an explicit binding.
414418
const resolvedSpaceName = entry.binding
415419
? spaceById.get(entry.binding.space_id)?.name
416420
: undefined;
@@ -522,6 +526,8 @@ function SegmentedFilter<T extends string>({
522526
key={o.value}
523527
type="button"
524528
onClick={() => onChange(o.value)}
529+
data-testid={`workspace-filter-${o.value}`}
530+
aria-pressed={active}
525531
className={[
526532
'inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all',
527533
active
@@ -692,7 +698,7 @@ function EntryCard({
692698
) : (
693699
<span className="inline-flex items-center gap-1.5 font-medium text-amber-600 dark:text-amber-400">
694700
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
695-
Not mapped — no tools until you map it
701+
Not mapped — using your default Starter tools
696702
</span>
697703
)}
698704
</div>
@@ -980,7 +986,7 @@ function InspectorPanel({
980986
mode === 'create'
981987
? 'Choose the folder and the tools it should get.'
982988
: mode === 'create-from-live'
983-
? 'This folder is open in an app but has no tools yet — map it.'
989+
? 'This folder is open in an app and using your default Starter tools — map it to give it a specific set instead.'
984990
: isMapped && entry?.binding
985991
? `Gives ${
986992
formatFsList(
@@ -1280,7 +1286,7 @@ function EffectiveFeaturesContent({
12801286
title={
12811287
data.source === 'binding'
12821288
? 'A workspace binding matched this folder — live sessions reporting it route here.'
1283-
: '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.'
1289+
: '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.'
12841290
}
12851291
className={[
12861292
'ml-auto text-[10px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wider border',

apps/desktop/src/lib/api/workspaceBindings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export interface EffectiveFeatureSetSummary {
158158

159159
export interface WorkspaceEffectiveFeatures {
160160
workspace_root: string;
161-
/** `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. */
161+
/** `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. */
162162
source: 'binding' | 'unbound';
163163
binding_id: string | null;
164164
space_id: string;

crates/mcpmux-gateway/src/mcp/handler.rs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,13 @@ impl McpMuxGatewayHandler {
8282
}
8383

8484
/// Log resolver decision, emit `WorkspaceNeedsBinding` when a session
85-
/// reports roots but no binding matched (`source=Default`), and — when
86-
/// the session's resolved FS *flipped* from a prior value — fire a
87-
/// per-peer `list_changed` so the client re-pulls its tools.
85+
/// reports roots but no binding matched (`source=SpaceDefault` or `Deny`),
86+
/// and — when the session's resolved FS *flipped* from a prior value —
87+
/// fire a per-peer `list_changed` so the client re-pulls its tools. That
88+
/// flip is also what broadcasts the freshly-resolved tools the moment a
89+
/// root is reported: the resolution moves from `PendingRoots` (empty) to
90+
/// either the folder's binding or the Space default, the fingerprint
91+
/// changes, and the peer re-lists.
8892
///
8993
/// `notifier` is optional: callers from contexts where peer notification
9094
/// doesn't apply (e.g. rootless init paths) can pass `None`.
@@ -126,12 +130,20 @@ impl McpMuxGatewayHandler {
126130
}
127131
}
128132

129-
// Prompt only when the session reported a root but no
130-
// binding matched (`Deny` with a non-empty root_for_prompt).
131-
// PendingRoots / ClientGrant / WorkspaceBinding never
132-
// trigger the prompt.
133-
let should_prompt =
134-
matches!(resolved.source, crate::services::ResolutionSource::Deny);
133+
// Prompt only when the session reported a root that has no
134+
// explicit binding — i.e. it fell back to the Space default
135+
// (`SpaceDefault`), or there was no default FS to fall back to
136+
// (`Deny`). Either way `root_for_prompt` is `Some(..)` for a
137+
// folder-reporting session and `None` for a rootless one, so
138+
// rootless defaults never prompt. PendingRoots / ClientGrant /
139+
// WorkspaceBinding never trigger the prompt. The folder still
140+
// works via the default FS meanwhile; the prompt just offers
141+
// an explicit mapping.
142+
let should_prompt = matches!(
143+
resolved.source,
144+
crate::services::ResolutionSource::Deny
145+
| crate::services::ResolutionSource::SpaceDefault
146+
);
135147
if let (true, Some(sid), Some(space_id), Some(root)) = (
136148
should_prompt,
137149
session_id,

crates/mcpmux-gateway/src/server/service_container.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ impl ServiceContainer {
108108
deps.workspace_binding_repo.clone(),
109109
session_roots.clone(),
110110
deps.inbound_client_repo.clone(),
111+
deps.feature_set_repo.clone(),
111112
));
112113

113114
// Authorization service is now a thin adapter over the resolver.

0 commit comments

Comments
 (0)