Skip to content

Commit b5a9167

Browse files
committed
feat(gateway): route by explicit X-Mcpmux-Workspace header
Clients that don't report MCP `roots` reliably — notably Cursor, which multiplexes one MCP host across windows with `roots.listChanged=false` — could not be routed to the right workspace binding, so `@mux` saw another window's FeatureSet. `roots` is also deprecated (SEP-2577) and sessions are being removed (SEP-2575/2567), so connection-carried identity, not the client's roots, is the durable signal. Add an `X-Mcpmux-Workspace` request header whose value is the workspace root path. The OAuth middleware pins it into SessionRootsRegistry, where it shadows the client's probed roots in `get()`. Because the resolver, the on-demand probe skip, and the prompt-root derivation all read roots through `get()`, the header flows through Tier 1 unchanged — exact binding match, else Space default — and is authoritative over a stale or absent reported root. No new resolver tier, parameter, or DB migration; the header value is just a workspace root normalized like any other. Tests: registry pin/shadow/clear units; resolver integration cases proving a pinned root routes to its binding with no reported roots, overrides a conflicting reported root, and falls back to Space default when unmapped. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 6ac98ef commit b5a9167

5 files changed

Lines changed: 234 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,32 @@ pub async fn mcp_oauth_middleware(
113113
space_id.to_string().parse().expect("valid header value"),
114114
);
115115

116+
// Pin an explicit workspace root advertised by the client via the
117+
// `X-Mcpmux-Workspace` header (injected by McpMux's per-workspace client
118+
// configs). It shadows the client's MCP-reported roots in the resolver, so
119+
// a connection routes to its workspace binding even when the client never
120+
// reports `roots` or reports a stale one (e.g. Cursor sharing one MCP host
121+
// across windows). Unlike client/space id above, this header is
122+
// client-asserted — the same trust model as MCP roots: any approved local
123+
// client can claim any binding (see FeatureSetResolver trust model). Keyed
124+
// by the `mcp-session-id` the client echoes on every post-initialize
125+
// request (the same key the handler stores reported roots under).
126+
let pin = {
127+
let headers = request.headers();
128+
let sid = headers
129+
.get("mcp-session-id")
130+
.and_then(|v| v.to_str().ok())
131+
.map(str::to_owned);
132+
let ws = headers
133+
.get("x-mcpmux-workspace")
134+
.and_then(|v| v.to_str().ok())
135+
.map(str::to_owned);
136+
sid.zip(ws)
137+
};
138+
if let Some((sid, ws)) = pin {
139+
services.session_roots.set_pinned(&sid, &ws);
140+
}
141+
116142
// Extract MCP method from body if POST
117143
let mcp_method = if request.method() == axum::http::Method::POST {
118144
use axum::body::to_bytes;

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@
7676
//!
7777
//! Roots-capable detection is stamped at `on_initialized` time into
7878
//! [`SessionRootsRegistry::set_roots_capable`].
79+
//!
80+
//! # Explicit workspace root via the `X-Mcpmux-Workspace` header
81+
//!
82+
//! A connection can carry an explicit workspace root in the
83+
//! `X-Mcpmux-Workspace` HTTP header, injected by McpMux's per-workspace client
84+
//! configs. The OAuth middleware pins it into
85+
//! [`SessionRootsRegistry::set_pinned`], where it **shadows** the client's
86+
//! probed MCP roots in [`SessionRootsRegistry::get`]. Because this resolver
87+
//! reads roots exclusively through `get`, a pinned root flows through Tier 1
88+
//! unchanged — an exact binding match, else the Space default — with no extra
89+
//! tier or parameter. This is the deterministic path for clients that don't
90+
//! report `roots` reliably (e.g. Cursor multiplexing one MCP host across
91+
//! windows): the header always wins over a stale or absent reported root.
7992
8093
use std::sync::Arc;
8194
use std::time::Duration;

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

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use std::time::{Duration, Instant};
1414

1515
use dashmap::DashMap;
1616
use mcpmux_core::normalize_workspace_root;
17+
use tracing::debug;
1718

1819
/// Thread-safe registry mapping `mcp-session-id` to the caller's reported
1920
/// workspace roots, plus the most recently resolved feature-set id so the
@@ -66,6 +67,18 @@ pub struct SessionRootsRegistry {
6667
/// roots-capable client from flashing the default FeatureSet and then
6768
/// flipping to its mapped one the instant its root lands.
6869
first_seen: DashMap<String, Instant>,
70+
/// `session_id -> explicit workspace root pinned via the
71+
/// `X-Mcpmux-Workspace` HTTP header`.
72+
///
73+
/// McpMux's per-workspace client configs inject that header with the
74+
/// folder's path, so a connection routes to its workspace binding even
75+
/// when the client never reports MCP `roots` or reports a stale one (e.g.
76+
/// Cursor sharing a single MCP host across windows, with
77+
/// `roots.listChanged = false`). A pinned root is **authoritative**: it
78+
/// shadows the probed [`Self::map`] roots in [`Self::get`], so the
79+
/// resolver, the on-demand probe skip, and the prompt-root derivation all
80+
/// honor the header with no special-casing. Already normalized on insert.
81+
pinned: DashMap<String, String>,
6982
}
7083

7184
impl SessionRootsRegistry {
@@ -77,6 +90,7 @@ impl SessionRootsRegistry {
7790
last_probe: DashMap::new(),
7891
probe_lock: DashMap::new(),
7992
first_seen: DashMap::new(),
93+
pinned: DashMap::new(),
8094
})
8195
}
8296

