Skip to content

Commit ee12700

Browse files
committed
fix(gateway): gate ambiguous multi-root sessions to PendingRoots
A roots-capable session reporting multiple folders (no pinned X-Mcpmux-Workspace header) was silently resolving to the first matching binding instead of holding until unambiguous. Reuses the existing PendingRoots pattern; mcpmux_set_workspace_root or a header pin remains the escape hatch. Reconciles the Agents Window spike doc with its actual finding (session isolation works; this gate is the fix for the real bug it surfaced) and flips the stale status on the rootless declare-root gate doc, which already shipped Jul 23. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent c0e3196 commit ee12700

4 files changed

Lines changed: 108 additions & 10 deletions

File tree

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

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
//! ```text
88
//! resolve(session_id, client_id):
99
//! // Signal 1 — reported root (deprecated MCP primitive, SEP-2577)
10-
//! if session reported roots AND a binding matches:
11-
//! return (binding.space_id, [binding.feature_set_id], WorkspaceBinding)
10+
//! if session reported roots:
11+
//! if roots.len() > 1 (no pinned X-Mcpmux-Workspace header):
12+
//! return ([], default_space, PendingRoots) // ambiguous — never guess
13+
//! if a binding matches the (single) root:
14+
//! return (binding.space_id, [binding.feature_set_id], WorkspaceBinding)
1215
//!
1316
//! // Tier 1b — roots reported, no binding matched
1417
//! if session reported roots AND no binding matched:
@@ -67,6 +70,14 @@
6770
//! through to another client's grants — after the grace it goes straight to
6871
//! `Unbound`, preserving per-session isolation.
6972
//!
73+
//! Multi-root ambiguity is a separate, non-timed hold: when
74+
//! [`SessionRootsRegistry::get`](crate::services::session_roots::SessionRootsRegistry::get)
75+
//! returns more than one root (no pinned `X-Mcpmux-Workspace` header), the
76+
//! resolver stays at `PendingRoots` indefinitely until the client pins a
77+
//! single root (header or `mcpmux_set_workspace_root`). Unlike the in-flight
78+
//! grace window, time alone cannot resolve which open folder the request
79+
//! belongs to — guessing would silently route to the wrong FeatureSet.
80+
//!
7081
//! The caller's client identity is used **only** for the rootless Tier-2 grant
7182
//! lookup — every roots-capable session routes via its own reported roots,
7283
//! regardless of which OAuth client opened it. This is what makes "two VS Code
@@ -135,8 +146,9 @@ pub enum ResolutionSource {
135146
/// A [`WorkspaceBinding`](mcpmux_core::WorkspaceBinding) matched one of
136147
/// the session's reported MCP roots.
137148
WorkspaceBinding,
138-
/// No binding matched, but the client is roots-capable so its `roots`
139-
/// list is in flight; return empty and re-resolve when they arrive.
149+
/// Held empty pending an unambiguous root: roots still in flight (grace
150+
/// window), a rootless client awaiting `mcpmux_set_workspace_root`, or a
151+
/// multi-root session with no pinned `X-Mcpmux-Workspace` header.
140152
PendingRoots,
141153
/// Rootless-by-design client. The space-default's per-client
142154
/// `client_grants` were applied.
@@ -504,6 +516,27 @@ impl FeatureSetResolverService {
504516
// (no ancestor inheritance).
505517
if has_roots {
506518
let reported_roots = roots.expect("has_roots implies Some");
519+
520+
// Ambiguous multi-root session: SessionRootsRegistry::get() only
521+
// returns more than one entry when there's no pinned
522+
// X-Mcpmux-Workspace header collapsing it to a single root (see
523+
// SessionRootsRegistry::get). Never guess which open folder this
524+
// request belongs to — hold at PendingRoots (meta tools, incl.
525+
// mcpmux_set_workspace_root, remain reachable) until the client
526+
// pins one explicitly.
527+
if reported_roots.len() > 1 {
528+
debug!(
529+
session_id = %sid,
530+
root_count = reported_roots.len(),
531+
"[FeatureSetResolver] multiple roots reported, no pinned header — PendingRoots",
532+
);
533+
return Ok(ResolvedFeatureSet {
534+
feature_set_ids: vec![],
535+
space_id: Some(deny_space_id),
536+
source: ResolutionSource::PendingRoots,
537+
});
538+
}
539+
507540
if let Some(binding) = self
508541
.find_binding_for_roots(&reported_roots, client_id, request_machine_id)
509542
.await?

docs/planning/cursor-workspace-routing-bridge.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Cursor Workspace Routing via Global `mcp-remote` Bridge
22

3-
**Last Updated:** Jul 24, 2026
4-
**Status:** Complete (Phases 1–3) — Agents Window multi-workspace spike **pending** (observability logs shipped)
3+
**Last Updated:** Jul 27, 2026
4+
**Status:** Complete (Phases 1–3) — Agents Window spike **done**; multi-root ambiguity gate shipped as server-side safety net
55
**Branch:** `dev-rebased`
66

77
### Phase 1 spike results (Jul 20, 2026)
@@ -41,7 +41,7 @@
4141

4242
**Next if fail:** prefer per-repo static `.cursor/mcp.json` header for Agents Window, and/or treat as Cursor Agents Window MCP binding gap (not a new per-agent identity axis).
4343

44-
**Spike results:** _pending manual run_
44+
**Spike results (Jul 24–27, 2026):** Session isolation works — Agents Window agents in separate workspaces get distinct `session_id`s and (when present) distinct `X-Mcpmux-Workspace` headers; no pin-clobber. Identical tool answers in the repro were overlapping FeatureSets, not shared-session clobber. Real bug found: some sessions arrive with an empty/absent workspace header, so `SessionRootsRegistry::get()` returns the full multi-folder `roots/list` and the resolver used to first-match-wins across that list. **Fix shipped:** resolver returns `PendingRoots` whenever `reported_roots.len() > 1` (no pinned header); escape hatch is `mcpmux_set_workspace_root` / a correct header pin. Complements the bridge's header injection as a server-side safety net.
4545

4646
---
4747

@@ -133,7 +133,7 @@ Cursor resolves `${workspaceFolder}` to the active window's project root *before
133133

134134
### Interaction with existing resolver tiers
135135

136-
No resolver changes. The bridge is purely a transport-layer trick to get `X-Mcpmux-Workspace` populated correctly — the gateway already treats that header as authoritative and pins it ahead of probed `roots` (`session_roots.rs`, `SessionRootsRegistry`). This feature doesn't touch `feature_set_resolver.rs`, `workspace_binding_repository.rs`, or any migration.
136+
The bridge is a transport-layer trick to get `X-Mcpmux-Workspace` populated correctly — the gateway already treats that header as authoritative and pins it ahead of probed `roots` (`session_roots.rs`, `SessionRootsRegistry`). When the header is absent and `roots/list` returns multiple folders, `feature_set_resolver.rs` now holds at `PendingRoots` instead of first-match-wins (multi-root ambiguity gate, Jul 27) — the server-side safety net for the empty-header path the Agents Window spike found.
137137

138138
---
139139

@@ -194,6 +194,7 @@ Removes the "hand-assemble JSON" friction so the bridge is actually usable by so
194194
| [`crates/mcpmux-gateway/src/services/session_roots.rs`](../../crates/mcpmux-gateway/src/services/session_roots.rs) | `X-Mcpmux-Workspace` pin is authoritative; Agents Window spike adds pin/clobber info+warn logs |
195195
| [`crates/mcpmux-gateway/src/mcp/oauth_middleware.rs`](../../crates/mcpmux-gateway/src/mcp/oauth_middleware.rs) | `→ MCP` logs `session_id` + `workspace_header`; warns when pin skipped |
196196
| [`crates/mcpmux-gateway/src/mcp/handler.rs`](../../crates/mcpmux-gateway/src/mcp/handler.rs) | Resolver resolved log includes `workspace_root` |
197+
| [`crates/mcpmux-gateway/src/services/feature_set_resolver.rs`](../../crates/mcpmux-gateway/src/services/feature_set_resolver.rs) | Multi-root ambiguity → `PendingRoots` when `get()` returns >1 root (no pin) |
197198
| [`docs/manual/workspace-header-routing.md`](../manual/workspace-header-routing.md) | Documents the underlying Cursor `roots`-reporting bug this bridge works around |
198199
| [`docs/planning/upstream-client-mapping-reconciliation.md`](./upstream-client-mapping-reconciliation.md) | Phase 1 — `mcpk_` API-key auth, reused here as the bridge's auth mechanism |
199200
| [`apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx`](../../apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx) | Existing API-key minting UI this feature's Phase 2 panel is modeled on |

docs/planning/rootless-declare-root-gate.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Rootless Declare-Before-Grant Gate
22

3-
**Last Updated:** Jul 23, 2026
4-
**Status:** Planningready to implement
3+
**Last Updated:** Jul 27, 2026
4+
**Status:** CompletePhase 1+2 shipped Jul 23 (`dff476f`, `38f1df1` on `dev-rebased`)
55
**Branch:** `dev-rebased` (fork-only for now; no upstream port planned)
66
**Depends on:** [`deny-by-default-bindable-callers.md`](./deny-by-default-bindable-callers.md) (Tier 4 `Unbound`, `ClientGrant` Tier 3), [`per-device-machine-header.md`](./per-device-machine-header.md), [`workspace-machine-binding.md`](./workspace-machine-binding.md)
77
**Unblocks:** Cloud Agent / rootless clients get scoped-by-repo tools instead of a permanent blanket grant

tests/rust/tests/integration/feature_set_resolver.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,70 @@ async fn roots_capable_unmapped_root_stays_unbound_not_grant() {
714714
assert_ne!(r.feature_set_ids, vec![f.fs_a_id]);
715715
}
716716

717+
#[tokio::test]
718+
async fn roots_capable_multi_root_session_is_pending_not_first_match() {
719+
// Ambiguity gate: a roots-capable session reporting multiple folders
720+
// (no pinned X-Mcpmux-Workspace header) must NOT silently bind to the
721+
// first matching root — hold at PendingRoots until the client pins one.
722+
let f = Fixture::new().await;
723+
let (root_a, root_b) = if cfg!(windows) {
724+
("d:\\work\\a", "d:\\work\\b")
725+
} else {
726+
("/work/a", "/work/b")
727+
};
728+
f.binding_repo
729+
.create(&WorkspaceBinding::new(
730+
normalize_workspace_root(root_a),
731+
f.space_id,
732+
f.fs_a_id.clone(),
733+
))
734+
.await
735+
.unwrap();
736+
737+
f.session_roots.set("s", [root_a, root_b]);
738+
f.session_roots.set_roots_capable("s", true);
739+
let r = f.resolver.resolve(Some("s"), None, None).await.unwrap();
740+
assert_eq!(r.source, ResolutionSource::PendingRoots);
741+
assert!(r.feature_set_ids.is_empty());
742+
assert_ne!(r.feature_set_ids, vec![f.fs_a_id]);
743+
}
744+
745+
#[tokio::test]
746+
async fn roots_capable_multi_root_session_unblocked_by_pinned_header() {
747+
// Same multi-root setup as above, but a pinned header collapses get() to
748+
// a single root and resolution resumes via WorkspaceBinding.
749+
let f = Fixture::new().await;
750+
let (root_a, root_b) = if cfg!(windows) {
751+
("d:\\work\\a", "d:\\work\\b")
752+
} else {
753+
("/work/a", "/work/b")
754+
};
755+
f.binding_repo
756+
.create(&WorkspaceBinding::new(
757+
normalize_workspace_root(root_a),
758+
f.space_id,
759+
f.fs_a_id.clone(),
760+
))
761+
.await
762+
.unwrap();
763+
f.binding_repo
764+
.create(&WorkspaceBinding::new(
765+
normalize_workspace_root(root_b),
766+
f.space_id,
767+
f.fs_b_id.clone(),
768+
))
769+
.await
770+
.unwrap();
771+
772+
f.session_roots.set("s", [root_a, root_b]);
773+
f.session_roots.set_roots_capable("s", true);
774+
f.session_roots.set_pinned("s", root_b);
775+
776+
let r = f.resolver.resolve(Some("s"), None, None).await.unwrap();
777+
assert_eq!(r.source, ResolutionSource::WorkspaceBinding);
778+
assert_eq!(r.feature_set_ids, vec![f.fs_b_id]);
779+
}
780+
717781
#[tokio::test]
718782
async fn rootless_client_without_grants_falls_back_to_default() {
719783
let f = Fixture::new().await;

0 commit comments

Comments
 (0)