Skip to content

Commit efabe48

Browse files
committed
feat(gateway): constrain workspace pins to the caller's open folder set
Cursor fails to substitute ${workspaceFolder} in ~21% of mcp-remote spawns (282 sampled), and mcp-remote then expands the unresolved literal to an empty X-Mcpmux-Workspace header. Probing the child environment found no reliable active-folder signal to fall back on: CURSOR_WORKSPACE_LABEL is stale, VSCODE_PID and VSCODE_IPC_HOOK are app-level rather than per-window, and position within WORKSPACE_FOLDER_PATHS identifies the active folder only 70% of the time. A 30% misroute rate would leak the wrong space's credentials into the wrong repo, so nothing here infers a folder. What WORKSPACE_FOLDER_PATHS does guarantee is membership: the active folder was in the set in 212 of 212 resolved multi-folder spawns. Carry it as a new X-Mcpmux-Workspace-Set header and use it strictly as a constraint. A one-member set pins (unambiguous by construction); larger sets only record candidates. mcpmux_set_workspace_root now refuses a root the calling window doesn't have open, closing a self-service grant where any approved client could name any path and receive that workspace's FeatureSet and credentials. Clients that send no set header stay permissive. bind_current_workspace names the open folders in its no-roots refusal instead of leaving the agent to guess. Also corrects the empty-header warning, which blamed the Agents window; the probe shows editor windows fail at 29% versus 4% for Agents windows. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent a8a2f4a commit efabe48

6 files changed

Lines changed: 263 additions & 11 deletions

File tree