@@ -155,10 +169,53 @@ impl SessionRootsRegistry {
155169
}
156170

157171
/// Retrieve the (already-normalized) roots for a session, if any.
172+
///
173+
/// An explicit root pinned via the `X-Mcpmux-Workspace` header
174+
/// ([`Self::set_pinned`]) takes precedence over — and entirely shadows —
175+
/// the client's probed MCP roots. That single seam is what makes the
176+
/// header authoritative everywhere `get` is consulted (resolver Tier 1,
177+
/// the probe early-return, prompt-root derivation) without threading the
178+
/// header through any of those call paths.
158179
pub fn get(&self, session_id: &str) -> Option<Vec<String>> {
180+
if let Some(pinned) = self.pinned.get(session_id) {
181+
return Some(vec![pinned.clone()]);
182+
}
159183
self.map.get(session_id).map(|v| v.clone())
160184
}
161185

186+
/// Pin an explicit workspace root for a session, sourced from the
187+
/// `X-Mcpmux-Workspace` HTTP header. `raw_root` is a filesystem path or
188+
/// `file://` URI; it's normalized like every other root before storage.
189+
/// A value that normalizes to empty is ignored (no pin), so a malformed
190+
/// header falls back to the client's reported roots rather than denying.
191+
/// Cheap to call on the request hot path: redundant writes (same
192+
/// normalized value already pinned) are skipped to avoid shard churn.
193+
pub fn set_pinned(&self, session_id: &str, raw_root: &str) {
194+
let normalized = normalize_workspace_root(raw_root);
195+
if normalized.is_empty() {
196+
return;
197+
}
198+
if self
199+
.pinned
200+
.get(session_id)
201+
.is_some_and(|v| *v == normalized)
202+
{
203+
return;
204+
}
205+
debug!(
206+
%session_id,
207+
workspace_root = %normalized,
208+
"[SessionRoots] pinned explicit workspace root from X-Mcpmux-Workspace header",
209+
);
210+
self.pinned.insert(session_id.to_string(), normalized);
211+
}
212+
213+
/// The explicit workspace root pinned for a session via the header, if any
214+
/// (already normalized).
215+
pub fn get_pinned(&self, session_id: &str) -> Option<String> {
216+
self.pinned.get(session_id).map(|v| v.clone())
217+
}
218+
162219
/// Drop a session's roots — call on client disconnect.
163220
pub fn remove(&self, session_id: &str) {
164221
self.map.remove(session_id);
@@ -167,6 +224,7 @@ impl SessionRootsRegistry {
167224
self.last_probe.remove(session_id);
168225
self.probe_lock.remove(session_id);
169226
self.first_seen.remove(session_id);
227+
self.pinned.remove(session_id);
170228
}
171229

172230
/// Compare-and-set the session's resolved feature-set id. Returns `true`
@@ -311,6 +369,56 @@ mod tests {
311369
assert_eq!(reg.len(), 0);
312370
}
313371

372+
#[test]
373+
fn test_pinned_root_shadows_reported_roots() {
374+
let reg = SessionRootsRegistry::default();
375+
#[cfg(windows)]
376+
let (reported, pin_in, pin_norm) = (
377+
"file:///D:/reported/",
378+
"D:\\Pinned\\Path",
379+
"d:\\pinned\\path",
380+
);
381+
#[cfg(not(windows))]
382+
let (reported, pin_in, pin_norm) = (
383+
"file:///home/u/reported/",
384+
"/home/u/Pinned",
385+
"/home/u/Pinned",
386+
);
387+
388+
reg.set("sess-1", [reported]);
389+
reg.set_pinned("sess-1", pin_in);
390+
391+
// The pinned (header) root entirely shadows the probed root.
392+
assert_eq!(reg.get("sess-1"), Some(vec![pin_norm.to_string()]));
393+
assert_eq!(reg.get_pinned("sess-1"), Some(pin_norm.to_string()));
394+
}
395+
396+
#[test]
397+
fn test_set_pinned_ignores_empty_and_normalizes() {
398+
let reg = SessionRootsRegistry::default();
399+
// Whitespace/garbage that normalizes to empty leaves no pin, so a
400+
// malformed header falls back to reported roots rather than denying.
401+
reg.set_pinned("sess-1", " ");
402+
assert!(reg.get_pinned("sess-1").is_none());
403+
404+
#[cfg(windows)]
405+
let (pin_in, pin_norm) = ("file:///D:/Foo/", "d:\\foo");
406+
#[cfg(not(windows))]
407+
let (pin_in, pin_norm) = ("file:///home/u/Foo/", "/home/u/Foo");
408+
reg.set_pinned("sess-1", pin_in);
409+
assert_eq!(reg.get_pinned("sess-1"), Some(pin_norm.to_string()));
410+
}
411+
412+
#[test]
413+
fn test_remove_clears_pinned() {
414+
let reg = SessionRootsRegistry::default();
415+
reg.set_pinned("sess-1", "/p");
416+
assert!(reg.get_pinned("sess-1").is_some());
417+
reg.remove("sess-1");
418+
assert!(reg.get_pinned("sess-1").is_none());
419+
assert!(reg.get("sess-1").is_none());
420+
}
421+
314422
#[test]
315423
fn test_record_resolution_flips_on_change() {
316424
let reg = SessionRootsRegistry::default();

tests/rust/tests/integration/feature_set_resolver.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,3 +655,85 @@ async fn two_sessions_on_same_root_resolve_to_the_same_binding() {
655655
assert_eq!(r2.feature_set_ids, vec![f.fs_a_id.clone()]);
656656
assert_eq!(r1.space_id, r2.space_id);
657657
}
658+
659+
// ---------------------------------------------------------------------------
660+
// Explicit workspace root via the X-Mcpmux-Workspace header (pinned root)
661+
// ---------------------------------------------------------------------------
662+
663+
#[tokio::test]
664+
async fn pinned_header_root_routes_to_binding_without_any_reported_roots() {
665+
// The deterministic fix for clients that don't report MCP roots reliably
666+
// (e.g. Cursor multiplexing one MCP host across windows): a session flagged
667+
// explicitly rootless, with no reported roots, still routes to its
668+
// workspace binding purely from the X-Mcpmux-Workspace header the gateway
669+
// pinned.
670+
let f = Fixture::new().await;
671+
f.binding_repo
672+
.create(&WorkspaceBinding::new(
673+
normalize_workspace_root(test_root()),
674+
f.space_id,
675+
f.fs_a_id.clone(),
676+
))
677+
.await
678+
.unwrap();
679+
680+
f.session_roots.set_roots_capable("s", false); // client says it has no roots
681+
f.session_roots.set_pinned("s", test_root()); // ...but the header pins one
682+
683+
let r = f.resolver.resolve(Some("s"), None).await.unwrap();
684+
assert_eq!(r.source, ResolutionSource::WorkspaceBinding);
685+
assert_eq!(r.space_id, Some(f.space_id));
686+
assert_eq!(r.feature_set_ids, vec![f.fs_a_id]);
687+
}
688+
689+
#[tokio::test]
690+
async fn pinned_header_root_overrides_a_conflicting_reported_root() {
691+
// The header is authoritative. When the client reports a stale/wrong root
692+
// AND a header root is pinned, the pinned one wins — exactly the Cursor
693+
// "reported the wrong window's root" failure, now corrected.
694+
let f = Fixture::new().await;
695+
let (reported, pinned) = if cfg!(windows) {
696+
("d:\\work\\reported", "d:\\work\\pinned")
697+
} else {
698+
("/work/reported", "/work/pinned")
699+
};
700+
f.binding_repo
701+
.create(&WorkspaceBinding::new(
702+
normalize_workspace_root(reported),
703+
f.space_id,
704+
f.fs_a_id.clone(),
705+
))
706+
.await
707+
.unwrap();
708+
f.binding_repo
709+
.create(&WorkspaceBinding::new(
710+
normalize_workspace_root(pinned),
711+
f.space_id,
712+
f.fs_b_id.clone(),
713+
))
714+
.await
715+
.unwrap();
716+
717+
f.session_roots.set("s", [reported]);
718+
f.session_roots.set_roots_capable("s", true);
719+
f.session_roots.set_pinned("s", pinned);
720+
721+
let r = f.resolver.resolve(Some("s"), None).await.unwrap();
722+
assert_eq!(r.source, ResolutionSource::WorkspaceBinding);
723+
// Resolved to the PINNED root's FS (B), not the reported root's FS (A).
724+
assert_eq!(r.feature_set_ids, vec![f.fs_b_id]);
725+
assert_ne!(r.feature_set_ids, vec![f.fs_a_id]);
726+
}
727+
728+
#[tokio::test]
729+
async fn pinned_header_root_without_binding_falls_back_to_space_default() {
730+
// A header root for an as-yet-unmapped folder still works out of the box on
731+
// the Space default (upstream emits WorkspaceNeedsBinding so the user can
732+
// attach an explicit mapping).
733+
let f = Fixture::new().await;
734+
f.session_roots.set_pinned("s", test_root());
735+
let r = f.resolver.resolve(Some("s"), None).await.unwrap();
736+
assert_eq!(r.source, ResolutionSource::SpaceDefault);
737+
assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]);
738+
assert_eq!(r.space_id, Some(f.space_id));
739+
}

0 commit comments

Comments
 (0)