Skip to content

Commit 02bd7b9

Browse files
committed
fix(gateway,ui): collapse describe tools, fire list_changed on Connect, badge denominator
* Collapse `mcpmux_describe_resolution` into `mcpmux_describe_workspace`. The split surfaced two reads with overlapping output and shipped a redundant tool to LLMs. `describe_workspace` now returns a `resolution` block with `feature_set_id`, `feature_set_name`, `source`, and `resolved_tool_count`. * Fire `list_changed` on `ServerStatusChanged(Connected)`, not just Disconnected. Reconnect flips per-feature `is_available`, which `get_all_features_for_space` filters on, so the content hash legitimately changes both ways. Without this, a backend reconnect after the client's initial `tools/list` left the client view stuck without the freshly-available tools. Loop concern mooted by the existing hash dedup. * `WorkspacesPage` per-server badge now reads `{mapped}/{server total}` — backend returns `server_totals` (HashMap of server_id -> per-type counts) computed before the FS filter is applied. The old `3/3` was `mapped/mapped`; the new badge tells the user "this FS includes 3 of the 10 cloudflare-docs tools available." * `WorkspacesPage` listens for `workspace-binding-changed` so popup-driven binding saves refresh the page live (previously stayed on `UNMAPPED` until the user navigated away and back). * Migration 008 enforces the default-space invariant: the seeded `My Space` row is the canonical default; every other row gets `is_default = 0`. Repairs DBs corrupted by the older `if no spaces exist, set_default()` branch (now removed from `SpaceAppService::create`). Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent ac33136 commit 02bd7b9

10 files changed

Lines changed: 232 additions & 97 deletions

File tree

apps/desktop/src-tauri/src/commands/workspace_binding.rs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,17 @@ pub struct EffectiveFeatureDto {
320320
pub available: bool,
321321
}
322322

323+
/// Per-server total counts in the resolved Space, regardless of the
324+
/// FeatureSet filter. The UI shows badges like "3 / {total}" — the right
325+
/// side is the total the server exposes in the Space, so the user can see
326+
/// "this FS includes 3 of the 10 cloudflare-docs tools available."
327+
#[derive(Debug, Clone, Serialize)]
328+
pub struct ServerFeatureTotalsDto {
329+
pub tools: usize,
330+
pub prompts: usize,
331+
pub resources: usize,
332+
}
333+
323334
/// Top-level DTO: the resolved (Space, FeatureSet) pair for a given root,
324335
/// plus its full configured tool/prompt/resource lists with availability.
325336
#[derive(Debug, Clone, Serialize)]
@@ -342,6 +353,10 @@ pub struct WorkspaceEffectiveFeaturesDto {
342353
pub tools: Vec<EffectiveFeatureDto>,
343354
pub prompts: Vec<EffectiveFeatureDto>,
344355
pub resources: Vec<EffectiveFeatureDto>,
356+
/// `server_id -> totals` over every feature the server exposes in the
357+
/// resolved Space (no FS filter applied). Used by the UI to render
358+
/// "{mapped} / {server total}" badges.
359+
pub server_totals: HashMap<String, ServerFeatureTotalsDto>,
345360
}
346361