apps/desktop/src/features/clients/cursor-bridge-config.helpers.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ export const CURSOR_BRIDGE_CLIENT_NAME = 'cursor-global-bridge';
55
* Build the `~/.cursor/mcp.json` snippet for the global mcp-remote bridge.
66
*
77
* Cursor resolves `${workspaceFolder}` in `args` at spawn time, so one global
8-
* entry routes each window to the correct workspace header.
8+
* entry routes each window to the correct workspace header. That substitution
9+
* is unreliable (measured at ~21% failure across 282 spawns), and when it
10+
* fails `mcp-remote` expands the leftover literal to an empty header value.
11+
* `${WORKSPACE_FOLDER_PATHS}` is not a Cursor variable, so it survives to
12+
* `mcp-remote`, which expands it from the child environment — giving the
13+
* gateway the window's full folder set even when the active folder is missing.
14+
* The set constrains which root the session may claim; it does not pick one.
915
*/
1016
export function buildCursorBridgeMcpJson(apiKey: string, gatewayUrl: string): string {
1117
const mcpUrl = `${gatewayUrl.replace(/\/$/, '')}/mcp`;
@@ -21,6 +27,8 @@ export function buildCursorBridgeMcpJson(apiKey: string, gatewayUrl: string): st
2127
'--header',
2228
'X-Mcpmux-Workspace:${workspaceFolder}',
2329
'--header',
30+
'X-Mcpmux-Workspace-Set:${WORKSPACE_FOLDER_PATHS}',
31+
'--header',
2432
'Authorization:Bearer ${MCPMUX_API_KEY}',
2533
],
2634
env: { MCPMUX_API_KEY: apiKey },

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ pub async fn mcp_oauth_middleware(
245245
// client can claim any binding (see FeatureSetResolver trust model). Keyed
246246
// by the `mcp-session-id` the client echoes on every post-initialize
247247
// request (the same key the handler stores reported roots under).
248-
let (session_id_header, workspace_header) = {
248+
let (session_id_header, workspace_header, workspace_set_header) = {
249249
let headers = request.headers();
250250
let sid = headers
251251
.get("mcp-session-id")
@@ -255,16 +255,24 @@ pub async fn mcp_oauth_middleware(
255255
.get("x-mcpmux-workspace")
256256
.and_then(|v| v.to_str().ok())
257257
.map(str::to_owned);
258-
(sid, ws)
258+
let ws_set = headers
259+
.get("x-mcpmux-workspace-set")
260+
.and_then(|v| v.to_str().ok())
261+
.map(str::to_owned);
262+
(sid, ws, ws_set)
259263
};
260264
match (&session_id_header, &workspace_header) {
261265
(_, Some(ws)) if ws.trim().is_empty() => {
262266
warn!(
263267
trace_id = %trace_id,
264268
session_id = session_id_header.as_deref().unwrap_or("<none>"),
265269
"[SessionRoots] X-Mcpmux-Workspace present but empty — pin skipped \
266-
(Cursor Agents window often spawns mcp-remote without resolving \
267-
${{workspaceFolder}}; see docs/manual/cursor-workspace-bridge.md Fallback)",
270+
(Cursor did not substitute ${{workspaceFolder}} before spawning \
271+
mcp-remote, which then expanded the unresolved literal to empty). \
272+
Affects editor and Agents windows alike; recover with \
273+
mcpmux_set_workspace_root, or install a per-repo static header to \
274+
avoid substitution entirely — \
275+
see docs/manual/cursor-workspace-bridge.md Fallback",
268276
);
269277
}
270278
(Some(sid), Some(ws)) => {
@@ -288,6 +296,30 @@ pub async fn mcp_oauth_middleware(
288296
_ => {}
289297
}
290298

299+
// The calling window's full folder set (`X-Mcpmux-Workspace-Set`, carrying
300+
// Cursor's `WORKSPACE_FOLDER_PATHS`). Recorded as a constraint on which
301+
// roots this session may claim, and as the candidate list shown when the
302+
// workspace header above failed to resolve. A set of one also pins,
303+
// because one candidate cannot be ambiguous. Held across initialize like
304+
// the workspace header, since both arrive before `mcp-session-id`.
305+
match (&session_id_header, &workspace_set_header) {
306+
(_, Some(set)) if set.trim().is_empty() => {}
307+
(Some(sid), Some(set)) => {
308+
services.session_roots.set_candidates(sid, set);
309+
}
310+
(None, Some(set)) => {
311+
services
312+
.session_roots
313+
.remember_pending_candidates(&client_id, set);
314+
}
315+
(Some(sid), None) => {
316+
services
317+
.session_roots
318+
.apply_pending_candidates(&client_id, sid);
319+
}
320+
_ => {}
321+
}
322+
291323
// Captured before `request` is consumed below — needed to recognize the
292324
// spec-correct GET shapes (pre-init SSE, post-timeout reconnect) when
293325
// deciding whether to warn on the response status.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ fn redact_headers_compact(headers: &axum::http::HeaderMap) -> String {
4747
| "mcp-protocol-version"
4848
| "last-event-id"
4949
| "x-mcpmux-workspace"
50+
| "x-mcpmux-workspace-set"
5051
| "x-mcpmux-machine-id"
5152
)
5253
})

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,31 @@ impl MetaTool for BindCurrentWorkspaceTool {
137137
// when multiple unpinned roots are present (header pin collapses get() to 1).
138138
let root = match roots.as_slice() {
139139
[] => {
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-
));
140+
// The window's folder set (X-Mcpmux-Workspace-Set) usually
141+
// survives even when the active-folder header came through
142+
// empty, so name those folders rather than making the caller
143+
// guess what to declare.
144+
let candidates = call
145+
.session_id
146+
.and_then(|sid| call.ctx.session_roots.get_candidates(sid))
147+
.unwrap_or_default();
148+
let known = if candidates.is_empty() {
149+
String::new()
150+
} else {
151+
format!(
152+
" This window has these folders open:\n{}\n",
153+
candidates
154+
.iter()
155+
.map(|c| format!(" - {c}"))
156+
.collect::<Vec<_>>()
157+
.join("\n")
158+
)
159+
};
160+
return Err(MetaToolError::InvalidArgument(format!(
161+
"caller did not report any MCP roots; cannot bind.{known} \
162+
Call mcpmux_set_workspace_root to declare the workspace this agent is \
163+
actually working in, then retry mcpmux_bind_current_workspace."
164+
)));
146165
}
147166
[single] => single.clone(),
148167
many => {

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,24 @@ impl MetaTool for SetWorkspaceRootTool {
7070
)));
7171
}
7272

