Skip to content

Commit 72e111b

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

9 files changed

Lines changed: 143 additions & 6 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,13 +696,15 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val
696696
session_id,
697697
space_id,
698698
workspace_root,
699+
space_locked,
699700
} => (
700701
"workspace-needs-binding",
701702
serde_json::json!({
702703
"client_id": client_id,
703704
"session_id": session_id,
704705
"space_id": space_id,
705706
"workspace_root": workspace_root,
707+
"space_locked": space_locked,
706708
}),
707709
),
708710

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ interface WorkspaceNeedsBindingPayload {
3636
session_id: string;
3737
space_id: string;
3838
workspace_root: string;
39+
/** The folder is scoped to `space_id` by a Space base directory — lock the
40+
* Space field to it (the user only picks the feature set). */
41+
space_locked?: boolean;
3942
}
4043

4144
/**
@@ -250,14 +253,16 @@ export function WorkspaceBindingSheet() {
250253
Space
251254
</div>
252255
<p className="mb-3 text-xs text-[rgb(var(--muted))]">
253-
A profile that groups MCP servers — pick the one this folder draws
254-
its tools from.
256+
{payload.space_locked
257+
? 'This folder is under a base directory of this space, so it stays in this space — just pick the feature set below.'
258+
: 'A profile that groups MCP servers — pick the one this folder draws its tools from.'}
255259
</p>
256260
<div className="relative">
257261
<select
258262
value={selectedSpaceId}
259263
onChange={(e) => setSelectedSpaceId(e.target.value)}
260-
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"
264+
disabled={payload.space_locked}
265+
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"
261266
data-testid="workspace-binding-space-picker"
262267
>
263268
{spaces.map((s) => (

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,11 @@ pub enum DomainEvent {
365365
session_id: String,
366366
space_id: Uuid,
367367
workspace_root: String,
368+
/// The folder is scoped to `space_id` by a Space base directory, so the
369+
/// mapping popup locks its Space field to it (the user only picks the
370+
/// FeatureSet). `false` for an ordinary unmapped folder, where the user
371+
/// may bind it to any Space.
372+
space_locked: bool,
368373
},
369374

370375
/// The live set of reported session roots changed (a client connected
@@ -719,6 +724,7 @@ mod tests {
719724
session_id: "sess-1".to_string(),
720725
space_id: Uuid::new_v4(),
721726
workspace_root: "/proj/foo".to_string(),
727+
space_locked: false,
722728
};
723729
assert!(!e.affects_mcp_capabilities());
724730
assert!(e.is_ui_only());
@@ -744,6 +750,7 @@ mod tests {
744750
session_id: "s".into(),
745751
space_id: Uuid::nil(),
746752
workspace_root: "/r".into(),
753+
space_locked: true,
747754
};
748755
let json = serde_json::to_string(&needs).unwrap();
749756
assert!(json.contains("\"type\":\"workspace_needs_binding\""));

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,21 @@ impl McpMuxGatewayHandler {
150150
resolved.space_id,
151151
root_for_prompt,
152152
) {
153+
// Lock the popup's Space field when the folder is scoped to
154+
// a Space by base directory — the user shouldn't be able to
155+
// bind it elsewhere.
156+
let space_locked = resolver
157+
.scoped_space_for_session(Some(sid))
158+
.await
159+
.unwrap_or(None)
160+
.is_some();
153161
services.gateway_state.read().await.emit_domain_event(
154162
mcpmux_core::DomainEvent::WorkspaceNeedsBinding {
155163
client_id: client_id.to_string(),
156164
session_id: sid.to_string(),
157165
space_id,
158166
workspace_root: root.to_string(),
167+
space_locked,
159168
},
160169
);
161170
}

crates/mcpmux-gateway/src/services/feature_set_resolver.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,20 @@ impl FeatureSetResolverService {
210210
Ok(None)
211211
}
212212

213+
/// The Space a session is scoped to by base directory — its reported root
214+
/// sits under that Space's base dir — or `None` when it isn't base-dir
215+
/// scoped (no session, no roots, or no matching base dir). The meta-tools
216+
/// use this to hard-restrict self-optimization to the matched Space.
217+
pub async fn scoped_space_for_session(&self, session_id: Option<&str>) -> Result<Option<Uuid>> {
218+
let Some(sid) = session_id else {
219+
return Ok(None);
220+
};
221+
let Some(roots) = self.session_roots.get(sid) else {
222+
return Ok(None);
223+
};
224+
self.space_for_roots(&roots).await
225+
}
226+
213227
/// Override the pending-roots grace window. `Duration::ZERO` makes the
214228
/// resolver skip the wait entirely and fall back to the Space default on
215229
/// the first pending resolution — used by tests to exercise the

crates/mcpmux-gateway/src/services/meta_tools/tools.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,30 @@ async fn caller_space_id(call: &MetaToolCall<'_>) -> Result<Uuid, MetaToolError>
7070
/// `mcpmux_list_spaces` — writes stay gated by the approval dialog, which names
7171
/// the target Space so cross-Space changes are a conscious user choice.
7272
async fn target_space_id(call: &MetaToolCall<'_>) -> Result<Uuid, MetaToolError> {
73+
// When the caller's workspace is scoped to a Space by base directory, that
74+
// Space is authoritative: the meta-tools see ONLY it (no cross-Space
75+
// targeting). An explicit `space_id` that names a different Space is
76+
// rejected; omitting it (or naming the scoped Space) resolves to it.
77+
if let Some(scoped) = call
78+
.ctx
79+
.resolver
80+
.scoped_space_for_session(call.session_id)
81+
.await?
82+
{
83+
if let Some(s) = opt_str_arg(&call.args, "space_id") {
84+
let id = Uuid::parse_str(&s).map_err(|_| {
85+
MetaToolError::InvalidArgument(format!("`space_id` is not a UUID: {s}"))
86+
})?;
87+
if id != scoped {
88+
return Err(MetaToolError::InvalidArgument(format!(
89+
"This workspace is scoped to space '{scoped}' by its base directory; \
90+
it can't target another space ('{id}')."
91+
)));
92+
}
93+
}
94+
return Ok(scoped);
95+
}
96+
7397
match opt_str_arg(&call.args, "space_id") {
7498
Some(s) => {
7599
let id = Uuid::parse_str(&s).map_err(|_| {
@@ -290,7 +314,18 @@ impl MetaTool for ListSpacesTool {
290314
}
291315

292316
async fn call(&self, call: MetaToolCall<'_>) -> Result<CallToolResult, MetaToolError> {
293-
let spaces = call.ctx.space_repo.list().await?;
317+
let mut spaces = call.ctx.space_repo.list().await?;
318+
// When the caller's workspace is scoped to a Space by base directory,
319+
// expose ONLY that Space — self-optimization must not reach across into
320+
// other Spaces' tools.
321+
if let Some(scoped) = call
322+
.ctx
323+
.resolver
324+
.scoped_space_for_session(call.session_id)
325+
.await?
326+
{
327+
spaces.retain(|s| s.id == scoped);
328+
}
294329
let spaces: Vec<_> = spaces
295330
.iter()
296331
.map(|s| {

tests/rust/tests/integration/feature_set_resolver.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,44 @@ async fn exact_binding_overrides_base_dir_scope() {
417417
assert_eq!(r.feature_set_ids, vec![f.fs_a_id.clone()]);
418418
}
419419

420+
#[tokio::test]
421+
async fn scoped_space_for_session_reports_base_dir_match() {
422+
let f = Fixture::new().await;
423+
let (base, root, outside) = if cfg!(windows) {
424+
("d:\\work", "d:\\work\\proj", "d:\\elsewhere")
425+
} else {
426+
("/work", "/work/proj", "/elsewhere")
427+
};
428+
let (work_space, _) = f.make_space_with_base_dir("Work", base).await;
429+
430+
// A session whose root is under a base dir IS scoped (the meta-tools use
431+
// this to restrict to that one Space).
432+
f.session_roots.set("s", [root]);
433+
assert_eq!(
434+
f.resolver
435+
.scoped_space_for_session(Some("s"))
436+
.await
437+
.unwrap(),
438+
Some(work_space)
439+
);
440+
441+
// A root outside every base dir is NOT scoped.
442+
f.session_roots.set("s2", [outside]);
443+
assert_eq!(
444+
f.resolver
445+
.scoped_space_for_session(Some("s2"))
446+
.await
447+
.unwrap(),
448+
None
449+
);
450+
451+
// No session / no roots → not scoped.
452+
assert_eq!(
453+
f.resolver.scoped_space_for_session(None).await.unwrap(),
454+
None
455+
);
456+
}
457+
420458
// ---------------------------------------------------------------------------
421459
// ClientGrant tier — rootless fallback
422460
// ---------------------------------------------------------------------------

tests/rust/tests/integration/workspace_binding_events.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ fn event_json_payloads_are_stable() {
209209
session_id: "s-9".to_string(),
210210
space_id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
211211
workspace_root: "/abs/path".to_string(),
212+
space_locked: false,
212213
};
213214
let v: serde_json::Value = serde_json::to_value(&needs).unwrap();
214215
assert_eq!(v["type"], "workspace_needs_binding");

tests/ts/components/WorkspaceBindingPrompt.test.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,18 @@ import { WorkspaceBindingSheet } from '@/features/workspaces/WorkspaceBindingShe
3535
const TITLE = /This folder is using your Starter set/i;
3636

3737
/** Invoke the captured `workspace-needs-binding` listener with a payload. */
38-
function fireNeedsBinding() {
38+
function fireNeedsBinding(overrides: Record<string, unknown> = {}) {
3939
const call = vi.mocked(listen).mock.calls.find((c) => c[0] === 'workspace-needs-binding');
4040
if (!call) throw new Error('workspace-needs-binding listener was not registered');
4141
const cb = call[1] as (e: { payload: unknown }) => unknown | Promise<unknown>;
4242
return cb({
43-
payload: { client_id: 'c', session_id: 's', space_id: 's1', workspace_root: '/home/u/proj' },
43+
payload: {
44+
client_id: 'c',
45+
session_id: 's',
46+
space_id: 's1',
47+
workspace_root: '/home/u/proj',
48+
...overrides,
49+
},
4450
});
4551
}
4652

@@ -86,4 +92,24 @@ describe('WorkspaceBindingSheet – mapping prompt toggle', () => {
8692
);
8793
await waitFor(() => expect(screen.queryByText(TITLE)).toBeNull());
8894
});
95+
96+
it('locks the Space picker when the folder is base-dir scoped', async () => {
97+
mockPromptEnabled(true);
98+
render(<WorkspaceBindingSheet />);
99+
await fireNeedsBinding({ space_locked: true });
100+
await screen.findByText(TITLE);
101+
102+
const picker = screen.getByTestId('workspace-binding-space-picker') as HTMLSelectElement;
103+
expect(picker.disabled).toBe(true);
104+
});
105+
106+
it('leaves the Space picker editable for an ordinary unmapped folder', async () => {
107+
mockPromptEnabled(true);
108+
render(<WorkspaceBindingSheet />);
109+
await fireNeedsBinding({ space_locked: false });
110+
await screen.findByText(TITLE);
111+
112+
const picker = screen.getByTestId('workspace-binding-space-picker') as HTMLSelectElement;
113+
expect(picker.disabled).toBe(false);
114+
});
89115
});

0 commit comments

Comments
 (0)