1919use std:: sync:: Arc ;
2020
2121use mcpmux_core:: {
22- normalize_workspace_root, FeatureSet , FeatureSetRepository , MemberMode , ServerFeature ,
23- ServerFeatureRepository , SpaceRepository , WorkspaceBinding , WorkspaceBindingRepository ,
22+ normalize_workspace_root, FeatureSet , FeatureSetMember , FeatureSetRepository , MemberMode ,
23+ MemberType , ServerFeature , ServerFeatureRepository , SpaceRepository , WorkspaceBinding ,
24+ WorkspaceBindingRepository ,
2425} ;
2526use mcpmux_gateway:: services:: { FeatureSetResolverService , ResolutionSource , SessionRootsRegistry } ;
2627use mcpmux_gateway:: { FeatureService , PrefixCacheService } ;
@@ -36,12 +37,16 @@ struct Ctx {
3637 feature_service : FeatureService ,
3738 session_roots : Arc < SessionRootsRegistry > ,
3839 binding_repo : Arc < dyn WorkspaceBindingRepository > ,
40+ fs_repo : Arc < dyn FeatureSetRepository > ,
3941 space_id : Uuid ,
4042 space_id_str : String ,
4143 /// FeatureSet whose members are the two `github` tools.
4244 fs_github : String ,
4345 /// FeatureSet whose only member is the `firebase` tool.
4446 fs_firebase : String ,
47+ /// Raw ServerFeature ids for composing custom FeatureSets in tests.
48+ gh_issue_id : String ,
49+ fb_deploy_id : String ,
4550}
4651
4752impl Ctx {
@@ -117,10 +122,13 @@ impl Ctx {
117122 feature_service,
118123 session_roots,
119124 binding_repo,
125+ fs_repo,
120126 space_id,
121127 space_id_str,
122128 fs_github : fs_github. id ,
123129 fs_firebase : fs_firebase. id ,
130+ gh_issue_id : gh_issue. id . to_string ( ) ,
131+ fb_deploy_id : fb_deploy. id . to_string ( ) ,
124132 }
125133 }
126134
@@ -176,6 +184,59 @@ async fn mapping_determines_effective_tools_per_session() {
176184 ) ;
177185}
178186
187+ /// Multi-client, SAME workspace root: two distinct client sessions (e.g. two
188+ /// editors opening the same folder) must resolve to the SAME binding and see
189+ /// an identical toolset — the root is the routing key, not the session/client.
190+ #[ tokio:: test( flavor = "multi_thread" ) ]
191+ async fn two_clients_same_root_see_identical_tools ( ) {
192+ let ctx = Ctx :: new ( ) . await ;
193+ let root = if cfg ! ( windows) {
194+ "d:\\ work\\ shared"
195+ } else {
196+ "/work/shared"
197+ } ;
198+ // First client opens the folder and binds it.
199+ ctx. bind ( "client-a" , root, & ctx. fs_github ) . await ;
200+ // Second client (different session) reports the very same root.
201+ ctx. session_roots . set ( "client-b" , [ root] ) ;
202+ ctx. session_roots . set_roots_capable ( "client-b" , true ) ;
203+
204+ let a = ctx. effective_tools ( "client-a" ) . await ;
205+ let b = ctx. effective_tools ( "client-b" ) . await ;
206+ assert_eq ! (
207+ a,
208+ vec![ "create_issue" . to_string( ) , "list_repos" . to_string( ) ]
209+ ) ;
210+ assert_eq ! (
211+ a, b,
212+ "two clients on the same root must see identical tools"
213+ ) ;
214+ }
215+
216+ /// Multi-client, DIFFERENT-shaped roots for the SAME folder: a binding created
217+ /// from the canonical Windows drive path must still match a client that
218+ /// reports that folder with a doubled leading slash (`//d:/…`, seen live from
219+ /// a `file:////D:/…` URI). Regression for the normalization bug that stored
220+ /// `\\d:\…` and silently never matched the canonical form.
221+ #[ tokio:: test( flavor = "multi_thread" ) ]
222+ async fn doubled_slash_root_resolves_to_canonical_binding ( ) {
223+ let ctx = Ctx :: new ( ) . await ;
224+ // Binding created from the canonical drive form.
225+ ctx. bind ( "canon" , "d:\\ work\\ proj" , & ctx. fs_github ) . await ;
226+
227+ // A second client reports the SAME folder via the doubled-slash form;
228+ // SessionRootsRegistry::set normalizes it, and it must collapse to the
229+ // canonical key so the resolver matches the existing binding.
230+ ctx. session_roots . set ( "dslash" , [ "//d:/work/proj" ] ) ;
231+ ctx. session_roots . set_roots_capable ( "dslash" , true ) ;
232+
233+ assert_eq ! (
234+ ctx. effective_tools( "dslash" ) . await ,
235+ vec![ "create_issue" . to_string( ) , "list_repos" . to_string( ) ] ,
236+ "doubled-slash root must route to the binding created from the canonical form"
237+ ) ;
238+ }
239+
179240/// An *empty* mapping (a binding with zero feature sets) is valid: the session
180241/// routes to the Space (source = WorkspaceBinding) but sees zero Space tools.
181242/// Built-in servers (gated per Space) are layered on by the request handler and
@@ -221,3 +282,55 @@ async fn unbound_session_sees_zero_effective_tools() {
221282
222283 assert ! ( ctx. effective_tools( "sess" ) . await . is_empty( ) ) ;
223284}
285+
286+ /// Regression (resolution #9): a composition CYCLE (FS X ⊇ Y, FS Y ⊇ X) must
287+ /// not infinite-loop the resolver on the live tools/list path. The command
288+ /// layer now blocks creating such a cycle, but the repository can still hold
289+ /// one (legacy data / direct writes), so the resolver must terminate
290+ /// defensively — returning the de-duplicated union of both sets' features.
291+ #[ tokio:: test( flavor = "multi_thread" ) ]
292+ async fn composition_cycle_terminates_and_returns_union ( ) {
293+ let ctx = Ctx :: new ( ) . await ;
294+
295+ // Two custom FeatureSets that include each other.
296+ let mut fs_x = FeatureSet :: new_custom ( "CycX" , ctx. space_id . to_string ( ) ) ;
297+ let mut fs_y = FeatureSet :: new_custom ( "CycY" , ctx. space_id . to_string ( ) ) ;
298+ ctx. fs_repo . create ( & fs_x) . await . unwrap ( ) ;
299+ ctx. fs_repo . create ( & fs_y) . await . unwrap ( ) ;
300+
301+ let member = |fs_id : & str , mtype : MemberType , mid : String | FeatureSetMember {
302+ id : Uuid :: new_v4 ( ) . to_string ( ) ,
303+ feature_set_id : fs_id. to_string ( ) ,
304+ member_type : mtype,
305+ member_id : mid,
306+ mode : MemberMode :: Include ,
307+ } ;
308+
309+ // X ⊇ {gh_issue (feature), Y (featureset)}
310+ fs_x. members = vec ! [
311+ member( & fs_x. id, MemberType :: Feature , ctx. gh_issue_id. clone( ) ) ,
312+ member( & fs_x. id, MemberType :: FeatureSet , fs_y. id. clone( ) ) ,
313+ ] ;
314+ // Y ⊇ {fb_deploy (feature), X (featureset)} ← closes the cycle
315+ fs_y. members = vec ! [
316+ member( & fs_y. id, MemberType :: Feature , ctx. fb_deploy_id. clone( ) ) ,
317+ member( & fs_y. id, MemberType :: FeatureSet , fs_x. id. clone( ) ) ,
318+ ] ;
319+ ctx. fs_repo . update ( & fs_x) . await . unwrap ( ) ;
320+ ctx. fs_repo . update ( & fs_y) . await . unwrap ( ) ;
321+
322+ let root = if cfg ! ( windows) {
323+ "d:\\ work\\ cyc"
324+ } else {
325+ "/work/cyc"
326+ } ;
327+ ctx. bind ( "sess" , root, & fs_x. id ) . await ;
328+
329+ // Must return (not hang) — the union of both sets' features, de-duplicated.
330+ let tools = ctx. effective_tools ( "sess" ) . await ;
331+ assert_eq ! (
332+ tools,
333+ vec![ "create_issue" . to_string( ) , "deploy" . to_string( ) ] ,
334+ "cyclic composition must resolve to the de-duplicated union and terminate"
335+ ) ;
336+ }
0 commit comments