Skip to content

Commit 947b40c

Browse files
committed
fix(gateway): write machine-scoped bindings from meta-tools bind
Thread X-Mcpmux-Machine-Id through MetaToolCall so bind and resolve use the same machine identity. Meta-tool binds now persist machine_id when a device is known, and responses report whether the FeatureSet is active for the current session. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 536c62a commit 947b40c

12 files changed

Lines changed: 632 additions & 61 deletions

File tree

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,9 @@ use uuid::Uuid;
3333
pub struct WorkspaceBinding {
3434
pub id: Uuid,
3535
pub workspace_root: String,
36-
/// Optional OAuth client scope. `None` is a global binding (the only kind
37-
/// produced today — resolution stays exact-match-global). The column
38-
/// exists for a later per-client routing phase; it is persisted but does
39-
/// not affect resolution yet.
36+
/// Optional OAuth client scope. `None` is a global binding. When set
37+
/// together with `machine_id`, resolution prefers the client+machine pair
38+
/// over a machine-only canonical binding on the same path.
4039
#[serde(default)]
4140
pub client_id: Option<String>,
4241
/// Optional machine scope. `None` is a global canonical binding shared
@@ -80,7 +79,7 @@ impl WorkspaceBinding {
8079

8180
/// Construct a binding optionally scoped to an OAuth `client_id`. A `None`
8281
/// scope is a global binding; `Some(client_id)` restricts the binding to
83-
/// that client during resolution.
82+
/// that client during resolution when no machine identity is registered.
8483
pub fn new_scoped_multi(
8584
workspace_root: impl Into<String>,
8685
space_id: Uuid,
@@ -101,6 +100,30 @@ impl WorkspaceBinding {
101100
updated_at: now,
102101
}
103102
}
103+
104+
/// Construct a machine-scoped binding (`client_id` unset). Used when this
105+
/// install or the caller's `X-Mcpmux-Machine-Id` header identifies the
106+
/// physical host that owns the workspace folder.
107+
pub fn new_machine_scoped_multi(
108+
workspace_root: impl Into<String>,
109+
space_id: Uuid,
110+
machine_id: Uuid,
111+
feature_set_ids: Vec<String>,
112+
) -> Self {
113+
let now = Utc::now();
114+
Self {
115+
id: Uuid::new_v4(),
116+
workspace_root: workspace_root.into(),
117+
client_id: None,
118+
machine_id: Some(machine_id),
119+
label: None,
120+
icon: None,
121+
space_id,
122+
feature_set_ids,
123+
created_at: now,
124+
updated_at: now,
125+
}
126+
}
104127
}
105128

106129
/// Trim-empty helper for optional binding metadata fields.

crates/mcpmux-gateway/src/mcp/handler.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -855,7 +855,13 @@ impl ServerHandler for McpMuxGatewayHandler {
855855
return match self
856856
.services
857857
.meta_tool_registry
858-
.call(&params.name, &oauth_ctx.client_id, session_id, args)
858+
.call_from_device(
859+
&params.name,
860+
&oauth_ctx.client_id,
861+
session_id,
862+
args,
863+
oauth_ctx.request_machine_id,
864+
)
859865
.await
860866
{
861867
Ok(result) => Ok(result),

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,29 @@ impl FeatureSetResolverService {
228228
*self.local_machine_id.write().await = id;
229229
}
230230

231+
/// The machine identity that should govern a *binding write* for this
232+
/// caller — mirrors Tier 1's read-side priority (request header, then the
233+
/// OAuth client's registered machine, then this gateway's own local
234+
/// machine identity) so a bind and the resolve that follows it never
235+
/// disagree about whose binding it is. `None` means no machine identity
236+
/// exists anywhere yet (fresh install with nothing registered) — callers
237+
/// should fall back to the pre-machine, client-scoped binding shape.
238+
pub async fn effective_machine_id(
239+
&self,
240+
client_id: Option<&str>,
241+
request_machine_id: Option<Uuid>,
242+
) -> Result<Option<Uuid>> {
243+
if let Some(id) = request_machine_id {
244+
return Ok(Some(id));
245+
}
246+
if let Some(cid) = client_id {
247+
if let Some(client_machine) = self.client_repo.get_machine_id(cid).await? {
248+
return Ok(Some(client_machine));
249+
}
250+
}
251+
Ok(*self.local_machine_id.read().await)
252+
}
253+
231254
/// Tier 1 exact binding lookup: request machine header, then client machine,
232255
/// then local machine, then global.
233256
async fn find_binding_for_roots(

crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs

Lines changed: 171 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
//! `mcpmux_bind_current_workspace` — persistently layer a FeatureSet onto a workspace binding.
22
33
use async_trait::async_trait;
4-
use mcpmux_core::{normalize_workspace_root, WorkspaceBinding};
4+
use mcpmux_core::{normalize_workspace_root, WorkspaceBinding, WorkspaceBindingRepository};
55
use rmcp::model::CallToolResult;
66
use serde_json::{json, Value};
77
use tracing::info;
8+
use uuid::Uuid;
89

910
use 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

1516
pub 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]
18104
impl 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

Comments
 (0)