Skip to content

Commit d16d50d

Browse files
committed
feat(core,storage): schema for FeatureSet resolver v2 (forward-compat)
Adds the storage surface for project-oriented FeatureSet selection. Behaviour is unchanged at runtime; nothing reads the new columns yet. This lets later commits plug in the resolver under a shadow-mode flag with zero risk to the existing per-client grants path. Migration 002 (forward-compatible additive only): * inbound_clients.pinned_feature_set_id — chosen at approval time * inbound_clients.pinned_space_id — backfilled from locked_space_id * spaces.active_feature_set_id — backfilled from each space's existing Default FS so day-one resolver behaviour matches today * workspace_bindings table — (space_id, workspace_root) -> fs_id Core: * Space.active_feature_set_id * Client.pinned_space_id + pinned_feature_set_id * new WorkspaceBinding entity + normalize_workspace_root + longest_prefix_match helpers (with Windows drive-letter folding, file:// scheme stripping, percent-decode, trailing-separator trim) * SpaceRepository::set_active_feature_set * InboundMcpClientRepository::set_pin * new WorkspaceBindingRepository trait (CRUD + find_longest_prefix_match) Storage: * SqliteSpaceRepository + SqliteInboundMcpClientRepository round-trip the new columns * SqliteWorkspaceBindingRepository + test that longest-prefix matching picks the deepest binding when multiple candidates share prefixes Old per-client grants (client_grants table, Client.grants field, ConnectionMode enum, grant_feature_set/revoke_feature_set/etc.) remain in place untouched — they'll be removed once the resolver flips out of shadow mode. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 1f8d206 commit d16d50d

15 files changed