347362
/// Walk a FeatureSet's members (with nested-FS recursion) to compute the
@@ -518,14 +533,32 @@ pub async fn get_workspace_effective_features(
518533
let mut visited = HashSet::<String>::new();
519534
collect_member_ids(&fs, &fs_lookup, &mut allowed, &mut excluded, &mut visited);
520535

521-
// 7. Pull every feature in the Space, then keep only those that pass
522-
// the FS filter — without the `is_available` gate, so we can show
523-
// "configured but disconnected" rows.
536+
// 7. Pull every feature in the Space, compute per-server totals (the
537+
// badge denominator), then keep only the FS-filtered subset for the
538+
// rendered list. The `is_available` gate is intentionally not
539+
// applied here — disconnected features still appear, dimmed.
524540
let all_features = state
525541
.server_feature_repository_core
526542
.list_for_space(&space_id.to_string())
527543
.await
528544
.map_err(|e| e.to_string())?;
545+
546+
let mut server_totals: HashMap<String, ServerFeatureTotalsDto> = HashMap::new();
547+
for f in &all_features {
548+
let entry = server_totals
549+
.entry(f.server_id.clone())
550+
.or_insert(ServerFeatureTotalsDto {
551+
tools: 0,
552+
prompts: 0,
553+
resources: 0,
554+
});
555+
match f.feature_type {
556+
mcpmux_core::FeatureType::Tool => entry.tools += 1,
557+
mcpmux_core::FeatureType::Prompt => entry.prompts += 1,
558+
mcpmux_core::FeatureType::Resource => entry.resources += 1,
559+
}
560+
}
561+
529562
let filtered: Vec<ServerFeature> = all_features
530563
.into_iter()
531564
.filter(|f| {
@@ -598,5 +631,6 @@ pub async fn get_workspace_effective_features(
598631
tools,
599632
prompts,
600633
resources,
634+
server_totals,
601635
})
602636
}

apps/desktop/src/features/workspaces/WorkspacesPage.tsx

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,21 @@ export function WorkspacesPage() {
109109
void loadData().finally(() => setIsLoading(false));
110110
}, [loadData]);
111111

112-
// Refresh the list whenever a session reports (or changes) its roots.
112+
// Refresh whenever something the table reflects changes outside the page:
113+
// • `session-roots-changed` — a connected client newly reported a root.
114+
// • `workspace-binding-changed` — a binding was created/updated/deleted
115+
// by another surface (e.g. the new-workspace popup or the meta-tool).
116+
// Without the binding listener, popup-driven saves leave this page showing
117+
// the stale "UNMAPPED" badge until the user navigates away and back.
113118
useEffect(() => {
114-
const un = listen('session-roots-changed', () => {
119+
const reload = () => {
115120
void loadData();
116-
});
121+
};
122+
const unRoots = listen('session-roots-changed', reload);
123+
const unBinding = listen('workspace-binding-changed', reload);
117124
return () => {
118-
un.then((fn) => fn());
125+
unRoots.then((fn) => fn());
126+
unBinding.then((fn) => fn());
119127
};
120128
}, [loadData]);
121129

@@ -942,15 +950,23 @@ interface ServerGroup {
942950
tools: EffectiveFeature[];
943951
prompts: EffectiveFeature[];
944952
resources: EffectiveFeature[];
945-
total: number;
946-
unavailable_total: number;
953+
/** Mapped count for this server in the resolved FS (= tools+prompts+resources lengths). */
954+
mapped: number;
955+
/** Total count of features the server exposes in the resolved Space, regardless of FS. */
956+
server_total: number;
957+
/** Of `mapped`, how many are unavailable because the server is disconnected. */
958+
unavailable_mapped: number;
947959
}
948960

