@@ -81,7 +81,9 @@ use std::sync::Arc;
8181use std:: time:: Duration ;
8282
8383use anyhow:: Result ;
84- use mcpmux_core:: { FeatureSetRepository , SpaceRepository , WorkspaceBindingRepository } ;
84+ use mcpmux_core:: {
85+ FeatureSetRepository , SpaceBaseDirRepository , SpaceRepository , WorkspaceBindingRepository ,
86+ } ;
8587use mcpmux_storage:: InboundClientRepository ;
8688use serde:: Serialize ;
8789use tracing:: { debug, warn} ;
@@ -165,6 +167,10 @@ pub struct FeatureSetResolverService {
165167 /// Looks up each Space's Starter FeatureSet for the default fallback
166168 /// (Tier 1b / Tier 1c-after-grace / Tier 3).
167169 feature_set_repo : Arc < dyn FeatureSetRepository > ,
170+ /// Scopes an unmapped reported root to a Space by base directory — an
171+ /// unmapped folder under a Space's base dir falls back to that Space's
172+ /// Starter instead of the global default Space.
173+ space_base_dir_repo : Arc < dyn SpaceBaseDirRepository > ,
168174 /// Grace window for the `PendingRoots` tier — see
169175 /// [`DEFAULT_PENDING_ROOTS_GRACE`]. Configurable so tests can force the
170176 /// post-grace path deterministically without sleeping.
@@ -178,17 +184,32 @@ impl FeatureSetResolverService {
178184 session_roots : Arc < SessionRootsRegistry > ,
179185 client_repo : Arc < InboundClientRepository > ,
180186 feature_set_repo : Arc < dyn FeatureSetRepository > ,
187+ space_base_dir_repo : Arc < dyn SpaceBaseDirRepository > ,
181188 ) -> Self {
182189 Self {
183190 space_repo,
184191 binding_repo,
185192 session_roots,
186193 client_repo,
187194 feature_set_repo,
195+ space_base_dir_repo,
188196 pending_grace : DEFAULT_PENDING_ROOTS_GRACE ,
189197 }
190198 }
191199
200+ /// The Space that claims one of `roots` by base directory, or `None`. Each
201+ /// root's longest-prefix match is taken (via the repo); the first reported
202+ /// root that lands in a Space wins. Used to scope an unmapped folder to its
203+ /// Space rather than always falling back to the global default.
204+ async fn space_for_roots ( & self , roots : & [ String ] ) -> Result < Option < Uuid > > {
205+ for r in roots {
206+ if let Some ( space_id) = self . space_base_dir_repo . find_space_for_root ( r) . await ? {
207+ return Ok ( Some ( space_id) ) ;
208+ }
209+ }
210+ Ok ( None )
211+ }
212+
192213 /// Override the pending-roots grace window. `Duration::ZERO` makes the
193214 /// resolver skip the wait entirely and fall back to the Space default on
194215 /// the first pending resolution — used by tests to exercise the
@@ -198,36 +219,37 @@ impl FeatureSetResolverService {
198219 self
199220 }
200221
201- /// Fall back to the default Space's Starter FeatureSet. Returns
222+ /// Fall back to `space_id`'s Starter FeatureSet. `space_id` is the global
223+ /// default Space for rootless sessions, or a base-dir-scoped Space for an
224+ /// unmapped folder under that Space's base directory. Returns
202225 /// [`ResolutionSource::SpaceDefault`] when a Starter exists (the normal
203226 /// path — it's builtin and seeded per Space), or, defensively,
204- /// [`ResolutionSource::Deny`] in the degenerate case where the default
205- /// Space has no Starter. `space_id` is always the default Space here —
206- /// unmapped/rootless sessions have no other Space to route to.
207- async fn default_fallback ( & self , default_space_id : Uuid ) -> Result < ResolvedFeatureSet > {
227+ /// [`ResolutionSource::Deny`] in the degenerate case where the Space has no
228+ /// Starter.
229+ async fn default_fallback ( & self , space_id : Uuid ) -> Result < ResolvedFeatureSet > {
208230 if let Some ( fs) = self
209231 . feature_set_repo
210- . get_starter_for_space ( & default_space_id . to_string ( ) )
232+ . get_starter_for_space ( & space_id . to_string ( ) )
211233 . await ?
212234 {
213235 debug ! (
214- space_id = %default_space_id ,
236+ %space_id ,
215237 feature_set_id = %fs. id,
216238 "[FeatureSetResolver] resolved via SpaceDefault (Starter fallback)" ,
217239 ) ;
218240 return Ok ( ResolvedFeatureSet {
219241 feature_set_ids : vec ! [ fs. id] ,
220- space_id : Some ( default_space_id ) ,
242+ space_id : Some ( space_id ) ,
221243 source : ResolutionSource :: SpaceDefault ,
222244 } ) ;
223245 }
224246 debug ! (
225- space_id = %default_space_id ,
226- "[FeatureSetResolver] no Starter FeatureSet in default Space — deny" ,
247+ %space_id ,
248+ "[FeatureSetResolver] no Starter FeatureSet in Space — deny" ,
227249 ) ;
228250 Ok ( ResolvedFeatureSet {
229251 feature_set_ids : vec ! [ ] ,
230- space_id : Some ( default_space_id ) ,
252+ space_id : Some ( space_id ) ,
231253 source : ResolutionSource :: Deny ,
232254 } )
233255 }
@@ -295,9 +317,10 @@ impl FeatureSetResolverService {
295317 // Tier 1: session reported roots — try an EXACT binding match
296318 // (no ancestor inheritance).
297319 if has_roots {
320+ let reported_roots = roots. expect ( "has_roots implies Some" ) ;
298321 if let Some ( binding) = self
299322 . binding_repo
300- . find_exact_for_roots ( & roots . unwrap ( ) )
323+ . find_exact_for_roots ( & reported_roots )
301324 . await ?
302325 {
303326 debug ! (
@@ -313,13 +336,23 @@ impl FeatureSetResolverService {
313336 } ) ;
314337 }
315338 // Tier 1b: had roots, no binding. The folder is unmapped, so
316- // fall back to the default Space's Starter FS — the folder
317- // works immediately instead of getting nothing. Upstream
318- // still emits WorkspaceNeedsBinding (it prompts on
319- // SpaceDefault too) so the user can attach an explicit
320- // mapping whenever they want something other than the default.
321- debug ! ( "[FeatureSetResolver] roots reported but no binding matched — SpaceDefault" , ) ;
322- return self . default_fallback ( default_space_id) . await ;
339+ // fall back to a Starter FS — the folder works immediately
340+ // instead of getting nothing. Scope it to the Space whose base
341+ // directory claims the root (longest-prefix), if any; otherwise
342+ // the global default Space. Upstream still emits
343+ // WorkspaceNeedsBinding (it prompts on SpaceDefault too) so the
344+ // user can attach an explicit mapping for something other than
345+ // the default.
346+ let target_space = self
347+ . space_for_roots ( & reported_roots)
348+ . await ?
349+ . unwrap_or ( default_space_id) ;
350+ debug ! (
351+ %target_space,
352+ scoped_by_base_dir = target_space != default_space_id,
353+ "[FeatureSetResolver] roots reported but no binding matched — SpaceDefault" ,
354+ ) ;
355+ return self . default_fallback ( target_space) . await ;
323356 }
324357
325358 // Tier 1c: client declared `roots` but none have ARRIVED yet
0 commit comments