@@ -8,8 +8,8 @@ use std::collections::{HashMap, HashSet};
88use std:: sync:: Arc ;
99
1010use 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} ;
1414use serde:: { Deserialize , Serialize } ;
1515use tauri:: State ;
@@ -54,6 +54,8 @@ async fn emit_binding_changed(
5454pub 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
8895fn 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 ,
0 commit comments