73+
// A caller may only declare a folder its own window actually has open.
74+
// Without this the tool is a self-service grant: any approved client
75+
// could name any path and receive that workspace's FeatureSet and
76+
// credentials. The candidate set comes from the spawning host's
77+
// environment rather than the caller, so it's the stronger claim.
78+
if !call.ctx.session_roots.is_candidate(session_id, &normalized) {
79+
let candidates = call
80+
.ctx
81+
.session_roots
82+
.get_candidates(session_id)
83+
.unwrap_or_default();
84+
return Err(MetaToolError::InvalidArgument(format!(
85+
"workspace_root `{normalized}` is not open in this window. \
86+
Declare one of: {}",
87+
candidates.join(", ")
88+
)));
89+
}
90+
7391
call.ctx
7492
.session_roots
7593
.set(session_id, std::iter::once(normalized.as_str()));

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

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,49 @@ pub struct SessionRootsRegistry {
8585
/// before `mcp-session-id` (initialize). Applied on the first request
8686
/// that has a session id, or when initialize's response issues one.
8787
pending_by_client: DashMap<String, String>,
88+
/// `session_id -> the full set of folders open in the calling window`,
89+
/// sourced from the `X-Mcpmux-Workspace-Set` header (Cursor's
90+
/// `WORKSPACE_FOLDER_PATHS`, expanded by `mcp-remote`).
91+
///
92+
/// This is a **constraint, never a resolver**. Measurement across 212
93+
/// multi-folder spawns found the active folder is always a member of this
94+
/// set, but its position within the set identifies the active folder only
95+
/// 70% of the time — so no ordering heuristic is safe for a routing
96+
/// decision that gates credentials. Two things it is good for:
97+
/// collapsing a one-member set to a pin (unambiguous), and rejecting a
98+
/// [`set_workspace_root`](super::meta_tools) pin that names a folder the
99+
/// calling window doesn't even have open.
100+
candidates: DashMap<String, Vec<String>>,
101+
/// `client_id -> candidate set` held when `X-Mcpmux-Workspace-Set` arrived
102+
/// before `mcp-session-id`, mirroring [`Self::pending_by_client`].
103+
pending_candidates_by_client: DashMap<String, Vec<String>>,
88104
/// Per-session active search index keyed by `(feature_set_ids fingerprint, index)`.
89105
/// Shared with [`MetaToolContext`](crate::services::meta_tools::MetaToolContext)
90106
/// so `mcpmux_search_tools` can reuse a session's resolved tool index.
91107
search_cache: Arc<DashMap<String, (u64, Arc<ToolIndex>)>>,
92108
}
93109