949961
function buildServerGroups(data: WorkspaceEffectiveFeatures): ServerGroup[] {
950962
const map = new Map<string, ServerGroup>();
951963
const place = (item: EffectiveFeature, kind: 'tool' | 'prompt' | 'resource') => {
952964
let g = map.get(item.server_id);
953965
if (!g) {
966+
const totals = data.server_totals[item.server_id];
967+
const server_total = totals
968+
? totals.tools + totals.prompts + totals.resources
969+
: 0;
954970
g = {
955971
server_id: item.server_id,
956972
server_alias: item.server_alias ?? item.server_id,
@@ -961,16 +977,17 @@ function buildServerGroups(data: WorkspaceEffectiveFeatures): ServerGroup[] {
961977
tools: [],
962978
prompts: [],
963979
resources: [],
964-
total: 0,
965-
unavailable_total: 0,
980+
mapped: 0,
981+
server_total,
982+
unavailable_mapped: 0,
966983
};
967984
map.set(item.server_id, g);
968985
}
969986
if (kind === 'tool') g.tools.push(item);
970987
else if (kind === 'prompt') g.prompts.push(item);
971988
else g.resources.push(item);
972-
g.total += 1;
973-
if (!item.available) g.unavailable_total += 1;
989+
g.mapped += 1;
990+
if (!item.available) g.unavailable_mapped += 1;
974991
};
975992
for (const t of data.tools) place(t, 'tool');
976993
for (const p of data.prompts) place(p, 'prompt');
@@ -1063,7 +1080,7 @@ function EffectiveFeaturesContent({
10631080
const groups = useMemo(() => (data ? buildServerGroups(data) : []), [data]);
10641081
const totalCount = data ? data.tools.length + data.prompts.length + data.resources.length : 0;
10651082
const availableCount = useMemo(
1066-
() => groups.reduce((acc, g) => acc + (g.total - g.unavailable_total), 0),
1083+
() => groups.reduce((acc, g) => acc + (g.mapped - g.unavailable_mapped), 0),
10671084
[groups]
10681085
);
10691086

@@ -1203,9 +1220,13 @@ function ServerGroupRow({
12031220
onToggle: () => void;
12041221
}) {
12051222
const issue = serverStatusIssue(group.server_status);
1206-
const availableCount = group.total - group.unavailable_total;
1207-
const allAvailable = group.total > 0 && availableCount === group.total;
1208-
const someAvailable = availableCount > 0 && availableCount < group.total;
1223+
const availableCount = group.mapped - group.unavailable_mapped;
1224+
// Badge denominator is the server's *total* feature count in the Space,
1225+
// not the mapped count — the user wants to see "3 of 10 cloudflare-docs
1226+
// tools are in this FS" rather than "3 of 3 mapped tools work".
1227+
const denominator = group.server_total > 0 ? group.server_total : group.mapped;
1228+
const allAvailable = group.mapped > 0 && availableCount === group.mapped;
1229+
const someAvailable = availableCount > 0 && availableCount < group.mapped;
12091230
const noneAvailable = availableCount === 0;
12101231

12111232
// Strip reverse-DNS prefix so display reads "cloudflare-bindings" not
@@ -1252,7 +1273,7 @@ function ServerGroupRow({
12521273
: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border border-amber-300/70 dark:border-amber-700/70',
12531274
].join(' ')}
12541275
>
1255-
{availableCount}/{group.total}
1276+
{group.mapped}/{denominator}
12561277
</span>
12571278
{issue && (
12581279
<span
@@ -1285,8 +1306,8 @@ function ServerGroupRow({
12851306
].join(' ')}
12861307
style={{
12871308
width:
1288-
group.total > 0
1289-
? `${(availableCount / group.total) * 100}%`
1309+
group.mapped > 0
1310+
? `${(availableCount / group.mapped) * 100}%`
12901311
: '0%',
12911312
}}
12921313
/>

apps/desktop/src/lib/api/workspaceBindings.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,16 @@ export interface EffectiveFeature {
123123
available: boolean;
124124
}
125125

126+
/**
127+
* Per-server total feature counts in the resolved Space, regardless of FS
128+
* filter. The right-hand side of the "{mapped} / {total}" badges.
129+
*/
130+
export interface ServerFeatureTotals {
131+
tools: number;
132+
prompts: number;
133+
resources: number;
134+
}
135+
126136
export interface WorkspaceEffectiveFeatures {
127137
workspace_root: string;
128138
/** `binding` when a saved WorkspaceBinding matched; `fallback` for the default Space's Default FS. */
@@ -136,6 +146,8 @@ export interface WorkspaceEffectiveFeatures {
136146
tools: EffectiveFeature[];
137147
prompts: EffectiveFeature[];
138148
resources: EffectiveFeature[];
149+
/** `server_id -> totals` for every server installed in the resolved Space. */
150+
server_totals: Record<string, ServerFeatureTotals>;
139151
}
140152

141153
/**

crates/mcpmux-core/src/application/space.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,18 @@ impl SpaceAppService {
5151

5252
/// Create a new space
5353
///
54+
/// User-created spaces are NEVER marked as default — the canonical
55+
/// default is the seeded "My Space" row, restored by migration 008 if
56+
/// it goes missing. The previous "first-created becomes default" branch
57+
/// caused durable corruption on installs that hit it.
58+
///
5459
/// Emits: `SpaceCreated`
5560
pub async fn create(&self, name: &str, icon: Option<String>) -> Result<Space> {
5661
let mut space = Space::new(name);
5762
if let Some(icon) = &icon {
5863
space = space.with_icon(icon);
5964
}
6065

61-
// If no spaces exist, make this one the default
62-
let existing = self.space_repo.list().await?;
63-
if existing.is_empty() {
64-
space = space.set_default();
65-
}
66-
6766
// Persist
6867
self.space_repo.create(&space).await?;
6968

crates/mcpmux-gateway/src/consumers/mcp_notifier.rs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -484,25 +484,40 @@ impl MCPNotifier {
484484
} => {
485485
use mcpmux_core::ConnectionStatus;
486486

487-
// Only notify if server disconnected (features unavailable)
488-
// We DO NOT notify on Connect because:
489-
// 1. If it's a new server, ToolsChanged will fire separately if needed
490-
// 2. If it's a reconnect, hashing will handle it
491-
// 3. Most importantly: Client connections trigger auto-connects, which would cause loops
492-
if matches!(status, ConnectionStatus::Disconnected) {
487+
// Disconnect AND reconnect both flip the per-feature
488+
// `is_available` flag, which `get_all_features_for_space`
489+
// filters on — so the content hash actually changes both
490+
// ways. We notify on each so the client's effective tool
491+
// list reflects "configured but unavailable" features
492+
// dropping out (on Disconnect) and coming back in (on
493+
// Connect). `force=false` lets the hash dedup absorb the
494+
// intermediate transient states (Connecting / Refreshing /
495+
// AuthRequired) without spamming.
496+
//
497+
// Loop concern (the old comment): a client `tools/list`
498+
// query that triggers a lazy backend connect would chain
499+
// Connected -> list_changed -> client refetch. Hashing
500+
// breaks that chain on the second iteration: the second
501+
// refetch sees the same hash as the first and dedupes.
502+
let should_notify = matches!(
503+
status,
504+
ConnectionStatus::Connected | ConnectionStatus::Disconnected
505+
);
506+
if should_notify {
493507
info!(
494508
server_id = %server_id,
495509
space_id = %space_id,
496510
status = ?status,
497-
"[MCPNotifier] ServerStatusChanged (Disconnected) - notifying clients to clear features"
511+
"[MCPNotifier] ServerStatusChanged ({:?}) - re-checking effective list",
512+
status,
498513
);
499514
self.notify_all_list_changed(space_id, false).await;
500515
} else {
501516
debug!(
502517
server_id = %server_id,
503518
space_id = %space_id,
504519
status = ?status,
505-
"[MCPNotifier] ServerStatusChanged - ignoring (not a disconnection)"
520+
"[MCPNotifier] ServerStatusChanged - transient state, no notify"
506521
);
507522
}
508523
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ pub fn build_default_registry(
7777
// Reads — no approval needed.
7878
registry.register(Box::new(tools::ListAllToolsTool));
7979
registry.register(Box::new(tools::ListFeatureSetsTool));
80-
registry.register(Box::new(tools::DescribeResolutionTool));
80+
// `describe_workspace` also returns the resolution fields the older
81+
// `describe_resolution` tool used to expose — the split was confusing
82+
// for LLMs (two reads with overlapping output) and trimming it shrinks
83+
// the toolbar visible to the caller.
8184
registry.register(Box::new(tools::DescribeWorkspaceTool));
8285
// Writes — gated by ApprovalBroker.
8386
registry.register(Box::new(tools::CreateFeatureSetTool));

0 commit comments

Comments
 (0)