Skip to content

Commit 38f1df1

Browse files
committed
feat(gateway): Phase 2 — repo-name matching for declared rootless roots
Autonomous decisions: - Match workspace_root basenames only (not Bundle display names) — simpler query via existing list() scan - ASCII case-insensitive basename comparison — aligns with Windows-normalized paths and tolerates cloud declare casing drift Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent dff476f commit 38f1df1

4 files changed

Lines changed: 142 additions & 3 deletions

File tree

crates/mcpmux-core/src/repository/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,17 @@ pub trait WorkspaceBindingRepository: Send + Sync {
311311
candidate_roots: &[String],
312312
) -> RepoResult<Option<WorkspaceBinding>>;
313313

314+
/// Best-effort basename match for declared workspace roots.
315+
///
316+
/// Compares the last path segment of each candidate against path-type
317+
/// bindings' `workspace_root` basenames (ASCII case-insensitive). Does not
318+
/// change exact-match semantics — used only for rootless declare-root
319+
/// fall-through when no exact path binding matched.
320+
async fn find_by_basename_for_roots(
321+
&self,
322+
candidate_roots: &[String],
323+
) -> RepoResult<Option<WorkspaceBinding>>;
324+
314325
/// Exact match for a machine-scoped binding on `workspace_root`.
315326
///
316327
/// Matches bindings where `machine_id` equals the given value AND either:

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -531,9 +531,33 @@ impl FeatureSetResolverService {
531531
if roots_capable_known == Some(false) {
532532
// True rootless client declared a root (e.g. via
533533
// `mcpmux_set_workspace_root`) but it didn't exact-match any
534-
// binding — fall through to Tier 3 grant lookup instead of
535-
// hard-denying. The pre-Tier-3 gate treats the declared root
536-
// as the identity signal it was waiting for.
534+
// binding — try repo-name (basename) match, then fall through
535+
// to Tier 3 grant lookup. The pre-Tier-3 gate treats the
536+
// declared root as the identity signal it was waiting for.
537+
if let Some(binding) = self
538+
.binding_repo
539+
.find_by_basename_for_roots(&reported_roots)
540+
.await?
541+
{
542+
if Self::binding_matches_space_lock(&binding, space_lock) {
543+
debug!(
544+
workspace_root = %binding.workspace_root,
545+
space_id = %binding.space_id,
546+
feature_sets = ?binding.feature_set_ids,
547+
"[FeatureSetResolver] rootless session resolved via basename WorkspaceBinding",
548+
);
549+
return Ok(ResolvedFeatureSet {
550+
feature_set_ids: binding.feature_set_ids,
551+
space_id: Some(binding.space_id),
552+
source: ResolutionSource::WorkspaceBinding,
553+
});
554+
}
555+
debug!(
556+
binding_space = %binding.space_id,
557+
?space_lock,
558+
"[FeatureSetResolver] basename binding outside locked Space — ignored",
559+
);
560+
}
537561
debug!(
538562
session_id = %sid,
539563
"[FeatureSetResolver] rootless session declared root but no binding matched — fall through to Tier 3",

crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,20 @@ impl SqliteWorkspaceBindingRepository {
162162
const SELECT_COLS: &'static str =
163163
"id, workspace_root, space_id, created_at, updated_at, client_id, machine_id, label, icon, binding_type";
164164

165+
/// Last path segment of a normalized workspace root for basename matching.
166+
fn workspace_root_basename(normalized: &str) -> Option<String> {
167+
if normalized.is_empty() {
168+
return None;
169+
}
170+
let sep = if normalized.contains('\\') { '\\' } else { '/' };
171+
normalized
172+
.trim_end_matches(sep)
173+
.rsplit(sep)
174+
.next()
175+
.filter(|segment| !segment.is_empty())
176+
.map(str::to_ascii_lowercase)
177+
}
178+
165179
/// Fetch bindings + their FeatureSet lists in two queries.
166180
/// `where_clause` is appended to the binding SELECT (use `""` for none);
167181
/// `string_params` are bound to its placeholders in order.
@@ -326,6 +340,30 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository {
326340
Ok(None)
327341
}
328342

343+
async fn find_by_basename_for_roots(
344+
&self,
345+
candidate_roots: &[String],
346+
) -> Result<Option<WorkspaceBinding>> {
347+
if candidate_roots.is_empty() {
348+
return Ok(None);
349+
}
350+
351+
let bindings = self.list().await?;
352+
for root in candidate_roots {
353+
let Some(declared) = Self::workspace_root_basename(root) else {
354+
continue;
355+
};
356+
if let Some(binding) = bindings.iter().find(|b| {
357+
b.binding_type == BindingType::Path
358+
&& Self::workspace_root_basename(&b.workspace_root)
359+
.is_some_and(|basename| basename == declared)
360+
}) {
361+
return Ok(Some(binding.clone()));
362+
}
363+
}
364+
Ok(None)
365+
}
366+
329367
async fn find_exact_for_machine(
330368
&self,
331369
machine_id: &Uuid,

tests/rust/tests/integration/feature_set_resolver.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,72 @@ async fn rootless_declared_unmatched_root_falls_through_to_grant() {
624624
assert_eq!(r.feature_set_ids, vec![f.fs_a_id]);
625625
}
626626

627+
#[tokio::test]
628+
async fn rootless_declared_root_basename_match_routes_to_binding_not_grant() {
629+
// Phase 2: a declared cloud path whose basename matches a desktop binding
630+
// resolves to that binding's FeatureSet(s), not the blanket grant.
631+
let f = Fixture::new().await;
632+
let client_id = "rootless.example/basename-match";
633+
f.make_client(client_id).await;
634+
f.client_repo
635+
.grant_feature_set(client_id, &f.space_id.to_string(), &f.starter_fs_id)
636+
.await
637+
.unwrap();
638+
639+
let desktop_root = if cfg!(windows) {
640+
"d:\\users\\joe\\desktop\\repos\\personal\\mcp-mux"
641+
} else {
642+
"/Users/joe/Desktop/Repos/Personal/mcp-mux"
643+
};
644+
f.binding_repo
645+
.create(&WorkspaceBinding::new(
646+
normalize_workspace_root(desktop_root),
647+
f.space_id,
648+
f.fs_a_id.clone(),
649+
))
650+
.await
651+
.unwrap();
652+
653+
let cloud_root = if cfg!(windows) {
654+
"d:\\workspace\\mcp-mux"
655+
} else {
656+
"/workspace/mcp-mux"
657+
};
658+
f.session_roots.set_roots_capable("s", false);
659+
f.session_roots.set("s", [cloud_root]);
660+
let r = f
661+
.resolver
662+
.resolve(Some("s"), Some(client_id), None)
663+
.await
664+
.unwrap();
665+
assert_eq!(r.source, ResolutionSource::WorkspaceBinding);
666+
assert_eq!(r.feature_set_ids, vec![f.fs_a_id]);
667+
assert_ne!(r.feature_set_ids, vec![f.starter_fs_id]);
668+
}
669+
670+
#[tokio::test]
671+
async fn rootless_declared_unmatched_root_without_grant_is_unbound() {
672+
// Regression: basename miss with no grant still denies (Phase 1 + Phase 2).
673+
let f = Fixture::new().await;
674+
let client_id = "rootless.example/no-grant-unmatched";
675+
f.make_client(client_id).await;
676+
677+
let cloud_root = if cfg!(windows) {
678+
"d:\\workspace\\unknown-repo"
679+
} else {
680+
"/workspace/unknown-repo"
681+
};
682+
f.session_roots.set_roots_capable("s", false);
683+
f.session_roots.set("s", [cloud_root]);
684+
let r = f
685+
.resolver
686+
.resolve(Some("s"), Some(client_id), None)
687+
.await
688+
.unwrap();
689+
assert_eq!(r.source, ResolutionSource::Unbound);
690+
assert!(r.feature_set_ids.is_empty());
691+
}
692+
627693
#[tokio::test]
628694
async fn roots_capable_unmapped_root_stays_unbound_not_grant() {
629695
// Regression: Tier 1b hard-deny for roots-capable sessions is unchanged.

0 commit comments

Comments
 (0)