11//! `mcpmux_bind_current_workspace` — persistently layer a FeatureSet onto a workspace binding.
22
33use async_trait:: async_trait;
4- use mcpmux_core:: { normalize_workspace_root, WorkspaceBinding } ;
4+ use mcpmux_core:: { normalize_workspace_root, WorkspaceBinding , WorkspaceBindingRepository } ;
55use rmcp:: model:: CallToolResult ;
66use serde_json:: { json, Value } ;
77use tracing:: info;
8+ use uuid:: Uuid ;
89
910use super :: meta_tool_common:: {
1011 caller_space_id, emit_tools_list_changed, emit_workspace_binding_changed, parse_uuid_arg,
@@ -14,6 +15,91 @@ use super::registry::{MetaTool, MetaToolCall, MetaToolError};
1415
1516pub struct BindCurrentWorkspaceTool ;
1617
18+ /// Machine identity for this bind — header, then OAuth client machine, then gateway local.
19+ async fn effective_machine_for_bind ( call : & MetaToolCall < ' _ > ) -> Result < Option < Uuid > , MetaToolError > {
20+ call. ctx
21+ . resolver
22+ . effective_machine_id ( Some ( call. client_id ) , call. request_machine_id )
23+ . await
24+ . map_err ( |e| MetaToolError :: Internal ( e. to_string ( ) ) )
25+ }
26+
27+ /// Look up an existing binding row using the same scope the write path will use.
28+ async fn find_existing_binding_for_bind (
29+ binding_repo : & dyn WorkspaceBindingRepository ,
30+ space_id : & Uuid ,
31+ machine_id : Option < Uuid > ,
32+ client_id : & str ,
33+ normalized : & str ,
34+ ) -> Result < Option < WorkspaceBinding > , MetaToolError > {
35+ if let Some ( mid) = machine_id {
36+ return binding_repo
37+ . find_exact_for_machine ( & mid, normalized, None )
38+ . await
39+ . map_err ( |e| MetaToolError :: Internal ( e. to_string ( ) ) ) ;
40+ }
41+ binding_repo
42+ . find_longest_prefix_match (
43+ space_id,
44+ Some ( client_id) ,
45+ & [ normalized. to_string ( ) ] ,
46+ )
47+ . await
48+ . map_err ( |e| MetaToolError :: Internal ( e. to_string ( ) ) )
49+ }
50+
51+ /// Inputs for the bind tool JSON response (post-write re-resolve for `active`).
52+ struct BindToolResultInput < ' a > {
53+ resolver : & ' a crate :: services:: FeatureSetResolverService ,
54+ session_id : Option < & ' a str > ,
55+ client_id : & ' a str ,
56+ request_machine_id : Option < Uuid > ,
57+ binding_id : Uuid ,
58+ workspace_root : & ' a str ,
59+ feature_set_id : Uuid ,
60+ feature_set_ids : Vec < String > ,
61+ already_bound : bool ,
62+ machine_id : Option < Uuid > ,
63+ }
64+
65+ async fn bind_tool_result ( input : BindToolResultInput < ' _ > ) -> Result < CallToolResult , MetaToolError > {
66+ let fs_id_str = input. feature_set_id . to_string ( ) ;
67+ let resolved = input
68+ . resolver
69+ . resolve (
70+ input. session_id ,
71+ Some ( input. client_id ) ,
72+ input. request_machine_id ,
73+ )
74+ . await
75+ . map_err ( |e| MetaToolError :: Internal ( e. to_string ( ) ) ) ?;
76+ let active = resolved
77+ . feature_set_ids
78+ . iter ( )
79+ . any ( |id| id == & fs_id_str) ;
80+
81+ let mut body = json ! ( {
82+ "ok" : true ,
83+ "binding_id" : input. binding_id,
84+ "workspace_root" : input. workspace_root,
85+ "feature_set_id" : input. feature_set_id,
86+ "feature_set_ids" : input. feature_set_ids,
87+ "already_bound" : input. already_bound,
88+ "active" : active,
89+ } ) ;
90+ if let Some ( mid) = input. machine_id {
91+ body[ "machine_id" ] = json ! ( mid) ;
92+ }
93+ if !active {
94+ body[ "note" ] = json ! (
95+ "binding persisted but FeatureSet is not active for this session — \
96+ verify machine identity (Settings → Machine Identity) and \
97+ X-Mcpmux-Machine-Id on tunneled clients"
98+ ) ;
99+ }
100+ Ok ( text_result ( body) )
101+ }
102+
17103#[ async_trait]
18104impl MetaTool for BindCurrentWorkspaceTool {
19105 fn name ( & self ) -> & ' static str {
@@ -47,6 +133,7 @@ impl MetaTool for BindCurrentWorkspaceTool {
47133 let fs_id = parse_uuid_arg ( & call. args , "feature_set_id" ) ?;
48134
49135 let space_id = caller_space_id ( & call) . await ?;
136+ let machine_id = effective_machine_for_bind ( & call) . await ?;
50137 let roots = call
51138 . session_id
52139 . and_then ( |sid| call. ctx . session_roots . get ( sid) )
@@ -74,32 +161,48 @@ impl MetaTool for BindCurrentWorkspaceTool {
74161 let caller_client_id = call. client_id . to_string ( ) ;
75162
76163 // Dedup before consent: repeat binds must not re-prompt the user.
77- if let Some ( existing) = binding_repo
78- . find_longest_prefix_match (
79- & space_id,
80- Some ( & caller_client_id) ,
81- std:: slice:: from_ref ( & normalized) ,
82- )
83- . await ?
164+ if let Some ( existing) = find_existing_binding_for_bind (
165+ binding_repo. as_ref ( ) ,
166+ & space_id,
167+ machine_id,
168+ & caller_client_id,
169+ & normalized,
170+ )
171+ . await ?
84172 {
85173 if existing. feature_set_ids . iter ( ) . any ( |id| id == & fs_id_str) {
86- return Ok ( text_result ( json ! ( {
87- "ok" : true ,
88- "binding_id" : existing. id,
89- "workspace_root" : normalized,
90- "feature_set_id" : fs_id,
91- "feature_set_ids" : existing. feature_set_ids,
92- "already_bound" : true ,
93- } ) ) ) ;
174+ return bind_tool_result ( BindToolResultInput {
175+ resolver : call. ctx . resolver . as_ref ( ) ,
176+ session_id : call. session_id ,
177+ client_id : call. client_id ,
178+ request_machine_id : call. request_machine_id ,
179+ binding_id : existing. id ,
180+ workspace_root : & normalized,
181+ feature_set_id : fs_id,
182+ feature_set_ids : existing. feature_set_ids ,
183+ already_bound : true ,
184+ machine_id,
185+ } )
186+ . await ;
94187 }
95188 }
96189
97- let summary = format ! (
98- "Append FeatureSet '{fs_name}' to workspace '{normalized}' binding \
99- for client '{caller_client_id}' (existing bundles preserved)."
100- ) ;
190+ let summary = match machine_id {
191+ Some ( mid) => format ! (
192+ "Append FeatureSet '{fs_name}' to workspace '{normalized}' binding \
193+ for machine '{mid}' (existing bundles preserved)."
194+ ) ,
195+ None => format ! (
196+ "Append FeatureSet '{fs_name}' to workspace '{normalized}' binding \
197+ for client '{caller_client_id}' (existing bundles preserved)."
198+ ) ,
199+ } ;
101200
102201 let event_tx = call. ctx . domain_event_tx . clone ( ) ;
202+ let resolver = call. ctx . resolver . clone ( ) ;
203+ let session_id_owned = call. session_id . map ( str:: to_owned) ;
204+ let caller_client_id_for_response = caller_client_id. clone ( ) ;
205+ let request_machine_id = call. request_machine_id ;
103206 with_approval (
104207 & call,
105208 "mcpmux_bind_current_workspace" ,
@@ -108,14 +211,14 @@ impl MetaTool for BindCurrentWorkspaceTool {
108211 true ,
109212 call. args . clone ( ) ,
110213 || async move {
111- let fs_id_str = fs_id . to_string ( ) ;
112- let existing = binding_repo
113- . find_longest_prefix_match (
114- & space_id ,
115- Some ( & caller_client_id) ,
116- std :: slice :: from_ref ( & normalized) ,
117- )
118- . await ?;
214+ let existing = find_existing_binding_for_bind (
215+ binding_repo. as_ref ( ) ,
216+ & space_id ,
217+ machine_id ,
218+ & caller_client_id,
219+ & normalized,
220+ )
221+ . await ?;
119222
120223 let ( binding_id, feature_set_ids, already_bound) = if let Some ( mut binding) =
121224 existing
@@ -131,14 +234,35 @@ impl MetaTool for BindCurrentWorkspaceTool {
131234 info ! (
132235 %space_id,
133236 client_id = %caller_client_id,
237+ ?machine_id,
134238 binding_id = %binding. id,
135239 workspace_root = %normalized,
136240 feature_set_id = %fs_id,
137241 already_bound,
138242 feature_set_count = binding. feature_set_ids. len( ) ,
139- "[meta_tools] bind_current_workspace updated existing scoped binding" ,
243+ "[meta_tools] bind_current_workspace updated existing binding" ,
140244 ) ;
141245 ( binding. id , binding. feature_set_ids . clone ( ) , already_bound)
246+ } else if let Some ( mid) = machine_id {
247+ let binding = WorkspaceBinding :: new_machine_scoped_multi (
248+ normalized. clone ( ) ,
249+ space_id,
250+ mid,
251+ vec ! [ fs_id_str. clone( ) ] ,
252+ ) ;
253+ let binding_id = binding. id ;
254+ let feature_set_ids = binding. feature_set_ids . clone ( ) ;
255+ binding_repo. create ( & binding) . await ?;
256+ emit_workspace_binding_changed ( & event_tx, space_id, & normalized) ;
257+ info ! (
258+ %space_id,
259+ %mid,
260+ binding_id = %binding_id,
261+ workspace_root = %normalized,
262+ feature_set_id = %fs_id,
263+ "[meta_tools] bind_current_workspace created machine-scoped binding" ,
264+ ) ;
265+ ( binding_id, feature_set_ids, false )
142266 } else {
143267 let binding = WorkspaceBinding :: new_scoped_multi (
144268 normalized. clone ( ) ,
@@ -149,26 +273,34 @@ impl MetaTool for BindCurrentWorkspaceTool {
149273 let binding_id = binding. id ;
150274 let feature_set_ids = binding. feature_set_ids . clone ( ) ;
151275 binding_repo. create ( & binding) . await ?;
276+ emit_workspace_binding_changed ( & event_tx, space_id, & normalized) ;
152277 info ! (
153278 %space_id,
154279 client_id = %caller_client_id,
155280 binding_id = %binding_id,
156281 workspace_root = %normalized,
157282 feature_set_id = %fs_id,
158- "[meta_tools] bind_current_workspace created scoped binding" ,
283+ "[meta_tools] bind_current_workspace created client- scoped binding" ,
159284 ) ;
160285 ( binding_id, feature_set_ids, false )
161286 } ;
162287
163- emit_tools_list_changed ( & event_tx, space_id) ;
164- Ok ( text_result ( json ! ( {
165- "ok" : true ,
166- "binding_id" : binding_id,
167- "workspace_root" : normalized,
168- "feature_set_id" : fs_id,
169- "feature_set_ids" : feature_set_ids,
170- "already_bound" : already_bound,
171- } ) ) )
288+ if !already_bound {
289+ emit_tools_list_changed ( & event_tx, space_id) ;
290+ }
291+ bind_tool_result ( BindToolResultInput {
292+ resolver : resolver. as_ref ( ) ,
293+ session_id : session_id_owned. as_deref ( ) ,
294+ client_id : & caller_client_id_for_response,
295+ request_machine_id,
296+ binding_id,
297+ workspace_root : & normalized,
298+ feature_set_id : fs_id,
299+ feature_set_ids,
300+ already_bound,
301+ machine_id,
302+ } )
303+ . await
172304 } ,
173305 )
174306 . await
0 commit comments