Skip to content

Commit 138d53d

Browse files
committed
fix(connect): merge onboarding connected_client_ids into wizard client list
GetAllStatus() / `GET /api/v1/connect` was made stat-only in #706 (MCP-2829) to kill the macOS App-Data prompt storm, so it now reports connected=false for every installed client. The Connect wizard and ConnectModal badge ride entirely on `c.connected`, regressing every connected client back to a blue "Connect" button (MCP-2951 / MCP-2952). Fix is frontend-only and adds zero new content reads: the wizard already fetches `connected_client_ids` (content-resolved) via onboarding.fetchState(). Expose it as `onboarding.connectedClientIds` and merge it into a derived client list (`mergedClients`) so a client renders Connected when `c.connected || connectedIds.has(c.id)`. ConnectModal now also refreshes the onboarding state on open (wizard-scoped, #706-safe) and merges the same way. The passively-polled Dashboard listing stays stat-only — no content resolution added to `GET /api/v1/connect`, so the #706 prompt storm does not return. Tests: ConnectModal renders Disconnect for an id in connected_client_ids despite connected=false (Connect still shown for genuinely-unconnected rows); OnboardingWizard mirrors the same on the Clients panel. Related #696
1 parent e8d98a7 commit 138d53d

5 files changed

Lines changed: 218 additions & 6 deletions

File tree

