55
66use rmcp:: model:: JsonObject ;
77use serde_json:: Value ;
8+ use tracing:: warn;
89
910use crate :: services:: SessionRootsRegistry ;
1011use 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.
2641pub 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+ }
0 commit comments