Skip to content

Commit ed8a5fb

Browse files
committed
fix(gateway): upsert workspace binding in bind_current_workspace
Rebind an existing workspace root via update instead of insert, avoiding UNIQUE constraint failures when Starter or UI bindings already exist. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 270921d commit ed8a5fb

3 files changed

Lines changed: 95 additions & 12 deletions

File tree

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

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use super::approval::{ApprovalPayload, ApprovalScope};
1818
use super::registry::{
1919
MetaTool, MetaToolCall, MetaToolError, SESSION_OVERRIDES_REQUIRE_APPROVAL_KEY,
2020
};
21+
use super::workspace_server::emit_workspace_binding_changed;
2122
use crate::services::ResolvedFeatureSet;
2223

2324
/// Fire a `FeatureSetMembersChanged` event so MCPNotifier pushes a
@@ -1030,19 +1031,46 @@ impl MetaTool for BindCurrentWorkspaceTool {
10301031
true,
10311032
call.args.clone(),
10321033
|| async move {
1033-
let binding =
1034-
WorkspaceBinding::new(normalized.clone(), space_id, fs_id.to_string());
1035-
binding_repo.create(&binding).await?;
1036-
info!(
1037-
%space_id,
1038-
workspace_root = %normalized,
1039-
feature_set_id = %fs_id,
1040-
"[meta_tools] bind_current_workspace applied",
1041-
);
1034+
let fs_id_str = fs_id.to_string();
1035+
let existing = binding_repo
1036+
.list()
1037+
.await?
1038+
.into_iter()
1039+
.find(|b| b.workspace_root == normalized);
1040+
1041+
let binding_id = if let Some(mut binding) = existing {
1042+
binding.space_id = space_id;
1043+
binding.feature_set_ids = vec![fs_id_str.clone()];
1044+
binding.updated_at = chrono::Utc::now();
1045+
binding_repo.update(&binding).await?;
1046+
emit_workspace_binding_changed(&event_tx, space_id, &normalized);
1047+
info!(
1048+
%space_id,
1049+
binding_id = %binding.id,
1050+
workspace_root = %normalized,
1051+
feature_set_id = %fs_id,
1052+
"[meta_tools] bind_current_workspace updated existing binding",
1053+
);
1054+
binding.id
1055+
} else {
1056+
let binding =
1057+
WorkspaceBinding::new(normalized.clone(), space_id, fs_id_str.clone());
1058+
let binding_id = binding.id;
1059+
binding_repo.create(&binding).await?;
1060+
info!(
1061+
%space_id,
1062+
binding_id = %binding_id,
1063+
workspace_root = %normalized,
1064+
feature_set_id = %fs_id,
1065+
"[meta_tools] bind_current_workspace created binding",
1066+
);
1067+
binding_id
1068+
};
1069+
10421070
emit_tools_list_changed(&event_tx, space_id);
10431071
Ok(text_result(json!({
10441072
"ok": true,
1045-
"binding_id": binding.id,
1073+
"binding_id": binding_id,
10461074
"workspace_root": normalized,
10471075
"feature_set_id": fs_id,
10481076
})))

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async fn resolve_workspace_binding(
5050
Ok((binding, normalized))
5151
}
5252

53-
fn emit_workspace_binding_changed(
53+
pub(crate) fn emit_workspace_binding_changed(
5454
event_tx: &broadcast::Sender<DomainEvent>,
5555
space_id: Uuid,
5656
workspace_root: &str,

tests/rust/tests/integration/meta_tools.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use mcpmux_core::{
1414
normalize_workspace_root, Client, DomainEvent, FeatureSet, FeatureSetMember,
1515
FeatureSetRepository, InboundMcpClientRepository, InstalledServer, InstalledServerRepository,
1616
MemberMode, MemberType, ServerFeature, ServerFeatureRepository, SpaceRepository,
17-
WorkspaceBindingRepository,
17+
WorkspaceBinding, WorkspaceBindingRepository,
1818
};
1919
use mcpmux_gateway::pool::FeatureService;
2020
use mcpmux_gateway::services::{
@@ -759,6 +759,61 @@ async fn bind_current_workspace_creates_binding_with_normalized_root() {
759759
);
760760
}
761761

762+
#[tokio::test(flavor = "multi_thread")]
763+
async fn bind_current_workspace_updates_existing_binding_for_same_root() {
764+
let f = Fixture::new().await;
765+
f.attach_auto_publisher(ApprovalDecision::AllowOnce);
766+
let input = if cfg!(windows) {
767+
"D:\\Projects\\Android\\MyApp\\"
768+
} else {
769+
"/home/me/projects/android/myapp/"
770+
};
771+
let normalized = normalize_workspace_root(input);
772+
f.session_roots.set(&f.session_id, [input]);
773+
774+
let fs_full_id = {
775+
let sets = f
776+
.feature_set_repo
777+
.list_by_space(&f.space_id.to_string())
778+
.await
779+
.unwrap();
780+
let full = sets
781+
.iter()
782+
.find(|fs| fs.name == "Full Access")
783+
.expect("Full Access FS");
784+
Uuid::parse_str(&full.id).unwrap()
785+
};
786+
787+
// Seed an existing binding (simulates Workspaces UI or prior bind).
788+
let starter = WorkspaceBinding::new(
789+
normalized.clone(),
790+
f.space_id,
791+
f.fs_android_id.to_string(),
792+
);
793+
f.binding_repo.create(&starter).await.unwrap();
794+
795+
let result = f
796+
.registry
797+
.call(
798+
"mcpmux_bind_current_workspace",
799+
&f.client_id,
800+
Some(&f.session_id),
801+
json!({ "feature_set_id": fs_full_id.to_string() }),
802+
)
803+
.await
804+
.unwrap();
805+
assert!(!Fixture::is_error(&result));
806+
807+
let bindings = f.binding_repo.list_for_space(&f.space_id).await.unwrap();
808+
assert_eq!(bindings.len(), 1, "must not insert a second binding row");
809+
assert_eq!(bindings[0].id, starter.id, "must reuse existing binding id");
810+
assert_eq!(bindings[0].workspace_root, normalized);
811+
assert_eq!(
812+
bindings[0].feature_set_ids,
813+
vec![fs_full_id.to_string()]
814+
);
815+
}
816+
762817
#[tokio::test(flavor = "multi_thread")]
763818
async fn invalid_feature_set_argument_rejected() {
764819
let f = Fixture::new().await;

0 commit comments

Comments
 (0)