Skip to content

Commit 76bdf19

Browse files
committed
feat(gateway): resolver scopes unmapped roots to a Space by base dir
An unmapped reported root that sits under a Space's base directory now falls back to THAT Space's Starter (scoped), not the global default Space. Exact WorkspaceBindings still win; roots outside every base dir still use the default Space. Because meta-tools resolve "which space" through the resolver, they auto-scope to the matched Space too. - FeatureSetResolverService gains a SpaceBaseDirRepository (wired through the GatewayDependencies, auto-derived from the database like the other repos). - Tier 1b: longest-prefix base-dir match → that Space's Starter. - Integration tests: under-base-dir scopes to that space, outside → default, nested → most-specific space wins, exact binding overrides base-dir scope. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent da15db2 commit 76bdf19

7 files changed

Lines changed: 204 additions & 28 deletions

File tree

crates/mcpmux-gateway/src/server/dependencies.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use crate::services::ClientMetadataService;
1010
use mcpmux_core::{
1111
AppSettingsRepository, CimdMetadataFetcher, CredentialRepository, FeatureSetRepository,
1212
InboundMcpClientRepository, InstalledServerRepository, OutboundOAuthRepository,
13-
ServerDiscoveryService, ServerFeatureRepository, ServerLogManager,
13+
ServerDiscoveryService, ServerFeatureRepository, ServerLogManager, SpaceBaseDirRepository,
1414
SpaceBuiltinConfigRepository, SpaceRepository, WorkspaceBindingRepository,
1515
};
1616
use mcpmux_storage::{Database, InboundClientRepository};
@@ -37,6 +37,9 @@ pub struct GatewayDependencies {
3737
pub inbound_mcp_client_repo: Arc<dyn InboundMcpClientRepository>,
3838
/// Workspace -> FeatureSet bindings for resolver v2.
3939
pub workspace_binding_repo: Arc<dyn WorkspaceBindingRepository>,
40+
/// Per-Space base directories — scope a reported workspace root to a Space
41+
/// by folder prefix (longest match wins).
42+
pub space_base_dir_repo: Arc<dyn SpaceBaseDirRepository>,
4043
/// Per-Space built-in server config (Tool Optimization enablement + tool
4144
/// toggles), consulted when advertising the `mcpmux_*` tools per Space.
4245
pub builtin_config_repo: Arc<dyn SpaceBuiltinConfigRepository>,
@@ -85,6 +88,9 @@ impl GatewayDependencies {
8588
let workspace_binding_repo: Arc<dyn WorkspaceBindingRepository> = Arc::new(
8689
mcpmux_storage::SqliteWorkspaceBindingRepository::new(database.clone()),
8790
);
91+
let space_base_dir_repo: Arc<dyn SpaceBaseDirRepository> = Arc::new(
92+
mcpmux_storage::SqliteSpaceBaseDirRepository::new(database.clone()),
93+
);
8894
let builtin_config_repo: Arc<dyn SpaceBuiltinConfigRepository> = Arc::new(
8995
mcpmux_storage::SqliteSpaceBuiltinConfigRepository::new(database.clone()),
9096
);
@@ -99,6 +105,7 @@ impl GatewayDependencies {
99105
inbound_client_repo,
100106
inbound_mcp_client_repo,
101107
workspace_binding_repo,
108+
space_base_dir_repo,
102109
builtin_config_repo,
103110
server_discovery,
104111
log_manager,
@@ -247,6 +254,9 @@ impl DependenciesBuilder {
247254
let workspace_binding_repo: Arc<dyn WorkspaceBindingRepository> = Arc::new(
248255
mcpmux_storage::SqliteWorkspaceBindingRepository::new(database.clone()),
249256
);
257+
let space_base_dir_repo: Arc<dyn SpaceBaseDirRepository> = Arc::new(
258+
mcpmux_storage::SqliteSpaceBaseDirRepository::new(database.clone()),
259+
);
250260
let builtin_config_repo: Arc<dyn SpaceBuiltinConfigRepository> = Arc::new(
251261
mcpmux_storage::SqliteSpaceBuiltinConfigRepository::new(database.clone()),
252262
);
@@ -267,6 +277,7 @@ impl DependenciesBuilder {
267277
inbound_client_repo,
268278
inbound_mcp_client_repo,
269279
workspace_binding_repo,
280+
space_base_dir_repo,
270281
builtin_config_repo,
271282
server_discovery: self
272283
.server_discovery

crates/mcpmux-gateway/src/server/service_container.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ impl ServiceContainer {
109109
session_roots.clone(),
110110
deps.inbound_client_repo.clone(),
111111
deps.feature_set_repo.clone(),
112+
deps.space_base_dir_repo.clone(),
112113
));
113114

114115
// Authorization service is now a thin adapter over the resolver.

crates/mcpmux-gateway/src/services/feature_set_resolver.rs

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ use std::sync::Arc;
8181
use std::time::Duration;
8282

8383
use anyhow::Result;
84-
use mcpmux_core::{FeatureSetRepository, SpaceRepository, WorkspaceBindingRepository};
84+
use mcpmux_core::{
85+
FeatureSetRepository, SpaceBaseDirRepository, SpaceRepository, WorkspaceBindingRepository,
86+
};
8587
use mcpmux_storage::InboundClientRepository;
8688
use serde::Serialize;
8789
use 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

tests/rust/tests/integration/effective_features.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, Sess
2727
use mcpmux_gateway::{FeatureService, PrefixCacheService};
2828
use mcpmux_storage::{
2929
Database, InboundClientRepository, SqliteFeatureSetRepository, SqliteServerFeatureRepository,
30-
SqliteSpaceRepository, SqliteWorkspaceBindingRepository,
30+
SqliteSpaceBaseDirRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository,
3131
};
3232
use tokio::sync::Mutex;
3333
use uuid::Uuid;
@@ -114,6 +114,7 @@ impl Ctx {
114114
session_roots.clone(),
115115
client_repo.clone(),
116116
fs_repo.clone(),
117+
Arc::new(SqliteSpaceBaseDirRepository::new(db.clone())),
117118
);
118119
let feature_service =
119120
FeatureService::new(feature_repo.clone(), fs_repo.clone(), prefix_cache);

0 commit comments

Comments
 (0)