Skip to content

Commit 53d28df

Browse files
committed
fix(gateway): supersede prior same-root session on reconnect
The mcp-remote bridge (Cursor global bridge) opens a fresh mcp-session-id on every reconnect but always targets one workspace root. rmcp's streamable-HTTP peers don't reliably flip is_transport_closed() after such a reconnect, so lazy reaping never removed the stale sessions. They piled up (observed 24 concurrent for a single bridge client) and every list_changed fanned out to all of them, exploding the log and pushing the client into a createClient-timeout reconnect storm. On register_session, evict any prior session sharing the same (client_id, resolved workspace root) before inserting the new one, and drop its pinned root from the resolver registry. Distinct roots — real multi-window editors — never match, so multi-window routing is preserved. Clients that route via probed roots (no X-Mcpmux-Workspace header) have no root at registration time and are left untouched. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent b66cc54 commit 53d28df

1 file changed

Lines changed: 72 additions & 6 deletions

File tree

crates/mcpmux-gateway/src/consumers/mcp_notifier.rs

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ impl SessionEntry {
117117
}
118118
}
119119

120+
/// Sort a session's roots so two sessions on the same folder set compare equal
121+
/// regardless of the order the registry returns them in.
122+
fn sorted_roots(mut roots: Vec<String>) -> Vec<String> {
123+
roots.sort();
124+
roots
125+
}
126+
120127
impl MCPNotifier {
121128
pub fn new(
122129
feature_set_resolver: Arc<FeatureSetResolverService>,
@@ -187,19 +194,78 @@ impl MCPNotifier {
187194
client_id: String,
188195
peer: Arc<Peer<RoleServer>>,
189196
) {
190-
let entry = SessionEntry::new(client_id.clone(), peer);
191-
let mut sessions = self.sessions.write();
192-
let is_reconnect = sessions.contains_key(&session_id);
193-
sessions.insert(session_id.clone(), entry);
197+
// A new session for the SAME (client_id, workspace root) supersedes any
198+
// prior one. The mcp-remote bridge opens a fresh `mcp-session-id` on
199+
// every reconnect but always targets one workspace root, so without
200+
// this its stale sessions pile up — rmcp doesn't flip
201+
// `is_transport_closed()` for them, so lazy reaping never removes them
202+
// and every list_changed fans out to all of them (the 24-sessions-for-
203+
// one-bridge-client amplification). Distinct roots — real multi-window
204+
// editors — never match here, so they're preserved.
205+
let superseded = self.collect_superseded_sessions(&session_id, &client_id);
206+
207+
let total_sessions = {
208+
let entry = SessionEntry::new(client_id.clone(), peer);
209+
let mut sessions = self.sessions.write();
210+
for stale in &superseded {
211+
sessions.remove(stale);
212+
}
213+
sessions.insert(session_id.clone(), entry);
214+
sessions.len()
215+
};
216+
217+
// Drop the superseded sessions' pinned roots so the resolver stops
218+
// consulting them (mirrors reap_dead_sessions' registry cleanup).
219+
let roots = self.feature_set_resolver.session_roots();
220+
for stale in &superseded {
221+
roots.remove(stale);
222+
}
223+
224+
if !superseded.is_empty() {
225+
info!(
226+
%session_id,
227+
%client_id,
228+
superseded = superseded.len(),
229+
"[MCPNotifier] ♻️ Superseded prior same-root sessions on reconnect"
230+
);
231+
}
194232
info!(
195233
%session_id,
196234
%client_id,
197-
is_reconnect,
198-
total_sessions = sessions.len(),
235+
total_sessions,
199236
"[MCPNotifier] 📡 Registered session (stream not yet active)"
200237
);
201238
}
202239

240+
/// Find prior sessions the newly-registering one supersedes: same OAuth
241+
/// `client_id` AND same resolved workspace root.
242+
///
243+
/// Returns empty when the new session has no known root yet — clients that
244+
/// route via probed MCP `roots` (rather than the `X-Mcpmux-Workspace`
245+
/// header) haven't responded to `roots/list` at registration time, so
246+
/// there's nothing safe to match against and nothing is evicted.
247+
fn collect_superseded_sessions(&self, new_session_id: &str, client_id: &str) -> Vec<String> {
248+
let roots = self.feature_set_resolver.session_roots();
249+
let new_root = roots.get(new_session_id).map(sorted_roots);
250+
let Some(new_root) = new_root.filter(|r| !r.is_empty()) else {
251+
return Vec::new();
252+
};
253+
254+
let same_client: Vec<String> = {
255+
let sessions = self.sessions.read();
256+
sessions
257+
.iter()
258+
.filter(|(sid, e)| e.client_id == client_id && sid.as_str() != new_session_id)
259+
.map(|(sid, _)| sid.clone())
260+
.collect()
261+
};
262+
263+
same_client
264+
.into_iter()
265+
.filter(|sid| roots.get(sid).map(sorted_roots).as_deref() == Some(new_root.as_slice()))
266+
.collect()
267+
}
268+
203269
/// Mark that a client has an active SSE stream and can receive notifications
204270
///
205271
/// This should be called when a client successfully creates an SSE stream.

0 commit comments

Comments
 (0)