Skip to content

Commit 9f6fb26

Browse files
committed
fix(gateway): recover stale DCR and keep meta tools reachable
Cloudflare pruning a cached client_id no longer bricks outbound OAuth. Hook/candidate-set mismatches no longer lock out mcpmux_* recovery tools. Startup also backfills git remotes so existing path bindings group across machines. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent fe783ba commit 9f6fb26

11 files changed

Lines changed: 473 additions & 53 deletions

File tree

apps/desktop/src-tauri/src/lib.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,32 @@ pub fn run() {
338338
let event_bus = mcpmux_core::create_shared_event_bus();
339339
let event_sender = event_bus.sender();
340340

341+
// Associate existing local clones with their git origin so the
342+
// Projects page groups the same repo across machines without a
343+
// manual refresh. Background: git probes are fail-open and can
344+
// sit on a timeout per folder.
345+
{
346+
let binding_repo = app_state.workspace_binding_repository.clone();
347+
let app_handle_for_git = app.handle().clone();
348+
tauri::async_runtime::spawn(async move {
349+
let updated = mcpmux_gateway::services::backfill_missing_git_remotes(
350+
binding_repo.as_ref(),
351+
)
352+
.await;
353+
if updated == 0 {
354+
return;
355+
}
356+
info!(
357+
updated,
358+
"[Startup] backfilled git remotes on workspace bindings"
359+
);
360+
let _ = app_handle_for_git.emit(
361+
"workspace-binding-changed",
362+
serde_json::json!({ "workspace_root": "" }),
363+
);
364+
});
365+
}
366+
341367
let server_app_service = mcpmux_core::ServerAppService::new(
342368
app_state.installed_server_repository.clone(),
343369
Some(app_state.server_feature_repository_core.clone()),

crates/mcpmux-core/src/domain/outbound_oauth_registration.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ pub struct StoredOAuthMetadata {
5454
///
5555
/// Separate from tokens (in credentials table) so:
5656
/// - Logout clears tokens but keeps registration
57-
/// - Re-auth uses existing client_id without new DCR (if port matches)
57+
/// - Re-auth reuses client_id when redirect_uri matches and the AS still
58+
/// recognizes it; `invalid_client` drops the row and re-registers
5859
#[derive(Debug, Clone, Serialize, Deserialize)]
5960
pub struct OutboundOAuthRegistration {
6061
/// Unique ID for this registration

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,10 +868,17 @@ impl ServerHandler for McpMuxGatewayHandler {
868868
let session_id = session_id_owned.as_deref();
869869

870870
let mut arguments = params.arguments.take().unwrap_or_default();
871+
// Meta tools (mcpmux_*) are the session's self-diagnosis/recovery surface —
872+
// they must stay reachable even when the hook's guessed root disagrees with
873+
// the candidate set, or the escape hatch becomes unreachable exactly when
874+
// it's needed. Regular backend tool calls stay strict: a wrong root there
875+
// would misroute credentials.
876+
let is_meta_tool_call = crate::services::is_meta_tool(&params.name);
871877
let call_ctx = super::mcpmux_context::take_mcpmux_context(
872878
&mut arguments,
873879
session_id,
874880
&self.services.session_roots,
881+
is_meta_tool_call,
875882
)
876883
.map_err(|e| McpError::invalid_params(e, None))?;
877884
if let Some(ctx) = &call_ctx {

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

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
use rmcp::model::JsonObject;
77
use serde_json::Value;
8+
use tracing::warn;
89

910
use crate::services::SessionRootsRegistry;
1011
use mcpmux_core::normalize_workspace_root;
@@ -22,11 +23,26 @@ pub struct ExtractedCallContext {
2223
/// Remove `_mcpmux_context` from `arguments` and validate it when present.
2324
///
2425
/// `Ok(None)` means the call has no hook context and should use the session
25-
/// ladder. Malformed objects and candidate-set mismatches are errors.
26+
/// ladder. Malformed objects are always errors.
27+
///
28+
/// `lenient_on_mismatch` controls what happens when the hook's guessed root
29+
/// isn't a member of the session's candidate set. The hook only ever sees
30+
/// Cursor's `workspace_roots`, a *different* signal than the header-derived
31+
/// candidate set — the two can legitimately disagree (multi-root workspace,
32+
/// stale header, shared `mcp-remote` session). For a normal backend tool
33+
/// call that mismatch must hard-fail: trusting the wrong root would route
34+
/// the call (and its credentials) to the wrong Space. For `mcpmux_*` meta
35+
/// tools — which exist specifically to self-diagnose and recover session
36+
/// state — hard-failing defeats the point: the escape hatch becomes
37+
/// unreachable exactly when the hook's guess is the thing that's broken.
38+
/// Callers pass `lenient_on_mismatch: true` for meta-tool calls to drop the
39+
/// bad hook context and fall through to the session ladder / the tool's own
40+
/// argument instead of erroring the whole call.
2641
pub fn take_mcpmux_context(
2742
arguments: &mut JsonObject,
2843
session_id: Option<&str>,
2944
session_roots: &SessionRootsRegistry,
45+
lenient_on_mismatch: bool,
3046
) -> Result<Option<ExtractedCallContext>, String> {
3147
let Some(raw) = arguments.remove(MCPMUX_CONTEXT_KEY) else {
3248
return Ok(None);
@@ -55,6 +71,23 @@ pub fn take_mcpmux_context(
5571

5672
if let Some(sid) = session_id {
5773
if !session_roots.is_candidate(sid, &workspace_root) {
74+
if lenient_on_mismatch {
75+
warn!(
76+
session_id = sid,
77+
hook_workspace_root = %workspace_root,
78+
candidates = ?session_roots.get_candidates(sid),
79+
"[mcpmux_context] hook root not in candidate set for meta-tool call; \
80+
dropping hook context, falling back to session ladder"
81+
);
82+
return Ok(None);
83+
}
84+
warn!(
85+
session_id = sid,
86+
hook_workspace_root = %workspace_root,
87+
candidates = ?session_roots.get_candidates(sid),
88+
"[mcpmux_context] hook root not in candidate set for backend tool call; \
89+
hard-failing (strict mode)"
90+
);
5891
return Err(
5992
"invalid _mcpmux_context: workspace_root is not in this session's candidate set"
6093
.into(),
@@ -74,3 +107,75 @@ pub fn take_mcpmux_context(
74107
tool_use_id,
75108
}))
76109
}
110+
111+
#[cfg(test)]
112+
mod tests {
113+
use super::*;
114+
use rmcp::model::JsonObject;
115+
use serde_json::json;
116+
117+
fn args_with_root(root: &str) -> JsonObject {
118+
json!({ "_mcpmux_context": { "workspace_root": root } })
119+
.as_object()
120+
.unwrap()
121+
.clone()
122+
}
123+
124+
#[test]
125+
fn no_context_is_none() {
126+
let mut args = JsonObject::new();
127+
let session_roots = SessionRootsRegistry::new();
128+
let result = take_mcpmux_context(&mut args, Some("s1"), &session_roots, false).unwrap();
129+
assert!(result.is_none());
130+
}
131+
132+
#[test]
133+
fn matching_candidate_passes_both_modes() {
134+
let session_roots = SessionRootsRegistry::new();
135+
session_roots.set_candidates("s1", "/repo/a");
136+
137+
for lenient in [false, true] {
138+
let mut args = args_with_root("/repo/a");
139+
let result = take_mcpmux_context(&mut args, Some("s1"), &session_roots, lenient)
140+
.unwrap()
141+
.unwrap();
142+
assert_eq!(result.workspace_root, "/repo/a");
143+
}
144+
}
145+
146+
#[test]
147+
fn mismatch_is_hard_error_when_strict() {
148+
let session_roots = SessionRootsRegistry::new();
149+
session_roots.set_candidates("s1", "/repo/a,/repo/b");
150+
151+
let mut args = args_with_root("/repo/c");
152+
let err = take_mcpmux_context(&mut args, Some("s1"), &session_roots, false).unwrap_err();
153+
assert!(err.contains("candidate set"));
154+
}
155+
156+
#[test]
157+
fn mismatch_falls_back_to_none_when_lenient() {
158+
// Regression: meta tools (search_tools, set_workspace_root, ...) must
159+
// stay reachable when the hook's guessed root disagrees with the
160+
// candidate set — otherwise the escape hatch is unreachable exactly
161+
// when it's needed (see generAIt dig, Aug 28 2026).
162+
let session_roots = SessionRootsRegistry::new();
163+
session_roots.set_candidates("s1", "/repo/a,/repo/b");
164+
165+
let mut args = args_with_root("/repo/c");
166+
let result = take_mcpmux_context(&mut args, Some("s1"), &session_roots, true).unwrap();
167+
assert!(result.is_none());
168+
}
169+
170+
#[test]
171+
fn malformed_object_errors_regardless_of_leniency() {
172+
for lenient in [false, true] {
173+
let mut args = JsonObject::new();
174+
args.insert(MCPMUX_CONTEXT_KEY.to_string(), json!("not an object"));
175+
let err =
176+
take_mcpmux_context(&mut args, Some("s1"), &SessionRootsRegistry::new(), lenient)
177+
.unwrap_err();
178+
assert!(err.contains("expected an object"));
179+
}
180+
}
181+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -505,8 +505,8 @@ fn resolve_window_pin(
505505
workspace_set_header: Option<&str>,
506506
trace_id: &str,
507507
) {
508-
let same_request_single_set = workspace_set_header
509-
.is_some_and(SessionRootsRegistry::set_header_is_single_folder);
508+
let same_request_single_set =
509+
workspace_set_header.is_some_and(SessionRootsRegistry::set_header_is_single_folder);
510510
if empty_workspace_header && !same_request_single_set {
511511
services.session_roots.forget_empty_header_claim(session_id);
512512
warn!(

0 commit comments

Comments
 (0)