Lines changed: 811 additions & 20 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/mcpmux-core/src/domain/client.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,28 @@ pub struct Client {
5151
pub connection_mode: ConnectionMode,
5252

5353
/// FeatureSet grants per Space: space_id -> [feature_set_ids]
54+
///
55+
/// Legacy field — superseded by `pinned_feature_set_id` + WorkspaceBinding.
56+
/// Kept while the FeatureSetResolver runs in shadow mode.
5457
#[serde(default)]
5558
pub grants: HashMap<Uuid, Vec<Uuid>>,
5659

60+
/// Space this access key belongs to (chosen at approval time).
61+
///
62+
/// Replaces the `Locked` variant of `ConnectionMode`. `None` means
63+
/// "follow the active Space" for legacy clients that haven't been
64+
/// migrated yet; new approvals always populate this.
65+
#[serde(default)]
66+
pub pinned_space_id: Option<Uuid>,
67+
68+
/// FeatureSet this access key is pinned to (chosen at approval time).
69+
///
70+
/// When `Some`, the resolver uses this FS directly. When `None`, the
71+
/// resolver falls through to workspace-root binding and then the
72+
/// Space's active FS.
73+
#[serde(default)]
74+
pub pinned_feature_set_id: Option<Uuid>,
75+
5776
/// Access key for authentication (local only, never synced)
5877
#[serde(skip)]
5978
pub access_key: Option<String>,
@@ -78,6 +97,8 @@ impl Client {
7897
client_type: client_type.into(),
7998
connection_mode: ConnectionMode::default(),
8099
grants: HashMap::new(),
100+
pinned_space_id: None,
101+
pinned_feature_set_id: None,
81102
access_key: None,
82103
created_at: now,
83104
updated_at: now,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod server;
1616
mod server_feature;
1717
mod server_log;
1818
mod space;
19+
mod workspace_binding;
1920

2021
// Export event types first (ConnectionStatus is defined here)
2122
pub use event::{ConnectionStatus, DiscoveredCapabilities, DomainEvent, DomainEventEnvelope};
@@ -31,3 +32,4 @@ pub use server::*;
3132
pub use server_feature::*;
3233
pub use server_log::*;
3334
pub use space::*;
35+
pub use workspace_binding::{longest_prefix_match, normalize_workspace_root, WorkspaceBinding};

crates/mcpmux-core/src/domain/space.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ pub struct Space {
2727
/// Sort order for display
2828
pub sort_order: i32,
2929

30+
/// Active FeatureSet id — the default FS applied to every client in this
31+
/// Space when neither an access-key pin nor a workspace-root binding matches.
32+
///
33+
/// `None` means "deny by default" — routing returns an empty toolset.
34+
#[serde(default)]
35+
pub active_feature_set_id: Option<Uuid>,
36+
3037
/// Creation timestamp
3138
pub created_at: DateTime<Utc>,
3239

@@ -45,6 +52,7 @@ impl Space {
4552
description: None,
4653
is_default: false,
4754
sort_order: 0,
55+
active_feature_set_id: None,
4856
created_at: now,
4957
updated_at: now,
5058
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
//! WorkspaceBinding entity — maps a workspace root on disk to a FeatureSet.
2+
//!
3+
//! Bindings are the middle tier of FeatureSet resolution:
4+
//! pinned_feature_set_id (on Client) > WorkspaceBinding > Space.active_feature_set_id.
5+
//!
6+
//! When a connected client declares MCP `roots` capability, the gateway calls
7+
//! `roots/list` and matches each reported `file://` root against the bindings
8+
//! for the client's Space using longest-prefix-wins.
9+
10+
use chrono::{DateTime, Utc};
11+
use serde::{Deserialize, Serialize};
12+
use uuid::Uuid;
13+
14+
/// A binding between a normalized workspace root path and a FeatureSet.
15+
///
16+
/// Uniqueness is `(space_id, workspace_root)` — the same on-disk directory
17+
/// can bind different FeatureSets in different Spaces.
18+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19+
pub struct WorkspaceBinding {
20+
/// Unique identifier
21+
pub id: Uuid,
22+
23+
/// Space this binding belongs to
24+
pub space_id: Uuid,
25+
26+
/// Normalized absolute path.
27+
///
28+
/// Normalization rules (applied before insert/compare):
29+
/// * resolve symlinks / junctions (`std::fs::canonicalize`)
30+
/// * Windows: lowercase drive letter, use backslashes
31+
/// * strip trailing path separator
32+
/// * drop the `file://` scheme if the caller provided a URI
33+
pub workspace_root: String,
34+
35+
/// FeatureSet to apply when this binding matches
36+
pub feature_set_id: Uuid,
37+
38+
/// Creation timestamp
39+
pub created_at: DateTime<Utc>,
40+
41+
/// Last update timestamp
42+
pub updated_at: DateTime<Utc>,
43+
}
44+
45+
impl WorkspaceBinding {
46+
/// Create a new binding. Caller is responsible for passing an already-normalized path.
47+
pub fn new(space_id: Uuid, workspace_root: impl Into<String>, feature_set_id: Uuid) -> Self {
48+
let now = Utc::now();
49+
Self {
50+
id: Uuid::new_v4(),
51+
space_id,
52+
workspace_root: workspace_root.into(),
53+
feature_set_id,
54+
created_at: now,
55+
updated_at: now,
56+
}
57+
}
58+
}
59+
60+
/// Normalize an absolute filesystem path or `file://` URI into the canonical
61+
/// form used for binding comparisons.
62+
///
63+
/// This is the single source of truth for path comparisons — always route
64+
/// through here before calling any repository method that takes `workspace_root`.
65+
pub fn normalize_workspace_root(input: &str) -> String {
66+
// Strip file:// scheme if present; tolerate both "file:///abs/path" and
67+
// "file://host/abs/path" (we don't use host, it's always localhost).
68+
let without_scheme = if let Some(rest) = input.strip_prefix("file://") {
69+
// A leading triple-slash (file:///abs) leaves us with "/abs".
70+
// A double-slash host form (file://localhost/abs) leaves us with
71+
// "localhost/abs" — drop the host component before the first slash.
72+
match rest.find('/') {
73+
Some(0) => rest.to_string(),
74+
Some(n) => rest[n..].to_string(),
75+
None => rest.to_string(),
76+
}
77+
} else {
78+
input.to_string()
79+
};
80+
81+
// URL-decode percent-escapes (e.g. %20 -> space) — MCP roots are URIs.
82+
let decoded = urlencoding::decode(&without_scheme)
83+
.map(|s| s.into_owned())
84+
.unwrap_or(without_scheme);
85+
86+
// On Windows, "file:///D:/foo" decodes to "/D:/foo" — strip the leading
87+
// slash so callers see "D:\foo"-style paths before case folding.
88+
#[cfg(windows)]
89+
let stripped = {
90+
let trimmed = decoded
91+
.strip_prefix('/')
92+
.filter(|rest| {
93+
let bytes = rest.as_bytes();
94+
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
95+
})
96+
.unwrap_or(&decoded);
97+
trimmed.replace('/', "\\")
98+
};
99+
#[cfg(not(windows))]
100+
let stripped = decoded;
101+
102+
// Lowercase the drive letter on Windows so "D:\" and "d:\" compare equal.
103+
#[cfg(windows)]
104+
let cased = {
105+
let mut chars: Vec<char> = stripped.chars().collect();
106+
if chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':' {
107+
chars[0] = chars[0].to_ascii_lowercase();
108+
}
109+
chars.into_iter().collect::<String>()
110+
};
111+
#[cfg(not(windows))]
112+
let cased = stripped;
113+
114+
// Strip trailing path separators (but keep a root like "/" or "d:\").
115+
let sep: &[char] = if cfg!(windows) { &['\\', '/'] } else { &['/'] };
116+
let trimmed = cased.trim_end_matches(sep);
117+
118+
// Preserve root — if the trim removed everything, keep one separator.
119+
if trimmed.is_empty() {
120+
if cfg!(windows) {
121+
"\\".to_string()
122+
} else {
123+
"/".to_string()
124+
}
125+
} else if cfg!(windows) && trimmed.ends_with(':') {
126+
// "d:" → "d:\"
127+
format!("{}\\", trimmed)
128+
} else {
129+
trimmed.to_string()
130+
}
131+
}
132+
133+
/// Returns the `workspace_root` in `candidates` whose path is the longest
134+
/// prefix of `query`. Used by the resolver to pick which binding wins when
135+
/// a client reports multiple roots.
136+
///
137+
/// Both `query` and every candidate MUST be already normalized via
138+
/// [`normalize_workspace_root`] — this function does not re-normalize.
139+
pub fn longest_prefix_match<'a, I>(query: &str, candidates: I) -> Option<&'a str>
140+
where
141+
I: IntoIterator<Item = &'a str>,
142+
{
143+
let mut best: Option<&'a str> = None;
144+
for candidate in candidates {
145+
// Match only at a path-component boundary so "/workspaces/foo" does
146+
// not match a binding for "/workspaces/foo-bar".
147+
let matches = query == candidate
148+
|| (query.starts_with(candidate)
149+
&& query
150+
.as_bytes()
151+
.get(candidate.len())
152+
.is_some_and(|b| *b == b'/' || (cfg!(windows) && *b == b'\\')));
153+
if matches && best.map(|b| candidate.len() > b.len()).unwrap_or(true) {
154+
best = Some(candidate);
155+
}
156+
}
157+
best
158+
}
159+
160+
#[cfg(test)]
161+
mod tests {
162+
use super::*;
163+
164+
#[test]
165+
fn test_normalize_file_uri_unix() {
166+
let n = normalize_workspace_root("file:///home/user/proj");
167+
#[cfg(not(windows))]
168+
assert_eq!(n, "/home/user/proj");
169+
#[cfg(windows)]
170+
assert_eq!(n, "\\home\\user\\proj");
171+
}
172+
173+
#[test]
174+
fn test_normalize_trailing_sep() {
175+
let sep = if cfg!(windows) { "\\" } else { "/" };
176+
let input = format!("/foo/bar{sep}");
177+
let n = normalize_workspace_root(&input);
178+
assert!(!n.ends_with(sep) || n.len() <= 3, "got {n}");
179+
}
180+
181+
#[cfg(windows)]
182+
#[test]
183+
fn test_normalize_windows_drive_letter_case_insensitive() {
184+
assert_eq!(
185+
normalize_workspace_root("D:\\Projects\\Foo"),
186+
normalize_workspace_root("d:\\Projects\\Foo")
187+
);
188+
assert_eq!(normalize_workspace_root("D:"), "d:\\");
189+
}
190+
191+
#[cfg(windows)]
192+
#[test]
193+
fn test_normalize_windows_file_uri() {
194+
assert_eq!(
195+
normalize_workspace_root("file:///D:/Projects/Foo"),
196+
"d:\\Projects\\Foo"
197+
);
198+
}
199+
200+
#[test]
201+
fn test_percent_decoded() {
202+
let n = normalize_workspace_root("file:///home/user/my%20project");
203+
assert!(n.ends_with("my project"));
204+
}
205+
206+
#[test]
207+
fn test_longest_prefix_match_exact() {
208+
let bindings = ["/a", "/a/b", "/a/b/c"];
209+
assert_eq!(longest_prefix_match("/a/b/c", bindings), Some("/a/b/c"));
210+
assert_eq!(longest_prefix_match("/a/b/c/d", bindings), Some("/a/b/c"));
211+
assert_eq!(longest_prefix_match("/a/b", bindings), Some("/a/b"));
212+
}
213+
214+
#[test]
215+
fn test_longest_prefix_no_false_partial() {
216+
// "/a/b-extra" must NOT match binding "/a/b".
217+
let bindings = ["/a/b"];
218+
assert_eq!(longest_prefix_match("/a/b-extra", bindings), None);
219+
}
220+
221+
#[test]
222+
fn test_longest_prefix_empty_candidates() {
223+
let bindings: [&str; 0] = [];
224+
assert_eq!(longest_prefix_match("/a", bindings), None);
225+
}
226+
}

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use uuid::Uuid;
88

99
use crate::domain::{
1010
Client, Credential, CredentialType, FeatureSet, FeatureSetMember, InstalledServer, MemberMode,
11-
OutboundOAuthRegistration, ServerFeature, Space,
11+
OutboundOAuthRegistration, ServerFeature, Space, WorkspaceBinding,
1212
};
1313

1414
/// Result type for repository operations
@@ -37,6 +37,16 @@ pub trait SpaceRepository: Send + Sync {
3737

3838
/// Set a space as default
3939
async fn set_default(&self, id: &Uuid) -> RepoResult<()>;
40+
41+
/// Set (or clear, with `None`) the active FeatureSet for a Space.
42+
///
43+
/// The active FS is the fallback applied when a connected client has
44+
/// no pinned FS and no matching workspace binding.
45+
async fn set_active_feature_set(
46+
&self,
47+
id: &Uuid,
48+
feature_set_id: Option<&Uuid>,
49+
) -> RepoResult<()>;
4050
}
4151

4252
/// InstalledServer repository trait
@@ -266,6 +276,55 @@ pub trait InboundMcpClientRepository: Send + Sync {
266276

267277
/// Check if client has any grants for a space
268278
async fn has_grants_for_space(&self, client_id: &Uuid, space_id: &str) -> RepoResult<bool>;
279+
280+
/// Set the pinned Space + optional pinned FeatureSet for a client.
281+
///
282+
/// This is the new (FeatureSet Resolver V2) path: each client row is an
283+
/// independent approval bound to one Space. `pinned_feature_set_id = None`
284+
/// means the client follows workspace-binding / space-active FS.
285+
async fn set_pin(
286+
&self,
287+
client_id: &Uuid,
288+
pinned_space_id: &Uuid,
289+
pinned_feature_set_id: Option<&Uuid>,
290+
) -> RepoResult<()>;
291+
}
292+
293+
/// Workspace binding repository trait
294+
///
295+
/// Bindings map normalized filesystem paths to FeatureSets on a per-Space basis.
296+
/// Matching is longest-prefix-wins; callers are expected to pass
297+
/// already-normalized paths (see [`crate::domain::normalize_workspace_root`]).
298+
#[async_trait]
299+
pub trait WorkspaceBindingRepository: Send + Sync {
300+
/// List every binding across all Spaces.
301+
async fn list(&self) -> RepoResult<Vec<WorkspaceBinding>>;
302+
303+
/// List bindings for a specific Space.
304+
async fn list_for_space(&self, space_id: &Uuid) -> RepoResult<Vec<WorkspaceBinding>>;
305+
306+
/// Fetch a binding by id.
307+
async fn get(&self, id: &Uuid) -> RepoResult<Option<WorkspaceBinding>>;
308+
309+
/// Insert a new binding. Fails on `(space_id, workspace_root)` conflict.
310+
async fn create(&self, binding: &WorkspaceBinding) -> RepoResult<()>;
311+
312+
/// Update an existing binding (e.g., point to a different FS).
313+
async fn update(&self, binding: &WorkspaceBinding) -> RepoResult<()>;
314+
315+
/// Delete a binding by id.
316+
async fn delete(&self, id: &Uuid) -> RepoResult<()>;
317+
318+
/// Resolve which FeatureSet applies for a set of candidate workspace roots.
319+
///
320+
/// Every candidate MUST already be normalized. Returns the binding whose
321+
/// `workspace_root` is the longest prefix of any candidate, or `None`
322+
/// when no binding matches.
323+
async fn find_longest_prefix_match(
324+
&self,
325+
space_id: &Uuid,
326+
candidate_roots: &[String],
327+
) -> RepoResult<Option<WorkspaceBinding>>;
269328
}
270329

271330
/// Credential repository trait (local-only, never synced)

0 commit comments

Comments
 (0)