Skip to content

Commit fb67e01

Browse files
committed
feat: generalized id mappings + lock-confine resolver (P2)
Mappings are now keyed by a folder PATH or an arbitrary ID string (BindingType; migration 021). The FeatureSet resolver implements the full precedence: - locked client -> Space is ALWAYS the locked one; the header only selects a FeatureSet *within* it (a foreign-Space header is ignored -> locked Starter). - unlocked -> header (path/id) > clientId-keyed binding > default-Space Starter. Adds binding_type to the domain + repo (find_by_id_key + path-scoped find_exact_for_roots), get/set_locked_space, and migration 022 for inbound_clients.locked_space_id (fixes P1 lock-to-space, which referenced a column that did not yet exist). The binding Tauri commands accept id mappings. Covered by storage + resolver-precedence tests (31 resolver cases green, incl. id-binding, clientId routing, and all three lock-confine cases). Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent d2844bb commit fb67e01

10 files changed

Lines changed: 458 additions & 26 deletions

File tree

apps/desktop/src-tauri/src/commands/workspace_binding.rs

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use std::collections::{HashMap, HashSet};
88
use std::sync::Arc;
99

1010
use mcpmux_core::{
11-
validate_workspace_root as validate_root, DomainEvent, FeatureSet, FeatureSetType, MemberMode,
12-
MemberType, ServerFeature, WorkspaceBinding, WorkspaceRootValidation,
11+
validate_workspace_root as validate_root, BindingType, DomainEvent, FeatureSet, FeatureSetType,
12+
MemberMode, MemberType, ServerFeature, WorkspaceBinding, WorkspaceRootValidation,
1313
};
1414
use serde::{Deserialize, Serialize};
1515
use tauri::State;
@@ -54,6 +54,8 @@ async fn emit_binding_changed(
5454
pub struct WorkspaceBindingDto {
5555
pub id: String,
5656
pub workspace_root: String,
57+
/// `path` (folder, normalized) or `id` (arbitrary exact-match key).
58+
pub binding_type: String,
5759
pub space_id: String,
5860
pub feature_set_ids: Vec<String>,
5961
pub created_at: String,
@@ -65,6 +67,7 @@ impl From<WorkspaceBinding> for WorkspaceBindingDto {
6567
Self {
6668
id: b.id.to_string(),
6769
workspace_root: b.workspace_root,
70+
binding_type: b.binding_type.as_str().to_string(),
6871
space_id: b.space_id.to_string(),
6972
feature_set_ids: b.feature_set_ids,
7073
created_at: b.created_at.to_rfc3339(),
@@ -83,6 +86,10 @@ pub struct WorkspaceBindingInput {
8386
pub workspace_root: String,
8487
pub space_id: String,
8588
pub feature_set_ids: Vec<String>,
89+
/// `path` (default — folder, normalized + validated) or `id` (arbitrary
90+
/// exact-match key, taken verbatim). Optional for backward compatibility.
91+
#[serde(default)]
92+
pub binding_type: Option<String>,
8693
}
8794

8895
fn parse_space_id(input: &WorkspaceBindingInput) -> Result<Uuid, String> {
@@ -229,6 +236,26 @@ fn normalize_and_validate(raw: &str) -> Result<String, String> {
229236
}
230237
}
231238

239+
/// Resolve the storage key + type from the input. `path` bindings are
240+
/// normalized + validated (rejecting relative paths, filesystem roots, …);
241+
/// `id` bindings take the raw string verbatim (any non-empty label a headless
242+
/// client sends in `X-Mcpmux-Workspace`, e.g. a client id or machine name).
243+
fn resolve_key_and_type(input: &WorkspaceBindingInput) -> Result<(String, BindingType), String> {
244+
match input.binding_type.as_deref() {
245+
Some("id") => {
246+
let key = input.workspace_root.trim();
247+
if key.is_empty() {
248+
return Err("Mapping id cannot be empty".into());
249+
}
250+
Ok((key.to_string(), BindingType::Id))
251+
}
252+
_ => Ok((
253+
normalize_and_validate(&input.workspace_root)?,
254+
BindingType::Path,
255+
)),
256+
}
257+
}
258+
232259
/// Create a binding. Path is normalized + validated server-side so the UI
233260
/// can pass raw input (Windows paths, file:// URIs, trailing slashes).
234261
#[tauri::command]
@@ -239,23 +266,26 @@ pub async fn create_workspace_binding(
239266
) -> Result<WorkspaceBindingDto, String> {
240267
let space_id = parse_space_id(&input)?;
241268
let feature_set_ids = validate_fs_list(&input)?;
242-
let normalized = normalize_and_validate(&input.workspace_root)?;
269+
let (key, binding_type) = resolve_key_and_type(&input)?;
243270

244-
// Reject a duplicate folder up front with a readable message. The schema
271+
// Reject a duplicate key up front with a readable message. The schema
245272
// already enforces `UNIQUE(workspace_root)`, but that surfaces an opaque
246273
// SQLite constraint error — this gives the UI something a user can act on.
247274
let existing = state
248275
.workspace_binding_repository
249276
.list()
250277
.await
251278
.map_err(|e| e.to_string())?;
252-
if existing.iter().any(|b| b.workspace_root == normalized) {
279+
if existing.iter().any(|b| b.workspace_root == key) {
253280
return Err(format!(
254-
"A mapping already exists for {normalized}. Edit the existing mapping instead of adding a second one."
281+
"A mapping already exists for {key}. Edit the existing mapping instead of adding a second one."
255282
));
256283
}
257284

258-
let binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids);
285+
let binding = match binding_type {
286+
BindingType::Id => WorkspaceBinding::new_id(key.clone(), space_id, feature_set_ids),
287+
BindingType::Path => WorkspaceBinding::new_multi(key.clone(), space_id, feature_set_ids),
288+
};
259289

260290
state
261291
.workspace_binding_repository
@@ -292,9 +322,9 @@ pub async fn update_workspace_binding(
292322
let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?;
293323
let space_id = parse_space_id(&input)?;
294324
let feature_set_ids = validate_fs_list(&input)?;
295-
let normalized = normalize_and_validate(&input.workspace_root)?;
325+
let (key, binding_type) = resolve_key_and_type(&input)?;
296326

297-
// If the edit moved the folder onto a path another mapping already owns,
327+
// If the edit moved the mapping onto a key another mapping already owns,
298328
// reject with a readable message rather than tripping the DB UNIQUE
299329
// constraint. Exclude this binding's own row.
300330
let all = state
@@ -304,10 +334,10 @@ pub async fn update_workspace_binding(
304334
.map_err(|e| e.to_string())?;
305335
if all
306336
.iter()
307-
.any(|b| b.id != id_uuid && b.workspace_root == normalized)
337+
.any(|b| b.id != id_uuid && b.workspace_root == key)
308338
{
309339
return Err(format!(
310-
"Another mapping already uses {normalized}. Pick a different folder."
340+
"Another mapping already uses {key}. Pick a different key."
311341
));
312342
}
313343

@@ -321,7 +351,8 @@ pub async fn update_workspace_binding(
321351

322352
let updated = WorkspaceBinding {
323353
id: existing.id,
324-
workspace_root: normalized,
354+
workspace_root: key,
355+
binding_type,
325356
space_id,
326357
feature_set_ids,
327358
created_at: existing.created_at,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,5 @@ pub use server_log::*;
3939
pub use space::*;
4040
pub use workspace_binding::{
4141
longest_matching_base, normalize_workspace_root, path_is_within, validate_workspace_root,
42-
WorkspaceBinding, WorkspaceRootValidation,
42+
BindingType, WorkspaceBinding, WorkspaceRootValidation,
4343
};

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,38 @@ use chrono::{DateTime, Utc};
2525
use serde::{Deserialize, Serialize};
2626
use uuid::Uuid;
2727

28+
/// How a binding's `workspace_root` key is matched against a request.
29+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
30+
#[serde(rename_all = "lowercase")]
31+
pub enum BindingType {
32+
/// A normalized absolute folder path (the original behaviour). Matched
33+
/// case-/separator-insensitively via [`normalize_workspace_root`].
34+
#[default]
35+
Path,
36+
/// An arbitrary exact-match string — a client id, machine name, or any
37+
/// label a headless/remote client sends in `X-Mcpmux-Workspace`. Matched
38+
/// verbatim (no path normalization).
39+
Id,
40+
}
41+
42+
impl BindingType {
43+
pub fn as_str(&self) -> &'static str {
44+
match self {
45+
BindingType::Path => "path",
46+
BindingType::Id => "id",
47+
}
48+
}
49+
50+
/// Parse from storage; unknown values fall back to `Path` (the default for
51+
/// pre-`binding_type` rows).
52+
pub fn parse(s: &str) -> Self {
53+
match s {
54+
"id" => BindingType::Id,
55+
_ => BindingType::Path,
56+
}
57+
}
58+
}
59+
2860
/// A binding between a normalized workspace root and the FeatureSet(s) it
2961
/// resolves to. `feature_set_ids` MAY be empty — an empty list is a valid
3062
/// "no Space tools" mapping (the folder still routes to this Space; built-in
@@ -33,6 +65,8 @@ use uuid::Uuid;
3365
pub struct WorkspaceBinding {
3466
pub id: Uuid,
3567
pub workspace_root: String,
68+
/// Whether `workspace_root` is a filesystem path or an arbitrary id key.
69+
pub binding_type: BindingType,
3670
pub space_id: Uuid,
3771
/// Order matters for UI rendering only — the resolver treats them as
3872
/// a set. Stored in the `workspace_binding_feature_sets` junction
@@ -63,12 +97,23 @@ impl WorkspaceBinding {
6397
Self {
6498
id: Uuid::new_v4(),
6599
workspace_root: workspace_root.into(),
100+
binding_type: BindingType::Path,
66101
space_id,
67102
feature_set_ids,
68103
created_at: now,
69104
updated_at: now,
70105
}
71106
}
107+
108+
/// Construct an **id-keyed** binding: `key` is matched verbatim (exact
109+
/// string, no path normalization) rather than as a folder. Used to route
110+
/// headless/remote clients by a client id or an arbitrary label.
111+
pub fn new_id(key: impl Into<String>, space_id: Uuid, feature_set_ids: Vec<String>) -> Self {
112+
Self {
113+
binding_type: BindingType::Id,
114+
..Self::new_multi(key, space_id, feature_set_ids)
115+
}
116+
}
72117
}
73118

74119
// ============================================================================

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,12 @@ pub trait WorkspaceBindingRepository: Send + Sync {
276276
&self,
277277
candidate_roots: &[String],
278278
) -> RepoResult<Option<WorkspaceBinding>>;
279+
280+
/// Resolve an **id-keyed** binding by exact-string match (no path
281+
/// normalization). Used to route headless/remote clients by a client id or
282+
/// an arbitrary label sent in `X-Mcpmux-Workspace`. Only `BindingType::Id`
283+
/// bindings are considered, so a folder path can never collide with a label.
284+
async fn find_by_id_key(&self, key: &str) -> RepoResult<Option<WorkspaceBinding>>;
279285
}
280286

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

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

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ use std::time::Duration;
9595

9696
use anyhow::Result;
9797
use mcpmux_core::{
98-
FeatureSetRepository, SpaceBaseDirRepository, SpaceRepository, WorkspaceBindingRepository,
98+
FeatureSetRepository, SpaceBaseDirRepository, SpaceRepository, WorkspaceBinding,
99+
WorkspaceBindingRepository,
99100
};
100101
use mcpmux_storage::InboundClientRepository;
101102
use serde::Serialize;
@@ -281,6 +282,65 @@ impl FeatureSetResolverService {
281282
})
282283
}
283284

285+
/// Resolve a binding from header/root candidate keys: a **path** binding
286+
/// (exact normalized match) or, failing that, an **id** binding (exact
287+
/// verbatim match — a client id or arbitrary label). First hit wins. Path
288+
/// and id bindings live in disjoint namespaces in the repo, so this never
289+
/// double-matches.
290+
async fn mapping_binding_for_roots(
291+
&self,
292+
roots: &[String],
293+
) -> Result<Option<WorkspaceBinding>> {
294+
if let Some(b) = self.binding_repo.find_exact_for_roots(roots).await? {
295+
return Ok(Some(b));
296+
}
297+
for r in roots {
298+
if let Some(b) = self.binding_repo.find_by_id_key(r).await? {
299+
return Ok(Some(b));
300+
}
301+
}
302+
Ok(None)
303+
}
304+
305+
/// Resolve a client locked to Space `locked`. The Space is fixed to
306+
/// `locked`; the header/roots may still pick the FeatureSet, but only when
307+
/// their binding lives in `locked`. A header whose binding resolves to a
308+
/// different Space — or no header at all — falls back to `locked`'s Starter.
309+
/// A locked client never touches the roots-pending or client-grant tiers.
310+
async fn resolve_locked(
311+
&self,
312+
session_id: Option<&str>,
313+
locked: Uuid,
314+
) -> Result<ResolvedFeatureSet> {
315+
if let Some(sid) = session_id {
316+
if let Some(roots) = self.session_roots.get(sid) {
317+
if !roots.is_empty() {
318+
if let Some(binding) = self.mapping_binding_for_roots(&roots).await? {
319+
if binding.space_id == locked {
320+
debug!(
321+
%locked,
322+
workspace_root = %binding.workspace_root,
323+
"[FeatureSetResolver] locked client — header binding within locked Space",
324+
);
325+
return Ok(ResolvedFeatureSet {
326+
feature_set_ids: binding.feature_set_ids,
327+
space_id: Some(locked),
328+
source: ResolutionSource::WorkspaceBinding,
329+
});
330+
}
331+
debug!(
332+
%locked,
333+
binding_space = %binding.space_id,
334+
"[FeatureSetResolver] locked client — header binding in a different Space; ignored",
335+
);
336+
}
337+
}
338+
}
339+
}
340+
// No header, or its binding is outside the locked Space → locked Starter.
341+
self.default_fallback(locked).await
342+
}
343+
284344
/// Borrow the session-roots registry. The notifier uses this to GC
285345
/// dead sessions out of the registry when reaping the corresponding
286346
/// peer entries — keeping both stores in sync.
@@ -311,6 +371,23 @@ impl FeatureSetResolverService {
311371
}
312372
};
313373

374+
// Lock-confine: a client locked to Space L only ever resolves to L. The
375+
// header/roots may still pick a FeatureSet *within* L; a binding that
376+
// resolves to a different Space — or no header — yields L's Starter.
377+
// Bypasses the roots-pending and grant tiers entirely.
378+
if let Some(cid) = client_id {
379+
if let Some(locked) = self.client_repo.get_locked_space(cid).await? {
380+
match locked.parse::<Uuid>() {
381+
Ok(locked_uuid) => return self.resolve_locked(session_id, locked_uuid).await,
382+
Err(e) => warn!(
383+
client_id = %cid,
384+
locked_space = %locked,
385+
"[FeatureSetResolver] client locked to unparseable space id: {e}",
386+
),
387+
}
388+
}
389+
}
390+
314391
// Tier 1 / 1b / 1c — branches on roots-capable + roots-arrived state.
315392
if let Some(sid) = session_id {
316393
let roots = self.session_roots.get(sid);
@@ -345,11 +422,9 @@ impl FeatureSetResolverService {
345422
// (no ancestor inheritance).
346423
if has_roots {
347424
let reported_roots = roots.expect("has_roots implies Some");
348-
if let Some(binding) = self
349-
.binding_repo
350-
.find_exact_for_roots(&reported_roots)
351-
.await?
352-
{
425+
// Exact binding match: a path binding (normalized folder) or an
426+
// id binding (verbatim label / client-id sent in the header).
427+
if let Some(binding) = self.mapping_binding_for_roots(&reported_roots).await? {
353428
debug!(
354429
workspace_root = %binding.workspace_root,
355430
space_id = %binding.space_id,
@@ -433,6 +508,23 @@ impl FeatureSetResolverService {
433508
// (the desktop UI's preview HTTP path lands here too). Consult the
434509
// per-client grant table.
435510
if let Some(cid) = client_id {
511+
// clientId-keyed mapping (auto-created for API-key clients): when no
512+
// header/roots selected a binding above, route by the client's own
513+
// id. Editor clients have no such binding and fall through to grants.
514+
if let Some(binding) = self.binding_repo.find_by_id_key(cid).await? {
515+
debug!(
516+
client_id = %cid,
517+
workspace_root = %binding.workspace_root,
518+
space_id = %binding.space_id,
519+
"[FeatureSetResolver] resolved via clientId mapping",
520+
);
521+
return Ok(ResolvedFeatureSet {
522+
feature_set_ids: binding.feature_set_ids,
523+
space_id: Some(binding.space_id),
524+
source: ResolutionSource::WorkspaceBinding,
525+
});
526+
}
527+
436528
// Propagate storage errors instead of treating them as "no
437529
// grants": a transient DB failure must surface as a request
438530
// error, not a silent deny (which would also record a `None`

crates/mcpmux-storage/src/database.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,16 @@ const MIGRATIONS: &[Migration] = &[
133133
name: "inbound_client_api_keys",
134134
sql: include_str!("migrations/020_inbound_client_api_keys.sql"),
135135
},
136+
Migration {
137+
version: 21,
138+
name: "binding_type",
139+
sql: include_str!("migrations/021_binding_type.sql"),
140+
},
141+
Migration {
142+
version: 22,
143+
name: "inbound_client_locked_space",
144+
sql: include_str!("migrations/022_inbound_client_locked_space.sql"),
145+
},
136146
];
137147

138148
/// SQLite database wrapper.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- Migration 021: workspace binding type (path vs id)
2+
--
3+
-- Generalizes mappings beyond filesystem folders. A binding's `workspace_root`
4+
-- is now a routing KEY that is either:
5+
-- * 'path' — a normalized absolute folder path (the original behaviour), or
6+
-- * 'id' — an arbitrary exact-match string (a client id, machine name, or
7+
-- any label a headless/remote client sends in X-Mcpmux-Workspace).
8+
-- Existing rows are folder paths, so they default to 'path' (backward
9+
-- compatible). Path keys are normalized + case-folded before comparison;
10+
-- id keys are matched verbatim.
11+
ALTER TABLE workspace_bindings ADD COLUMN binding_type TEXT NOT NULL DEFAULT 'path';

0 commit comments

Comments
 (0)