110+
/// Split an `X-Mcpmux-Workspace-Set` header into normalized folder paths,
111+
/// sorted and deduped.
112+
///
113+
/// ponytail: splits on `,` because that is the delimiter Cursor uses for
114+
/// `WORKSPACE_FOLDER_PATHS`, so a folder whose own name contains a comma will
115+
/// shatter into bogus entries. The ceiling is acceptable because the set is
116+
/// only ever a constraint: a shattered entry can't match a real root, so the
117+
/// worst case is a pin rejection that falls back to today's behavior rather
118+
/// than a misroute. Upgrade path is a length-prefixed or JSON-array header if
119+
/// Cursor ever offers one.
120+
fn parse_candidate_set(raw_set: &str) -> Vec<String> {
121+
let mut parsed: Vec<String> = raw_set
122+
.split(',')
123+
.map(normalize_workspace_root)
124+
.filter(|path| !path.is_empty())
125+
.collect();
126+
parsed.sort();
127+
parsed.dedup();
128+
parsed
129+
}
130+
94131
impl SessionRootsRegistry {
95132
pub fn new() -> Arc<Self> {
96133
Arc::new(Self {
@@ -102,6 +139,8 @@ impl SessionRootsRegistry {
102139
first_seen: DashMap::new(),
103140
pinned: DashMap::new(),
104141
pending_by_client: DashMap::new(),
142+
candidates: DashMap::new(),
143+
pending_candidates_by_client: DashMap::new(),
105144
search_cache: Arc::new(DashMap::new()),
106145
})
107146
}
@@ -285,6 +324,77 @@ impl SessionRootsRegistry {
285324
self.pinned.get(session_id).map(|v| v.clone())
286325
}
287326

327+
/// Record the calling window's full folder set from the
328+
/// `X-Mcpmux-Workspace-Set` header.
329+
///
330+
/// A one-member set is unambiguous, so it pins directly — that is the only
331+
/// case where this header decides a route. Larger sets are stored as a
332+
/// constraint for [`Self::is_candidate`] and for naming candidates in
333+
/// refusals; they never select a folder on their own.
334+
pub fn set_candidates(&self, session_id: &str, raw_set: &str) {
335+
self.store_candidates(session_id, parse_candidate_set(raw_set));
336+
}
337+
338+
fn store_candidates(&self, session_id: &str, parsed: Vec<String>) {
339+
if parsed.is_empty() {
340+
return;
341+
}
342+
if let [only] = parsed.as_slice() {
343+
// The active folder is always a member of the set, so a set of one
344+
// names it outright — no guessing involved.
345+
info!(
346+
%session_id,
347+
workspace_root = %only,
348+
"[SessionRoots] single-folder X-Mcpmux-Workspace-Set — pinned without ambiguity",
349+
);
350+
self.set_pinned(session_id, only);
351+
}
352+
self.candidates.insert(session_id.to_string(), parsed);
353+
}
354+
355+
/// The calling window's folder set, if the header supplied one.
356+
pub fn get_candidates(&self, session_id: &str) -> Option<Vec<String>> {
357+
self.candidates.get(session_id).map(|v| v.clone())
358+
}
359+
360+
/// Whether `root` is one of the folders the calling window has open.
361+
///
362+
/// `true` when no set was reported — an absent constraint must not block
363+
/// clients that don't send the header (every non-Cursor client, and Cursor
364+
/// before the bridge config is reinstalled).
365+
pub fn is_candidate(&self, session_id: &str, root: &str) -> bool {
366+
let Some(candidates) = self.candidates.get(session_id) else {
367+
return true;
368+
};
369+
let normalized = normalize_workspace_root(root);
370+
candidates.iter().any(|c| c == &normalized)
371+
}
372+
373+
/// Hold a candidate set for `client_id` until a session id exists,
374+
/// mirroring [`Self::remember_pending_workspace`].
375+
pub fn remember_pending_candidates(&self, client_id: &str, raw_set: &str) {
376+
let parsed = parse_candidate_set(raw_set);
377+
if parsed.is_empty() {
378+
return;
379+
}
380+
self.pending_candidates_by_client
381+
.insert(client_id.to_string(), parsed);
382+
}
383+
384+
/// Apply a previously remembered candidate set to a session. Returns
385+
/// `true` when one was applied.
386+
pub fn apply_pending_candidates(&self, client_id: &str, session_id: &str) -> bool {
387+
let Some(candidates) = self
388+
.pending_candidates_by_client
389+
.get(client_id)
390+
.map(|value| value.clone())
391+
else {
392+
return false;
393+
};
394+
self.store_candidates(session_id, candidates);
395+
true
396+
}
397+
288398
/// Drop a session's roots — call on client disconnect.
289399
pub fn remove(&self, session_id: &str) {
290400
self.map.remove(session_id);
@@ -294,6 +404,7 @@ impl SessionRootsRegistry {
294404
self.probe_lock.remove(session_id);
295405
self.first_seen.remove(session_id);
296406
self.pinned.remove(session_id);
407+
self.candidates.remove(session_id);
297408
self.search_cache.remove(session_id);
298409
}
299410

@@ -611,6 +722,69 @@ mod tests {
611722
assert_eq!(reg.get("sess-mixed"), Some(vec![keep]));
612723
}
613724

725+
/// Paths that survive `normalize_workspace_root` unchanged on this
726+
/// platform, so the candidate assertions below don't fight normalization.
727+
#[cfg(windows)]
728+
const CANDIDATES: [&str; 3] = ["d:\\alpha", "d:\\beta", "d:\\gamma"];
729+
#[cfg(not(windows))]
730+
const CANDIDATES: [&str; 3] = ["/repos/alpha", "/repos/beta", "/repos/gamma"];
731+
732+
#[test]
733+
fn single_candidate_pins_but_multiple_only_constrain() {
734+
let reg = SessionRootsRegistry::default();
735+
736+
// One folder open is unambiguous — the set is allowed to decide.
737+
reg.set_candidates("sess-one", CANDIDATES[0]);
738+
assert_eq!(reg.get_pinned("sess-one"), Some(CANDIDATES[0].to_string()));
739+
740+
// Several folders open must never auto-select, however tempting the
741+
// ordering looks: position predicts the active folder only ~70% of
742+
// the time, which is a misroute, not a fallback.
743+
let many = CANDIDATES.join(",");
744+
reg.set_candidates("sess-many", &many);
745+
assert!(
746+
reg.get_pinned("sess-many").is_none(),
747+
"a multi-folder set must not pin any of its members"
748+
);
749+
assert_eq!(reg.get_candidates("sess-many").unwrap().len(), 3);
750+
}
751+
752+
#[test]
753+
fn candidate_set_constrains_declarable_roots() {
754+
let reg = SessionRootsRegistry::default();
755+
reg.set_candidates("sess-1", &CANDIDATES.join(","));
756+
757+
assert!(reg.is_candidate("sess-1", CANDIDATES[1]));
758+
assert!(
759+
!reg.is_candidate("sess-1", "/repos/not-open"),
760+
"a folder the window doesn't have open must be refusable"
761+
);
762+
763+
// No set reported (every client that doesn't send the header) stays
764+
// permissive — the constraint must not become a new denial path.
765+
assert!(reg.is_candidate("sess-unknown", "/repos/anything"));
766+
}
767+
768+
#[test]
769+
fn parse_candidate_set_dedupes_and_drops_blanks() {
770+
let raw = format!("{a},,{a}, ,{b}", a = CANDIDATES[0], b = CANDIDATES[1]);
771+
assert_eq!(
772+
parse_candidate_set(&raw),
773+
vec![CANDIDATES[0].to_string(), CANDIDATES[1].to_string()]
774+
);
775+
assert!(parse_candidate_set(" , ").is_empty());
776+
}
777+
778+
#[test]
779+
fn pending_candidates_apply_when_session_id_arrives() {
780+
let reg = SessionRootsRegistry::default();
781+
reg.remember_pending_candidates("client-1", CANDIDATES[0]);
782+
assert!(reg.get_candidates("sess-new").is_none());
783+
assert!(reg.apply_pending_candidates("client-1", "sess-new"));
784+
// A held single-folder set still pins once the session id lands.
785+
assert_eq!(reg.get_pinned("sess-new"), Some(CANDIDATES[0].to_string()));
786+
}
787+
614788
#[test]
615789
fn test_remove_clears_resolution_too() {
616790
let reg = SessionRootsRegistry::default();

0 commit comments

Comments
 (0)