frontend/src/components/ConnectModal.vue

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
<!-- Client list -->
2323
<div v-else class="space-y-2">
2424
<div
25-
v-for="client in clients"
25+
v-for="client in mergedClients"
2626
:key="client.id"
2727
class="flex items-center justify-between p-3 rounded-lg border border-base-300 hover:bg-base-200/50 transition-colors"
2828
>
@@ -91,6 +91,7 @@
9191
import { ref, reactive, computed, watch } from 'vue'
9292
import api from '@/services/api'
9393
import { useSystemStore } from '@/stores/system'
94+
import { useOnboardingStore } from '@/stores/onboarding'
9495
import type { ClientStatus } from '@/types'
9596
9697
interface Props {
@@ -104,6 +105,7 @@ interface Emits {
104105
const props = defineProps<Props>()
105106
const emit = defineEmits<Emits>()
106107
const systemStore = useSystemStore()
108+
const onboarding = useOnboardingStore()
107109
108110
const clients = ref<ClientStatus[]>([])
109111
const error = ref<string | null>(null)
@@ -114,10 +116,22 @@ const loading = reactive({
114116
clients: {} as Record<string, boolean>,
115117
})
116118
119+
// MCP-2952: `GET /api/v1/connect` is stat-only (#706/MCP-2829) and reports
120+
// connected=false for every client. Merge the content-resolved
121+
// connected_client_ids (fetched on open via onboarding.fetchState) so already
122+
// connected clients render Disconnect instead of a fresh Connect button.
123+
// Derived (not mutated) so refreshes stay correct.
124+
const mergedClients = computed<ClientStatus[]>(() => {
125+
const connectedIds = new Set(onboarding.connectedClientIds)
126+
return clients.value.map(c =>
127+
c.connected || !connectedIds.has(c.id) ? c : { ...c, connected: true }
128+
)
129+
})
130+
117131
const connectableClients = computed(() =>
118132
// Bridge clients (e.g. Claude Desktop) can be connected even without an
119133
// existing config file — Connect creates it.
120-
clients.value.filter(c => c.supported && (c.exists || c.bridge) && !c.connected)
134+
mergedClients.value.filter(c => c.supported && (c.exists || c.bridge) && !c.connected)
121135
)
122136
123137
const allConnected = computed(() =>
@@ -226,10 +240,13 @@ function close() {
226240
emit('close')
227241
}
228242
229-
// Fetch client list when modal opens
243+
// Fetch client list when modal opens. Also refresh the onboarding state so
244+
// connected_client_ids is current — this is the wizard-scoped, #706-safe path
245+
// that already content-resolves connections (MCP-2952).
230246
watch(() => props.show, (newVal) => {
231247
if (newVal) {
232248
fetchClients()
249+
void onboarding.fetchState()
233250
resultMessage.value = ''
234251
}
235252
})

frontend/src/components/OnboardingWizard.vue

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -657,18 +657,30 @@ const modalSizing = computed(() => ({
657657
// --- Client sort: detected → pinned trio → others -----------------------
658658
const PINNED_TRIO: readonly string[] = ['claude-code', 'codex', 'gemini']
659659
660+
// MCP-2952: the `GET /api/v1/connect` listing is stat-only (#706/MCP-2829) and
661+
// always reports connected=false. Merge the content-resolved
662+
// connected_client_ids the wizard already fetched via onboarding.fetchState()
663+
// so connected clients render the Connected badge instead of a Connect button.
664+
// Derived (not mutated) so polling refreshes stay correct.
665+
const mergedClients = computed<ClientStatus[]>(() => {
666+
const connectedIds = new Set(onboarding.connectedClientIds)
667+
return clients.value.map(c =>
668+
c.connected || !connectedIds.has(c.id) ? c : { ...c, connected: true }
669+
)
670+
})
671+
660672
const detectedClients = computed(() =>
661-
clients.value.filter(c => c.exists)
673+
mergedClients.value.filter(c => c.exists)
662674
)
663675
const pinnedClients = computed(() =>
664676
PINNED_TRIO
665-
.map(id => clients.value.find(c => c.id === id))
677+
.map(id => mergedClients.value.find(c => c.id === id))
666678
.filter((c): c is ClientStatus => !!c && !c.exists)
667679
)
668680
const moreClients = computed(() => {
669681
const detectedIds = new Set(detectedClients.value.map(c => c.id))
670682
const pinnedIds = new Set(pinnedClients.value.map(c => c.id))
671-
return clients.value.filter(c => !detectedIds.has(c.id) && !pinnedIds.has(c.id))
683+
return mergedClients.value.filter(c => !detectedIds.has(c.id) && !pinnedIds.has(c.id))
672684
})
673685
674686
const serverCountLabel = computed(() => {

frontend/src/stores/onboarding.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ export const useOnboardingStore = defineStore('onboarding', () => {
4040
const mcpClientsSeenEver = computed<string[]>(() => state.value?.mcp_clients_seen_ever ?? [])
4141
const incompleteTabCount = computed(() => state.value?.incomplete_tab_count ?? 0)
4242

43+
// MCP-2952 — content-resolved IDs of clients currently wired to MCPProxy.
44+
// GetAllStatus()/`GET /api/v1/connect` is stat-only (#706/MCP-2829) and
45+
// reports connected=false for every client, so the Connect wizard merges
46+
// these IDs (already fetched with the onboarding state) to mark connected
47+
// clients without triggering new content reads.
48+
const connectedClientIds = computed<string[]>(() => state.value?.connected_client_ids ?? [])
49+
4350
// v1 visibleSteps kept for back-compat with any remaining caller. The v2
4451
// wizard renders a fixed three-tab surface and ignores this value.
4552
const visibleSteps = computed<Array<'connect' | 'server'>>(() => {
@@ -161,6 +168,7 @@ export const useOnboardingStore = defineStore('onboarding', () => {
161168
firstMCPClientEver,
162169
mcpClientsSeenEver,
163170
incompleteTabCount,
171+
connectedClientIds,
164172
visibleSteps,
165173
fetchState,
166174
mark,

frontend/tests/unit/connect-modal.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ vi.mock('@/services/api', () => ({
99
getConnectStatus: vi.fn(),
1010
connectClient: vi.fn(),
1111
disconnectClient: vi.fn(),
12+
getOnboardingState: vi.fn(),
1213
},
1314
}))
1415

@@ -21,6 +22,10 @@ describe('ConnectModal', () => {
2122
;(api.getConnectStatus as any).mockReset()
2223
;(api.connectClient as any).mockReset()
2324
;(api.disconnectClient as any).mockReset()
25+
;(api.getOnboardingState as any).mockReset()
26+
// Default: no content-resolved connections (most tests exercise the
27+
// stat-only listing only). Individual tests override as needed.
28+
;(api.getOnboardingState as any).mockResolvedValue({ success: true, data: null })
2429
})
2530

2631
it('renders an OpenCode row', async () => {
@@ -200,4 +205,67 @@ describe('ConnectModal', () => {
200205

201206
expect(disconnectSpy).toHaveBeenCalledWith('opencode', 'proxy-alt')
202207
})
208+
209+
// MCP-2952: GetAllStatus() is stat-only (#706) and always reports
210+
// connected=false. The wizard/modal must merge the content-resolved
211+
// connected_client_ids from the onboarding state so already-connected
212+
// clients render Disconnect, not a fresh Connect button.
213+
it('renders Disconnect for a client present in connected_client_ids despite connected=false', async () => {
214+
;(api.getConnectStatus as any).mockResolvedValue({
215+
success: true,
216+
data: [
217+
{
218+
id: 'codex',
219+
name: 'Codex CLI',
220+
config_path: '/Users/test/.codex/config.toml',
221+
exists: true,
222+
connected: false, // stat-only listing never sets this
223+
supported: true,
224+
icon: 'codex',
225+
},
226+
{
227+
id: 'cursor',
228+
name: 'Cursor',
229+
config_path: '/Users/test/.cursor/mcp.json',
230+
exists: true,
231+
connected: false, // genuinely not connected
232+
supported: true,
233+
icon: 'cursor',
234+
},
235+
],
236+
})
237+
;(api.getOnboardingState as any).mockResolvedValue({
238+
success: true,
239+
data: {
240+
has_connected_client: true,
241+
has_configured_server: false,
242+
connected_client_count: 1,
243+
connected_client_ids: ['codex'],
244+
configured_server_count: 0,
245+
state: { engaged: false },
246+
should_show_wizard: true,
247+
first_mcp_client_ever: false,
248+
mcp_clients_seen_ever: [],
249+
incomplete_tab_count: 0,
250+
},
251+
})
252+
253+
const wrapper = mount(ConnectModal, {
254+
props: { show: false },
255+
global: { plugins: [pinia] },
256+
})
257+
258+
await wrapper.setProps({ show: true })
259+
await flushPromises()
260+
261+
// codex resolved as connected -> Disconnect button.
262+
const codexRow = wrapper.find('button.btn-ghost.text-error')
263+
expect(codexRow.exists()).toBe(true)
264+
expect(codexRow.text()).toContain('Disconnect')
265+
266+
// cursor is genuinely not connected -> Connect button still offered.
267+
const connectButtons = wrapper.findAll('button.btn-primary.btn-xs')
268+
expect(connectButtons.length).toBe(1)
269+
expect(connectButtons[0].text()).toContain('Connect')
270+
})
203271
})
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest'
2+
import { mount, flushPromises } from '@vue/test-utils'
3+
import { createPinia, setActivePinia } from 'pinia'
4+
import OnboardingWizard from '@/components/OnboardingWizard.vue'
5+
import api from '@/services/api'
6+
7+
// MCP-2952: GetAllStatus() is stat-only (#706/MCP-2829) and always reports
8+
// connected=false for every installed client. The wizard already fetches the
9+
// onboarding state (which carries content-resolved connected_client_ids) and
10+
// must merge those IDs into the per-client list so connected clients render a
11+
// "Connected" badge instead of a fresh Connect button.
12+
13+
vi.mock('@/services/api', () => ({
14+
default: {
15+
getConnectStatus: vi.fn(),
16+
getOnboardingState: vi.fn(),
17+
getActivities: vi.fn(),
18+
getConfig: vi.fn(),
19+
getDockerStatus: vi.fn(),
20+
getCanonicalConfigPaths: vi.fn(),
21+
connectClient: vi.fn(),
22+
},
23+
}))
24+
25+
function onboardingState(connectedIds: string[]) {
26+
return {
27+
success: true,
28+
data: {
29+
has_connected_client: connectedIds.length > 0,
30+
has_configured_server: true,
31+
connected_client_count: connectedIds.length,
32+
connected_client_ids: connectedIds,
33+
configured_server_count: 1,
34+
state: { engaged: false },
35+
should_show_wizard: true,
36+
first_mcp_client_ever: false,
37+
mcp_clients_seen_ever: [],
38+
incomplete_tab_count: 0,
39+
},
40+
}
41+
}
42+
43+
describe('OnboardingWizard connected_client_ids merge', () => {
44+
let pinia: any
45+
46+
beforeEach(() => {
47+
pinia = createPinia()
48+
setActivePinia(pinia)
49+
vi.clearAllMocks()
50+
// Safe defaults for the wizard's open lifecycle.
51+
;(api.getActivities as any).mockResolvedValue({ success: true, data: { activities: [] } })
52+
;(api.getConfig as any).mockResolvedValue({ success: true, data: {} })
53+
;(api.getDockerStatus as any).mockResolvedValue({ success: true, data: { available: false } })
54+
;(api.getCanonicalConfigPaths as any).mockResolvedValue({ success: true, data: { paths: [] } })
55+
})
56+
57+
it('renders Connected for a stat-only client present in connected_client_ids, Connect for the rest', async () => {
58+
;(api.getConnectStatus as any).mockResolvedValue({
59+
success: true,
60+
data: [
61+
{
62+
id: 'codex',
63+
name: 'Codex CLI',
64+
config_path: '/Users/test/.codex/config.toml',
65+
exists: true,
66+
connected: false, // stat-only listing never sets this
67+
supported: true,
68+
icon: 'codex',
69+
},
70+
{
71+
id: 'cursor',
72+
name: 'Cursor',
73+
config_path: '/Users/test/.cursor/mcp.json',
74+
exists: true,
75+
connected: false, // genuinely not connected
76+
supported: true,
77+
icon: 'cursor',
78+
},
79+
],
80+
})
81+
;(api.getOnboardingState as any).mockResolvedValue(onboardingState(['codex']))
82+
83+
const wrapper = mount(OnboardingWizard, {
84+
props: { show: false },
85+
global: { plugins: [pinia] },
86+
})
87+
88+
await wrapper.setProps({ show: true })
89+
await flushPromises()
90+
91+
// Ensure the Clients tab is active (initial tab depends on onboarding
92+
// predicates; the merge under test lives on the Clients panel).
93+
await wrapper.find('[data-test="tab-clients"]').trigger('click')
94+
await flushPromises()
95+
96+
// codex resolved as connected -> Connected badge, no Connect button.
97+
const codexRow = wrapper.find('[data-test="client-row-codex"]')
98+
expect(codexRow.exists()).toBe(true)
99+
expect(codexRow.text()).toContain('Connected')
100+
expect(wrapper.find('[data-test="connect-codex"]').exists()).toBe(false)
101+
102+
// cursor genuinely not connected -> Connect button still offered.
103+
const cursorRow = wrapper.find('[data-test="client-row-cursor"]')
104+
expect(cursorRow.exists()).toBe(true)
105+
expect(wrapper.find('[data-test="connect-cursor"]').exists()).toBe(true)
106+
})
107+
})

0 commit comments

Comments
 (0)