Skip to content

Commit f5c8a37

Browse files
committed
fix(meta-tools): bind emits WorkspaceBindingChanged so the Workspaces UI refreshes
`mcpmux_bind_current_workspace` emitted `FeatureSetMembersChanged`, which maps to the `feature-set-changed` UI channel. But the desktop Workspaces tab only listens for `workspace-binding-changed`, so an agent-driven bind/rebind never refreshed it — the folder kept showing the stale "Unmapped" badge until the user navigated away and back. Emit `WorkspaceBindingChanged` instead. It is the semantically correct event: it carries the workspace root, drives MCPNotifier''s list_changed push to peers (so MCP clients still re-fetch), AND is the event the Workspaces tab refreshes on. FeatureSet meta-tools keep using FeatureSetMembersChanged. Adds an integration test asserting a successful bind emits WorkspaceBindingChanged with the normalized root. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 6cd3630 commit f5c8a37

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,15 @@ impl MetaTool for BindCurrentWorkspaceTool {
690690
feature_set_ids = ?fs_ids,
691691
"[meta_tools] bind_current_workspace applied",
692692
);
693-
emit_tools_list_changed(&event_tx, space_id);
693+
// A binding change isn't a FeatureSet-membership change — emit
694+
// the binding-specific event. It both drives MCPNotifier's
695+
// list_changed push to peers AND is the event the desktop
696+
// Workspaces tab refreshes on (`workspace-binding-changed`).
697+
// Using FeatureSetMembersChanged here left that tab stale.
698+
let _ = event_tx.send(DomainEvent::WorkspaceBindingChanged {
699+
space_id,
700+
workspace_root: normalized.clone(),
701+
});
694702
Ok(text_result(json!({
695703
"ok": true,
696704
"binding_id": binding_id,

tests/rust/tests/integration/meta_tools.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ struct Fixture {
3838
feature_set_repo: Arc<dyn FeatureSetRepository>,
3939
binding_repo: Arc<dyn WorkspaceBindingRepository>,
4040
session_roots: Arc<SessionRootsRegistry>,
41+
/// Domain-event sender the registry writes to; tests subscribe to assert
42+
/// the events the desktop UI / MCPNotifier react to are actually emitted.
43+
event_tx: broadcast::Sender<DomainEvent>,
4144
space_id: Uuid,
4245
/// Opaque client identity (UUID-as-string here; in production for DCR
4346
/// clients this can be a `client_metadata` URL).
@@ -113,6 +116,7 @@ impl Fixture {
113116

114117
let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500)));
115118
let (tx, _rx) = broadcast::channel::<DomainEvent>(32);
119+
let event_tx = tx.clone();
116120

117121
let builtin_config_repo: Arc<dyn SpaceBuiltinConfigRepository> =
118122
Arc::new(SqliteSpaceBuiltinConfigRepository::new(db.clone()));
@@ -139,13 +143,20 @@ impl Fixture {
139143
feature_set_repo,
140144
binding_repo,
141145
session_roots,
146+
event_tx,
142147
space_id,
143148
client_id,
144149
session_id,
145150
fs_android_id,
146151
}
147152
}
148153

154+
/// Subscribe to the registry's domain-event stream. Subscribe BEFORE the
155+
/// call under test — broadcast only delivers messages sent after subscribe.
156+
fn subscribe(&self) -> broadcast::Receiver<DomainEvent> {
157+
self.event_tx.subscribe()
158+
}
159+
149160
/// Attach a publisher that always auto-approves with the given decision.
150161
fn attach_auto_publisher(&self, decision: ApprovalDecision) {
151162
let broker = self.broker.clone();
@@ -526,6 +537,59 @@ async fn bind_current_workspace_creates_binding_with_normalized_root() {
526537
);
527538
}
528539

540+
/// A successful bind must emit `WorkspaceBindingChanged` (not a generic
541+
/// FeatureSet-members event) — that's the event the desktop Workspaces tab
542+
/// refreshes on, and the one MCPNotifier turns into a list_changed push.
543+
/// Regression guard for "workspace mapping didn't refresh in the UI".
544+
#[tokio::test(flavor = "multi_thread")]
545+
async fn bind_current_workspace_emits_workspace_binding_changed() {
546+
let f = Fixture::new().await;
547+
f.attach_auto_publisher(ApprovalDecision::AllowOnce);
548+
let mut rx = f.subscribe();
549+
550+
let input = if cfg!(windows) {
551+
"D:\\Projects\\Notify\\"
552+
} else {
553+
"/proj/notify"
554+
};
555+
f.session_roots.set(&f.session_id, [input]);
556+
557+
f.registry
558+
.call(
559+
"mcpmux_bind_current_workspace",
560+
&f.client_id,
561+
Some(&f.session_id),
562+
json!({ "feature_set_id": f.fs_android_id.to_string() }),
563+
)
564+
.await
565+
.unwrap();
566+
567+
// The stream also carries the central MetaToolInvoked audit event, so scan
568+
// for the binding-changed signal specifically rather than asserting on the
569+
// first event received.
570+
let expected_root = normalize_workspace_root(input);
571+
let mut found = false;
572+
for _ in 0..8 {
573+
match tokio::time::timeout(Duration::from_millis(300), rx.recv()).await {
574+
Ok(Ok(DomainEvent::WorkspaceBindingChanged {
575+
space_id,
576+
workspace_root,
577+
})) => {
578+
assert_eq!(space_id, f.space_id);
579+
assert_eq!(workspace_root, expected_root);
580+
found = true;
581+
break;
582+
}
583+
Ok(Ok(_other)) => continue, // e.g. MetaToolInvoked — skip
584+
Ok(Err(_)) | Err(_) => break, // channel closed/lagged or timed out
585+
}
586+
}
587+
assert!(
588+
found,
589+
"bind must emit WorkspaceBindingChanged so the Workspaces UI refreshes"
590+
);
591+
}
592+
529593
#[tokio::test(flavor = "multi_thread")]
530594
async fn invalid_feature_set_argument_rejected() {
531595
let f = Fixture::new().await;

0 commit comments

Comments
 (0)