Skip to content

Commit d88ee03

Browse files
committed
fix(gateway): reject multi-root bind with recoverable root list
mcpmux_bind_current_workspace was still first-root-wins on unpinned multi-root sessions, so a gait agent could offer to mutate sync2hire. Refuse until one root is pinned, list candidates in the error and in mcpmux_list_servers PendingRoots notes, and log pre-approval binds. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent ee12700 commit d88ee03

3 files changed

Lines changed: 98 additions & 12 deletions

File tree

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

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,43 @@ impl MetaTool for BindCurrentWorkspaceTool {
133133
.session_id
134134
.and_then(|sid| call.ctx.session_roots.get(sid))
135135
.unwrap_or_default();
136-
let root = roots.into_iter().next().ok_or_else(|| {
137-
MetaToolError::InvalidArgument(
138-
"caller did not report any MCP roots; cannot bind — \
139-
call mcpmux_set_workspace_root first to declare your workspace path, \
140-
then retry mcpmux_bind_current_workspace"
141-
.into(),
142-
)
143-
})?;
136+
// Same gate as FeatureSetResolver PendingRoots: never first-root-wins
137+
// when multiple unpinned roots are present (header pin collapses get() to 1).
138+
let root = match roots.as_slice() {
139+
[] => {
140+
return Err(MetaToolError::InvalidArgument(
141+
"caller did not report any MCP roots; cannot bind — \
142+
call mcpmux_set_workspace_root first to declare your workspace path, \
143+
then retry mcpmux_bind_current_workspace"
144+
.into(),
145+
));
146+
}
147+
[single] => single.clone(),
148+
many => {
149+
info!(
150+
session_id = ?call.session_id,
151+
client_id = %call.client_id,
152+
root_count = many.len(),
153+
reported_roots = ?many,
154+
feature_set_id = %fs_id,
155+
"[meta_tools] bind_current_workspace refused — multiple unpinned roots"
156+
);
157+
let listed = many
158+
.iter()
159+
.map(|r| format!(" - {r}"))
160+
.collect::<Vec<_>>()
161+
.join("\n");
162+
return Err(MetaToolError::InvalidArgument(format!(
163+
"cannot bind: {} workspace roots reported and none is pinned, so which \
164+
folder to mutate is ambiguous. Reported roots:\n{listed}\n\
165+
Call mcpmux_set_workspace_root with exactly one of those paths (the \
166+
workspace this agent is actually working in), then retry \
167+
mcpmux_bind_current_workspace with the same feature_set_id. A correct \
168+
X-Mcpmux-Workspace header pin also collapses this.",
169+
many.len(),
170+
)));
171+
}
172+
};
144173
let normalized = normalize_workspace_root(&root);
145174

146175
let fs_name = call
@@ -198,6 +227,18 @@ impl MetaTool for BindCurrentWorkspaceTool {
198227
let session_id_owned = call.session_id.map(str::to_owned);
199228
let caller_client_id_for_response = caller_client_id.clone();
200229
let request_machine_id = call.request_machine_id;
230+
info!(
231+
session_id = ?call.session_id,
232+
client_id = %call.client_id,
233+
chosen_root = %normalized,
234+
root_count = 1,
235+
feature_set_id = %fs_id,
236+
feature_set_name = %fs_name,
237+
request_machine_id = ?call.request_machine_id,
238+
effective_machine_id = ?machine_id,
239+
space_id = %space_id,
240+
"[meta_tools] bind_current_workspace approval requested"
241+
);
201242
with_approval(
202243
&call,
203244
"mcpmux_bind_current_workspace",

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,43 @@ use super::diagnose_server::{
1212
use super::meta_tool_common::{caller_resolution, derive_server_readiness, text_result};
1313
use super::registry::{MetaTool, MetaToolCall, MetaToolError};
1414
use crate::pool::ConnectionStatus;
15+
use crate::services::ResolutionSource;
16+
17+
/// Everything shows `readiness: "bindable"` when the resolver is at
18+
/// `PendingRoots` — indistinguishable from a genuinely unbound workspace on
19+
/// the server list alone. Give the agent a direct diagnosis instead of
20+
/// making it infer the cause from "every server is bindable".
21+
fn pending_roots_note(call: &MetaToolCall<'_>, source: ResolutionSource) -> Option<String> {
22+
if source != ResolutionSource::PendingRoots {
23+
return None;
24+
}
25+
let roots = call
26+
.session_id
27+
.and_then(|sid| call.ctx.resolver.session_roots().get(sid))
28+
.unwrap_or_default();
29+
if roots.len() > 1 {
30+
let listed = roots
31+
.iter()
32+
.map(|r| format!(" - {r}"))
33+
.collect::<Vec<_>>()
34+
.join("\n");
35+
Some(format!(
36+
"{} workspace roots reported and none is pinned, so no binding can be resolved \
37+
unambiguously — every server below shows as bindable regardless of any existing \
38+
binding. Reported roots:\n{listed}\n\
39+
Call mcpmux_set_workspace_root with exactly one of those paths (the workspace this \
40+
agent is actually working in) to disambiguate.",
41+
roots.len(),
42+
))
43+
} else {
44+
Some(
45+
"No workspace root has arrived for this session yet, so no binding can be resolved \
46+
— every server below shows as bindable regardless of any existing binding. If this \
47+
persists, call mcpmux_set_workspace_root with this session's workspace path."
48+
.to_string(),
49+
)
50+
}
51+
}
1552

1653
pub struct ListServersTool;
1754

@@ -168,6 +205,10 @@ impl MetaTool for ListServersTool {
168205
.cmp(b.get("id").and_then(|v| v.as_str()).unwrap_or(""))
169206
});
170207

171-
Ok(text_result(json!({ "servers": servers })))
208+
let mut result = json!({ "servers": servers });
209+
if let Some(note) = pending_roots_note(&call, resolved.source) {
210+
result["note"] = json!(note);
211+
}
212+
Ok(text_result(result))
172213
}
173214
}

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

Lines changed: 7 additions & 3 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 27, 2026
4-
**Status:** Complete (Phases 1–3) — Agents Window spike **done**; multi-root ambiguity gate shipped as server-side safety net
3+
**Last Updated:** Jul 28, 2026
4+
**Status:** Complete (Phases 1–3) — Agents Window spike **done**; multi-root ambiguity gate covers resolver + bind + list_servers note
55
**Branch:** `dev-rebased`
66

77
### Phase 1 spike results (Jul 20, 2026)
@@ -43,6 +43,8 @@
4343

4444
**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

46+
**Follow-up (Jul 27–28, 2026):** An unpinned dual-root session still let `mcpmux_bind_current_workspace` first-root-wins and offered to append `bundle:gait` onto `sync2hire-platform` (approval dialog). **Bind now shares the multi-root gate:** refuses with an `isError` listing the reported roots and instructing `mcpmux_set_workspace_root` with exactly one path before retry. `mcpmux_list_servers` adds a `note` with the same candidate list when resolution is `PendingRoots`. Pre-approval bind logs include `session_id` / `chosen_root` / `feature_set_id`.
47+
4648
---
4749

4850
## Problem
@@ -133,7 +135,7 @@ Cursor resolves `${workspaceFolder}` to the active window's project root *before
133135

134136
### Interaction with existing resolver tiers
135137

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.
138+
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` holds at `PendingRoots`, and `mcpmux_bind_current_workspace` refuses with a recoverable error listing candidates (multi-root ambiguity gate, Jul 27–28) — the server-side safety net for the empty-header path the Agents Window spike found.
137139

138140
---
139141

@@ -195,6 +197,8 @@ Removes the "hand-assemble JSON" friction so the bridge is actually usable by so
195197
| [`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 |
196198
| [`crates/mcpmux-gateway/src/mcp/handler.rs`](../../crates/mcpmux-gateway/src/mcp/handler.rs) | Resolver resolved log includes `workspace_root` |
197199
| [`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) |
200+
| [`crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs) | Same multi-root gate on bind; fat recoverable error + pre-approval info log |
201+
| [`crates/mcpmux-gateway/src/services/meta_tools/list_servers.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/list_servers.rs) | `PendingRoots` `note` lists candidate roots for agent self-heal |
198202
| [`docs/manual/workspace-header-routing.md`](../manual/workspace-header-routing.md) | Documents the underlying Cursor `roots`-reporting bug this bridge works around |
199203
| [`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 |
200204
| [`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 |

0 commit comments

Comments
 (0)