@n8n/frontend-module-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE.md +88 -0
  2. package/README.md +51 -0
  3. package/biome.jsonc +4 -0
  4. package/eslint.config.mjs +4 -0
  5. package/package.json +72 -0
  6. package/src/SettingsMCPAgentsView.test.ts +256 -0
  7. package/src/SettingsMCPAgentsView.vue +223 -0
  8. package/src/SettingsMCPClientsView.test.ts +280 -0
  9. package/src/SettingsMCPClientsView.vue +168 -0
  10. package/src/SettingsMCPView.test.ts +832 -0
  11. package/src/SettingsMCPView.vue +496 -0
  12. package/src/SettingsMCPWorkflowsView.test.ts +274 -0
  13. package/src/SettingsMCPWorkflowsView.vue +245 -0
  14. package/src/__tests__/design-system-icons.test.ts +24 -0
  15. package/src/__tests__/setup.ts +4 -0
  16. package/src/assets/client-icons/claude.svg +1 -0
  17. package/src/assets/client-icons/codex.svg +1 -0
  18. package/src/assets/client-icons/cursor.svg +1 -0
  19. package/src/assets/client-icons/gemini.svg +1 -0
  20. package/src/assets/client-icons/mistral.svg +1 -0
  21. package/src/assets/client-icons/openai.svg +3 -0
  22. package/src/assets/client-icons/vscode.svg +1 -0
  23. package/src/clients.utils.test.ts +85 -0
  24. package/src/clients.utils.ts +124 -0
  25. package/src/components/ConnectionParameter.vue +213 -0
  26. package/src/components/MCPAgentsSelect.vue +176 -0
  27. package/src/components/MCPEmptyState.vue +75 -0
  28. package/src/components/MCPWorkflowsSelect.test.ts +384 -0
  29. package/src/components/MCPWorkflowsSelect.vue +159 -0
  30. package/src/components/McpAccessToggle.vue +77 -0
  31. package/src/components/McpAccessTokenTab.test.ts +220 -0
  32. package/src/components/McpAccessTokenTab.vue +201 -0
  33. package/src/components/McpAllowedCallbackUrlsDialog.test.ts +122 -0
  34. package/src/components/McpAllowedCallbackUrlsDialog.vue +359 -0
  35. package/src/components/McpClientLogoCards.vue +134 -0
  36. package/src/components/McpConfigSnippet.vue +92 -0
  37. package/src/components/McpConnectClientDialog.test.ts +115 -0
  38. package/src/components/McpConnectClientDialog.vue +432 -0
  39. package/src/components/McpConnectedClientRow.test.ts +86 -0
  40. package/src/components/McpConnectedClientRow.vue +152 -0
  41. package/src/components/McpEmptyStateCard.vue +83 -0
  42. package/src/components/McpStatusControl.test.ts +33 -0
  43. package/src/components/McpStatusControl.vue +96 -0
  44. package/src/components/OAuthClientDetailsModal.vue +182 -0
  45. package/src/components/RevokeOAuthClientConfirmModal.vue +60 -0
  46. package/src/components/WorkflowLocation.test.ts +329 -0
  47. package/src/components/WorkflowLocation.vue +198 -0
  48. package/src/components/tabs/AgentsTable.vue +312 -0
  49. package/src/components/tabs/OAuthClientOwnerCell.vue +71 -0
  50. package/src/components/tabs/OAuthClientsFilters.test.ts +86 -0
  51. package/src/components/tabs/OAuthClientsFilters.vue +201 -0
  52. package/src/components/tabs/OAuthClientsTable.test.ts +394 -0
  53. package/src/components/tabs/OAuthClientsTable.vue +473 -0
  54. package/src/components/tabs/WorkflowsTable.test.ts +661 -0
  55. package/src/components/tabs/WorkflowsTable.vue +398 -0
  56. package/src/composables/useMcp.test.ts +59 -0
  57. package/src/composables/useMcp.ts +61 -0
  58. package/src/composables/useOAuthClientRevoke.ts +90 -0
  59. package/src/index.ts +10 -0
  60. package/src/mcp.api.test.ts +81 -0
  61. package/src/mcp.api.ts +206 -0
  62. package/src/mcp.clients.catalog.test.ts +63 -0
  63. package/src/mcp.clients.catalog.ts +183 -0
  64. package/src/mcp.constants.ts +45 -0
  65. package/src/mcp.module.test.ts +106 -0
  66. package/src/mcp.module.ts +90 -0
  67. package/src/mcp.store.test.ts +628 -0
  68. package/src/mcp.store.ts +490 -0
  69. package/src/mcp.test.utils.ts +57 -0
  70. package/src/mcp.types.ts +20 -0
  71. package/src/modals/MCPConnectAgentsModal.test.ts +141 -0
  72. package/src/modals/MCPConnectAgentsModal.vue +122 -0
  73. package/src/modals/MCPConnectWorkflowsModal.test.ts +303 -0
  74. package/src/modals/MCPConnectWorkflowsModal.vue +101 -0
  75. package/src/redirect-uris.utils.ts +22 -0
  76. package/stylelint.config.mjs +9 -0
  77. package/vite.config.ts +52 -0
@@ -0,0 +1,832 @@
1
+ import { nextTick } from 'vue';
2
+ import { createTestingPinia } from '@pinia/testing';
3
+ import { waitFor, within } from '@testing-library/vue';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { capabilities, capabilityRegistry } from '@n8n/frontend-module-sdk';
6
+ import type { McpExposeAllOffer } from '@n8n/frontend-module-sdk';
7
+ import type { Mock } from 'vitest';
8
+ import {
9
+ createComponentRenderer,
10
+ mockedStore,
11
+ type MockedStore,
12
+ waitAllPromises,
13
+ } from '@n8n/frontend-test-utils';
14
+ import SettingsMCPView from './SettingsMCPView.vue';
15
+ import { useMCPStore } from './mcp.store';
16
+ import { useSettingsStore } from '@n8n/stores/settings.store';
17
+ import { useRBACStore } from '@n8n/stores/rbac.store';
18
+ import type { FrontendSettings, OAuthClientResponseDto } from '@n8n/api-types';
19
+ import { MCP_CLIENTS_VIEW, MCP_WORKFLOWS_VIEW, UNKNOWN_COUNT_VALUE } from './mcp.constants';
20
+ import type { McpAgent, McpWorkflow } from './mcp.types';
21
+
22
+ import { createOAuthClient } from './mcp.test.utils';
23
+ import { useToast } from '@n8n/composables/useToast';
24
+
25
+ const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }));
26
+ const {
27
+ trackSpy,
28
+ trackAutoExposeToggledSpy,
29
+ trackConnectClientClickedSpy,
30
+ trackClientAccessRevokedSpy,
31
+ } = vi.hoisted(() => ({
32
+ trackSpy: vi.fn(),
33
+ trackAutoExposeToggledSpy: vi.fn(),
34
+ trackConnectClientClickedSpy: vi.fn(),
35
+ trackClientAccessRevokedSpy: vi.fn(),
36
+ }));
37
+
38
+ vi.mock('@n8n/composables/useTelemetry', () => ({
39
+ useTelemetry: () => ({ track: trackSpy }),
40
+ }));
41
+
42
+ vi.mock('@n8n/composables/useToast', () => {
43
+ const showMessage = vi.fn();
44
+ const showError = vi.fn();
45
+ return {
46
+ useToast: () => ({ showMessage, showError }),
47
+ };
48
+ });
49
+
50
+ vi.mock('vue-router', async (importOriginal) => ({
51
+ ...(await importOriginal()),
52
+ useRouter: () => ({ push: routerPush, replace: vi.fn() }),
53
+ useRoute: vi.fn(() => ({
54
+ params: {},
55
+ })),
56
+ RouterLink: {
57
+ template: '<a><slot /></a>',
58
+ },
59
+ }));
60
+
61
+ vi.mock('@n8n/composables/useDocumentTitle', () => ({
62
+ useDocumentTitle: () => ({
63
+ set: vi.fn(),
64
+ }),
65
+ }));
66
+
67
+ vi.mock('./composables/useMcp', () => ({
68
+ useMcp: () => ({
69
+ trackUserToggledMcpAccess: vi.fn(),
70
+ trackAutoExposeToggled: trackAutoExposeToggledSpy,
71
+ trackConnectClientClicked: trackConnectClientClickedSpy,
72
+ trackClientAccessRevoked: trackClientAccessRevokedSpy,
73
+ }),
74
+ }));
75
+
76
+ let pinia: ReturnType<typeof createTestingPinia>;
77
+ let mcpStore: MockedStore<typeof useMCPStore>;
78
+ let settingsStore: MockedStore<typeof useSettingsStore>;
79
+ let rbacStore: MockedStore<typeof useRBACStore>;
80
+ let exposeAllOffer: {
81
+ isEnabled: Mock<McpExposeAllOffer['isEnabled']>;
82
+ offer: Mock<McpExposeAllOffer['offer']>;
83
+ };
84
+
85
+ const createComponent = createComponentRenderer(SettingsMCPView, {
86
+ global: {
87
+ stubs: {
88
+ N8nTimeAgo: {
89
+ props: ['date'],
90
+ template: '<span>{{ date }}</span>',
91
+ },
92
+ MCPEmptyState: {
93
+ props: ['disabled', 'loading'],
94
+ template:
95
+ '<div data-test-id="mcp-empty-state"><button data-test-id="enable-mcp-button" :disabled="disabled" @click="$emit(\'turnOnMcp\')">Turn On</button></div>',
96
+ },
97
+ McpStatusControl: {
98
+ props: ['disabled', 'loading'],
99
+ template:
100
+ '<button data-test-id="disable-mcp-button" :disabled="disabled" @click="$emit(\'disable\')">Disable</button>',
101
+ },
102
+ McpConnectClientDialog: {
103
+ template: '<div data-test-id="mcp-connect-dialog-stub" />',
104
+ },
105
+ McpAllowedCallbackUrlsDialog: {
106
+ props: ['open', 'uris', 'saving'],
107
+ template:
108
+ '<div v-if="open" data-test-id="mcp-callback-urls-dialog-stub"><button data-test-id="stub-save-urls" @click="$emit(\'save\', [\'https://client.example.com/cb\'])">Save</button></div>',
109
+ },
110
+ },
111
+ },
112
+ });
113
+
114
+ const workflowPage = (data: McpWorkflow[] = [], count = data.length) => ({ data, count });
115
+
116
+ const enableMcpSettings = () => {
117
+ settingsStore.moduleSettings = {
118
+ mcp: {
119
+ mcpAccessEnabled: true,
120
+ mcpManagedByEnv: false,
121
+ autoExposeNewWorkflows: false,
122
+ },
123
+ };
124
+ };
125
+
126
+ describe('SettingsMCPView', () => {
127
+ beforeEach(() => {
128
+ pinia = createTestingPinia();
129
+ rbacStore = mockedStore(useRBACStore);
130
+ rbacStore.hasScope.mockReturnValue(true);
131
+ mcpStore = mockedStore(useMCPStore);
132
+ settingsStore = mockedStore(useSettingsStore);
133
+ exposeAllOffer = {
134
+ isEnabled: vi.fn(() => false),
135
+ offer: vi.fn(async () => false),
136
+ };
137
+ capabilityRegistry.provide(capabilities.mcpExposeAllOffer, exposeAllOffer);
138
+
139
+ settingsStore.settings = {
140
+ enterprise: {},
141
+ } as FrontendSettings;
142
+
143
+ settingsStore.moduleSettings = {
144
+ mcp: {
145
+ mcpAccessEnabled: false,
146
+ mcpManagedByEnv: false,
147
+ autoExposeNewWorkflows: false,
148
+ },
149
+ };
150
+
151
+ mcpStore.allowedRedirectUris = [];
152
+ mcpStore.oauthClientTotals = { mine: 0 };
153
+ mcpStore.oauthClientsPreview = [];
154
+ mcpStore.fetchOAuthClientsPreview.mockResolvedValue([]);
155
+ mcpStore.fetchWorkflowsAvailableForMCP.mockResolvedValue(workflowPage());
156
+ mcpStore.fetchAllowedRedirectUris.mockResolvedValue([]);
157
+ });
158
+
159
+ afterEach(() => {
160
+ capabilityRegistry.clear();
161
+ vi.clearAllMocks();
162
+ });
163
+
164
+ describe('Empty state (MCP disabled)', () => {
165
+ it('should render empty state when MCP access is disabled', async () => {
166
+ const { getByTestId, queryByTestId } = createComponent({ pinia });
167
+ await nextTick();
168
+
169
+ expect(getByTestId('mcp-settings-header')).toBeVisible();
170
+ expect(getByTestId('mcp-empty-state')).toBeVisible();
171
+ expect(queryByTestId('mcp-enabled-section')).not.toBeInTheDocument();
172
+ });
173
+ });
174
+
175
+ describe('MCP enabled state', () => {
176
+ beforeEach(() => {
177
+ enableMcpSettings();
178
+ mcpStore.oauthClientTotals = { mine: 2 };
179
+ });
180
+
181
+ it('should render the settings sections and the connected-clients row', async () => {
182
+ const { getByTestId, queryByTestId } = createComponent({ pinia });
183
+ await nextTick();
184
+
185
+ expect(getByTestId('mcp-settings-header')).toBeVisible();
186
+ expect(getByTestId('mcp-enabled-section')).toBeVisible();
187
+ expect(getByTestId('mcp-workflows-exposed-row')).toBeVisible();
188
+ expect(getByTestId('mcp-clients-view-all-row')).toBeVisible();
189
+ expect(queryByTestId('mcp-empty-state')).not.toBeInTheDocument();
190
+ });
191
+
192
+ it('should keep the connected-clients row (stating the count) when there are none', async () => {
193
+ mcpStore.oauthClientTotals = { mine: 0 };
194
+ mcpStore.fetchOAuthClientsPreview.mockResolvedValue([]);
195
+
196
+ const { getByTestId, queryByTestId } = createComponent({ pinia });
197
+
198
+ await waitFor(() => {
199
+ expect(queryByTestId('mcp-clients-empty')).not.toBeInTheDocument();
200
+ expect(queryByTestId('mcp-clients-preview')).not.toBeInTheDocument();
201
+ const row = getByTestId('mcp-clients-view-all-row');
202
+ expect(row).toBeVisible();
203
+ expect(row).toHaveTextContent('0');
204
+ });
205
+ });
206
+
207
+ it("should navigate to the user's own clients from the view-all row", async () => {
208
+ const { getByTestId } = createComponent({ pinia });
209
+ await nextTick();
210
+
211
+ await userEvent.click(getByTestId('mcp-clients-view-all-row'));
212
+
213
+ expect(routerPush).toHaveBeenCalledWith({
214
+ name: MCP_CLIENTS_VIEW,
215
+ query: { tab: 'mine' },
216
+ });
217
+ });
218
+
219
+ it("should open everyone's clients when a manager has none of their own", async () => {
220
+ mcpStore.oauthClientTotals = { mine: 0, all: 4 };
221
+
222
+ const { getByTestId } = createComponent({ pinia });
223
+ await waitFor(() => {
224
+ expect(getByTestId('mcp-clients-view-all-row')).toHaveTextContent('4 clients have access');
225
+ });
226
+
227
+ await userEvent.click(getByTestId('mcp-clients-view-all-row'));
228
+
229
+ expect(routerPush).toHaveBeenCalledWith({
230
+ name: MCP_CLIENTS_VIEW,
231
+ query: { tab: 'all' },
232
+ });
233
+ });
234
+
235
+ it('should show the exposed workflows count on the access row', async () => {
236
+ mcpStore.fetchWorkflowsAvailableForMCP.mockResolvedValue(workflowPage([], 3));
237
+
238
+ const { getByTestId } = createComponent({ pinia });
239
+
240
+ await waitFor(() => {
241
+ expect(getByTestId('mcp-workflows-exposed-row').textContent).toContain('3 workflows');
242
+ });
243
+ expect(mcpStore.fetchWorkflowsAvailableForMCP).toHaveBeenCalledWith(1, 1);
244
+ });
245
+
246
+ it('should navigate to the workflows sub-view when the row is clicked', async () => {
247
+ const { getByTestId } = createComponent({ pinia });
248
+ await nextTick();
249
+
250
+ await userEvent.click(getByTestId('mcp-workflows-exposed-row'));
251
+
252
+ expect(routerPush).toHaveBeenCalledWith({ name: MCP_WORKFLOWS_VIEW });
253
+ });
254
+
255
+ it('should open the connect dialog from the Your client row', async () => {
256
+ const { getByTestId } = createComponent({ pinia });
257
+ await nextTick();
258
+
259
+ await userEvent.click(getByTestId('mcp-connect-client-button'));
260
+
261
+ expect(mcpStore.openConnectPopover).toHaveBeenCalled();
262
+ expect(trackConnectClientClickedSpy).toHaveBeenCalledWith('settings');
263
+ });
264
+ });
265
+
266
+ describe('Connected clients loading state', () => {
267
+ beforeEach(() => {
268
+ enableMcpSettings();
269
+ });
270
+
271
+ it('should show placeholder instead of 0 while the clients preview is pending', async () => {
272
+ // Create a promise we control so we can keep it pending
273
+ let resolveClients!: (value: OAuthClientResponseDto[]) => void;
274
+ const clientsPromise = new Promise<OAuthClientResponseDto[]>((res) => {
275
+ resolveClients = res;
276
+ });
277
+ mcpStore.fetchOAuthClientsPreview.mockReturnValue(clientsPromise);
278
+
279
+ const { getAllByText, queryByText, queryAllByText } = createComponent({ pinia });
280
+ await nextTick();
281
+
282
+ expect(getAllByText(UNKNOWN_COUNT_VALUE).length).toBeGreaterThan(0);
283
+ expect(queryByText('0 clients have access')).not.toBeInTheDocument();
284
+
285
+ resolveClients([]);
286
+ await waitFor(() => {
287
+ expect(queryAllByText(UNKNOWN_COUNT_VALUE)).toHaveLength(0);
288
+ });
289
+ });
290
+
291
+ it('should show 0 after the clients preview resolves with zero clients', async () => {
292
+ mcpStore.oauthClientTotals = { mine: 0 };
293
+ mcpStore.fetchOAuthClientsPreview.mockResolvedValue([]);
294
+
295
+ const { getByTestId } = createComponent({ pinia });
296
+
297
+ await waitFor(() => {
298
+ const row = getByTestId('mcp-clients-view-all-row');
299
+ expect(row).not.toHaveTextContent(UNKNOWN_COUNT_VALUE);
300
+ expect(row).toHaveTextContent('0');
301
+ });
302
+ });
303
+
304
+ it('should keep — and not silently show 0 when the clients preview fails', async () => {
305
+ mcpStore.fetchOAuthClientsPreview.mockRejectedValue(new Error('network error'));
306
+
307
+ const { getAllByText, queryByText } = createComponent({ pinia });
308
+
309
+ await waitFor(() => {
310
+ expect(mcpStore.fetchOAuthClientsPreview).toHaveBeenCalled();
311
+ });
312
+
313
+ expect(getAllByText(UNKNOWN_COUNT_VALUE).length).toBeGreaterThan(0);
314
+ expect(queryByText('0 clients have access')).not.toBeInTheDocument();
315
+ });
316
+ });
317
+
318
+ describe('Connected clients preview', () => {
319
+ const ownClients = [
320
+ createOAuthClient({ id: 'client-1', name: 'Cursor' }),
321
+ createOAuthClient({ id: 'client-2', name: 'Claude Code' }),
322
+ ];
323
+
324
+ beforeEach(() => {
325
+ enableMcpSettings();
326
+ mcpStore.oauthClientsPreview = ownClients;
327
+ mcpStore.oauthClientTotals = { mine: 2 };
328
+ mcpStore.fetchOAuthClientsPreview.mockResolvedValue(ownClients);
329
+ });
330
+
331
+ it("should preview the user's own clients and drop the view-all row when it shows them all", async () => {
332
+ const { getAllByTestId, queryByTestId } = createComponent({ pinia });
333
+ await waitAllPromises();
334
+
335
+ const rows = getAllByTestId('mcp-client-preview-row');
336
+ expect(rows).toHaveLength(2);
337
+ expect(rows[0]).toHaveTextContent('Cursor');
338
+ expect(rows[0]).toHaveTextContent('IDE');
339
+ expect(rows[1]).toHaveTextContent('Claude Code');
340
+ expect(rows[1]).toHaveTextContent('CLI');
341
+ expect(queryByTestId('mcp-clients-view-all-row')).not.toBeInTheDocument();
342
+ });
343
+
344
+ it('should keep the view-all row when the user has more clients than the preview shows', async () => {
345
+ mcpStore.oauthClientTotals = { mine: 5 };
346
+
347
+ const { getAllByTestId, getByTestId } = createComponent({ pinia });
348
+ await waitAllPromises();
349
+
350
+ expect(getAllByTestId('mcp-client-preview-row')).toHaveLength(2);
351
+ expect(getByTestId('mcp-clients-view-all-row')).toHaveTextContent('5 clients have access');
352
+ });
353
+
354
+ it("should keep the view-all row for a manager when other users' clients exist", async () => {
355
+ mcpStore.oauthClientTotals = { mine: 2, all: 6 };
356
+
357
+ const { getAllByTestId, getByTestId } = createComponent({ pinia });
358
+ await waitAllPromises();
359
+
360
+ // Only the user's own clients are previewed; the others stay behind the clients page.
361
+ expect(getAllByTestId('mcp-client-preview-row')).toHaveLength(2);
362
+ expect(getByTestId('mcp-clients-view-all-row')).toHaveTextContent('6 clients have access');
363
+ });
364
+
365
+ it('should open the client details when a preview row is clicked', async () => {
366
+ const { getAllByTestId } = createComponent({ pinia });
367
+ await waitAllPromises();
368
+
369
+ await userEvent.click(getAllByTestId('mcp-client-preview-row')[0]);
370
+
371
+ await waitFor(() => {
372
+ expect(within(document.body).getByTestId('mcp-client-details-modal')).toBeVisible();
373
+ });
374
+ expect(within(document.body).getByTestId('mcp-client-details-modal')).toHaveTextContent(
375
+ 'Cursor',
376
+ );
377
+ });
378
+
379
+ it('should confirm before revoking from a preview row, then refresh the preview', async () => {
380
+ mcpStore.removeOAuthClient.mockResolvedValue({ success: true, message: '' });
381
+
382
+ const { getAllByTestId } = createComponent({ pinia });
383
+ await waitAllPromises();
384
+ mcpStore.fetchOAuthClientsPreview.mockClear();
385
+
386
+ await userEvent.click(getAllByTestId('mcp-client-preview-revoke-button')[1]);
387
+
388
+ // nothing is revoked until the dialog is confirmed
389
+ await waitFor(() => {
390
+ expect(
391
+ within(document.body).getByText('Revoke access for "Claude Code"?'),
392
+ ).toBeInTheDocument();
393
+ });
394
+ expect(mcpStore.removeOAuthClient).not.toHaveBeenCalled();
395
+
396
+ await userEvent.click(within(document.body).getByRole('button', { name: 'Revoke' }));
397
+
398
+ await waitFor(() => {
399
+ // No list refetch from the overview: it never renders the clients page's
400
+ // list, whose persisted ownership might be "all".
401
+ expect(mcpStore.removeOAuthClient).toHaveBeenCalledWith('client-2', undefined, {
402
+ refreshList: false,
403
+ });
404
+ });
405
+ expect(trackClientAccessRevokedSpy).toHaveBeenCalledWith({
406
+ clientId: 'client-2',
407
+ clientName: 'Claude Code',
408
+ revokedForOther: false,
409
+ });
410
+ await waitFor(() => {
411
+ expect(mcpStore.fetchOAuthClientsPreview).toHaveBeenCalledTimes(1);
412
+ });
413
+ });
414
+
415
+ it('should drop the cached preview when leaving the page', async () => {
416
+ const { unmount } = createComponent({ pinia });
417
+ await waitAllPromises();
418
+ expect(mcpStore.clearOAuthClientsPreview).not.toHaveBeenCalled();
419
+
420
+ unmount();
421
+
422
+ expect(mcpStore.clearOAuthClientsPreview).toHaveBeenCalled();
423
+ });
424
+
425
+ it('should not open the client details when the revoke action is clicked', async () => {
426
+ const { getAllByTestId } = createComponent({ pinia });
427
+ await waitAllPromises();
428
+
429
+ await userEvent.click(getAllByTestId('mcp-client-preview-revoke-button')[0]);
430
+
431
+ await waitFor(() => {
432
+ expect(within(document.body).getByText('Revoke access for "Cursor"?')).toBeInTheDocument();
433
+ });
434
+ expect(within(document.body).queryByTestId('mcp-client-details-modal')).toBeNull();
435
+ });
436
+ });
437
+
438
+ describe('Workflows and agents loading state', () => {
439
+ beforeEach(() => {
440
+ enableMcpSettings();
441
+ settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
442
+ settingsStore.isAgentsEnabled = true;
443
+ mcpStore.fetchAgentsAvailableForMCP.mockResolvedValue({ data: [], count: 0 });
444
+ });
445
+
446
+ it('should show placeholder while fetchWorkflowsAvailableForMCP is pending', async () => {
447
+ let resolveWorkflows!: (value: { data: McpWorkflow[]; count: number }) => void;
448
+ const workflowsPromise = new Promise<{ data: McpWorkflow[]; count: number }>((res) => {
449
+ resolveWorkflows = res;
450
+ });
451
+ mcpStore.fetchWorkflowsAvailableForMCP.mockReturnValue(workflowsPromise);
452
+
453
+ const { getByTestId } = createComponent({ pinia });
454
+ await nextTick();
455
+
456
+ expect(getByTestId('mcp-workflows-exposed-row')).toHaveTextContent(UNKNOWN_COUNT_VALUE);
457
+
458
+ resolveWorkflows({ data: [], count: 3 });
459
+ await waitFor(() => {
460
+ expect(getByTestId('mcp-workflows-exposed-row')).toHaveTextContent('3 workflows');
461
+ });
462
+ });
463
+
464
+ it('should show 0 after fetchWorkflowsAvailableForMCP resolves with zero', async () => {
465
+ mcpStore.fetchWorkflowsAvailableForMCP.mockResolvedValue({ data: [], count: 0 });
466
+
467
+ const { getByTestId } = createComponent({ pinia });
468
+
469
+ await waitFor(() => {
470
+ expect(getByTestId('mcp-workflows-exposed-row')).not.toHaveTextContent(UNKNOWN_COUNT_VALUE);
471
+ expect(getByTestId('mcp-workflows-exposed-row')).toHaveTextContent('0');
472
+ });
473
+ });
474
+
475
+ it('should keep — when fetchWorkflowsAvailableForMCP fails', async () => {
476
+ mcpStore.fetchWorkflowsAvailableForMCP.mockRejectedValue(new Error('network error'));
477
+
478
+ const { getByTestId } = createComponent({ pinia });
479
+
480
+ await waitFor(() => {
481
+ expect(mcpStore.fetchWorkflowsAvailableForMCP).toHaveBeenCalled();
482
+ });
483
+
484
+ expect(getByTestId('mcp-workflows-exposed-row')).toHaveTextContent(UNKNOWN_COUNT_VALUE);
485
+ });
486
+
487
+ it('should show placeholder while fetchAgentsAvailableForMCP is pending', async () => {
488
+ let resolveAgents!: (value: { data: McpAgent[]; count: number }) => void;
489
+ const agentsPromise = new Promise<{ data: McpAgent[]; count: number }>((res) => {
490
+ resolveAgents = res;
491
+ });
492
+
493
+ mcpStore.fetchAgentsAvailableForMCP.mockReturnValue(agentsPromise);
494
+
495
+ const { getByTestId } = createComponent({ pinia });
496
+ await nextTick();
497
+
498
+ expect(getByTestId('mcp-agents-exposed-row')).toHaveTextContent(UNKNOWN_COUNT_VALUE);
499
+
500
+ resolveAgents({ data: [], count: 2 });
501
+ await waitFor(() => {
502
+ expect(getByTestId('mcp-agents-exposed-row')).toHaveTextContent('2');
503
+ });
504
+ });
505
+
506
+ it('should keep — when fetchAgentsAvailableForMCP fails', async () => {
507
+ mcpStore.fetchAgentsAvailableForMCP.mockRejectedValue(new Error('network error'));
508
+
509
+ const { getByTestId } = createComponent({ pinia });
510
+ await waitFor(() => {
511
+ expect(mcpStore.fetchAgentsAvailableForMCP).toHaveBeenCalled();
512
+ });
513
+
514
+ expect(getByTestId('mcp-agents-exposed-row')).toHaveTextContent(UNKNOWN_COUNT_VALUE);
515
+ });
516
+ });
517
+
518
+ describe('Allowed callback URLs', () => {
519
+ beforeEach(() => {
520
+ enableMcpSettings();
521
+ });
522
+
523
+ it('should show the callback URLs row for admins only', async () => {
524
+ rbacStore.hasScope.mockReturnValue(true);
525
+ const admin = createComponent({ pinia });
526
+ await nextTick();
527
+ expect(admin.getByTestId('mcp-callback-urls-row')).toBeVisible();
528
+ admin.unmount();
529
+
530
+ rbacStore.hasScope.mockReturnValue(false);
531
+ const member = createComponent({ pinia });
532
+ await nextTick();
533
+ expect(member.queryByTestId('mcp-callback-urls-row')).not.toBeInTheDocument();
534
+ });
535
+
536
+ it('should show "All" when no URLs are configured and the count otherwise', async () => {
537
+ rbacStore.hasScope.mockReturnValue(true);
538
+ mcpStore.allowedRedirectUris = [];
539
+ const all = createComponent({ pinia });
540
+ await nextTick();
541
+ expect(all.getByTestId('mcp-callback-urls-row').textContent).toContain('All');
542
+ all.unmount();
543
+
544
+ mcpStore.allowedRedirectUris = ['https://a.example.com/cb', 'https://b.example.com/cb'];
545
+ const counted = createComponent({ pinia });
546
+ await nextTick();
547
+ expect(counted.getByTestId('mcp-callback-urls-row').textContent).toContain('2 URLs');
548
+ });
549
+
550
+ it('should open the dialog from the row and persist on save', async () => {
551
+ rbacStore.hasScope.mockReturnValue(true);
552
+ mcpStore.setAllowedRedirectUris.mockResolvedValue(undefined);
553
+
554
+ const { getByTestId, queryByTestId } = createComponent({ pinia });
555
+ await nextTick();
556
+
557
+ expect(queryByTestId('mcp-callback-urls-dialog-stub')).not.toBeInTheDocument();
558
+ await userEvent.click(getByTestId('mcp-callback-urls-row'));
559
+ expect(getByTestId('mcp-callback-urls-dialog-stub')).toBeVisible();
560
+
561
+ await userEvent.click(getByTestId('stub-save-urls'));
562
+
563
+ await waitFor(() => {
564
+ expect(mcpStore.setAllowedRedirectUris).toHaveBeenCalledWith([
565
+ 'https://client.example.com/cb',
566
+ ]);
567
+ });
568
+ });
569
+
570
+ it('should load the redirect URIs on mount for admins', async () => {
571
+ rbacStore.hasScope.mockReturnValue(true);
572
+ createComponent({ pinia });
573
+ await nextTick();
574
+
575
+ expect(mcpStore.fetchAllowedRedirectUris).toHaveBeenCalled();
576
+ });
577
+ });
578
+
579
+ describe('Toggle MCP on/off', () => {
580
+ beforeEach(() => {
581
+ rbacStore.hasScope.mockReturnValue(true);
582
+ });
583
+
584
+ it('should call setMcpAccessEnabled when turning on MCP', async () => {
585
+ mcpStore.setMcpAccessEnabled.mockResolvedValue(true);
586
+
587
+ const { getByTestId } = createComponent({ pinia });
588
+ await nextTick();
589
+
590
+ await userEvent.click(getByTestId('enable-mcp-button'));
591
+
592
+ expect(mcpStore.setMcpAccessEnabled).toHaveBeenCalledWith(true);
593
+ });
594
+
595
+ it('should fetch the workflow count and oauth clients after enabling MCP', async () => {
596
+ mcpStore.setMcpAccessEnabled.mockResolvedValue(true);
597
+
598
+ const { getByTestId } = createComponent({ pinia });
599
+ await nextTick();
600
+
601
+ await userEvent.click(getByTestId('enable-mcp-button'));
602
+
603
+ expect(mcpStore.fetchWorkflowsAvailableForMCP).toHaveBeenCalledWith(1, 1);
604
+ expect(mcpStore.fetchOAuthClientsPreview).toHaveBeenCalled();
605
+ });
606
+
607
+ it('should only disable after the confirmation dialog is confirmed', async () => {
608
+ enableMcpSettings();
609
+ mcpStore.setMcpAccessEnabled.mockResolvedValue(false);
610
+
611
+ const { getByTestId } = createComponent({ pinia });
612
+ await nextTick();
613
+
614
+ await userEvent.click(getByTestId('disable-mcp-button'));
615
+
616
+ // nothing happens until the dialog is confirmed
617
+ await waitFor(() => {
618
+ expect(within(document.body).getByText('Disable MCP access?')).toBeInTheDocument();
619
+ });
620
+ expect(mcpStore.setMcpAccessEnabled).not.toHaveBeenCalled();
621
+
622
+ await userEvent.click(
623
+ within(document.body).getByRole('button', { name: 'Disable MCP access' }),
624
+ );
625
+
626
+ await waitFor(() => {
627
+ expect(mcpStore.setMcpAccessEnabled).toHaveBeenCalledWith(false);
628
+ });
629
+ });
630
+
631
+ it('should not disable when the confirmation dialog is cancelled', async () => {
632
+ enableMcpSettings();
633
+
634
+ const { getByTestId } = createComponent({ pinia });
635
+ await nextTick();
636
+
637
+ await userEvent.click(getByTestId('disable-mcp-button'));
638
+ await waitFor(() => {
639
+ expect(within(document.body).getByText('Disable MCP access?')).toBeInTheDocument();
640
+ });
641
+
642
+ await userEvent.click(within(document.body).getByRole('button', { name: 'Cancel' }));
643
+
644
+ expect(mcpStore.setMcpAccessEnabled).not.toHaveBeenCalled();
645
+ });
646
+
647
+ it('should disable the enable button for non-owner/non-admin users', async () => {
648
+ rbacStore.hasScope.mockReturnValue(false);
649
+
650
+ const { getByTestId } = createComponent({ pinia });
651
+ await nextTick();
652
+
653
+ expect(getByTestId('enable-mcp-button')).toBeDisabled();
654
+ });
655
+
656
+ it('should disable the enable button when MCP is managed by env', async () => {
657
+ rbacStore.hasScope.mockReturnValue(true);
658
+ settingsStore.moduleSettings = {
659
+ mcp: {
660
+ mcpAccessEnabled: false,
661
+ mcpManagedByEnv: true,
662
+ autoExposeNewWorkflows: false,
663
+ },
664
+ };
665
+
666
+ const { getByTestId } = createComponent({ pinia });
667
+ await nextTick();
668
+
669
+ expect(getByTestId('enable-mcp-button')).toBeDisabled();
670
+ });
671
+ });
672
+
673
+ describe('Expose all workflows experiment', () => {
674
+ beforeEach(() => {
675
+ rbacStore.hasScope.mockReturnValue(true);
676
+ mcpStore.setMcpAccessEnabled.mockResolvedValue(true);
677
+ });
678
+
679
+ it('should offer to expose all workflows after enabling MCP', async () => {
680
+ const { getByTestId } = createComponent({ pinia });
681
+ await nextTick();
682
+
683
+ await userEvent.click(getByTestId('enable-mcp-button'));
684
+
685
+ await waitFor(() => {
686
+ expect(exposeAllOffer.offer).toHaveBeenCalledWith(expect.any(Function));
687
+ });
688
+ // The connect dialog must not stack on top of the expose-all modal
689
+ expect(mcpStore.openConnectPopover).not.toHaveBeenCalled();
690
+ });
691
+
692
+ it('should enable MCP without an offer when the shell provides none', async () => {
693
+ capabilityRegistry.clear();
694
+
695
+ const { getByTestId } = createComponent({ pinia });
696
+ await nextTick();
697
+
698
+ await userEvent.click(getByTestId('enable-mcp-button'));
699
+
700
+ await waitFor(() => {
701
+ expect(mcpStore.setMcpAccessEnabled).toHaveBeenCalledWith(true);
702
+ });
703
+ expect(exposeAllOffer.offer).not.toHaveBeenCalled();
704
+ // Enabling MCP no longer auto-opens the connect dialog.
705
+ expect(mcpStore.openConnectPopover).not.toHaveBeenCalled();
706
+ });
707
+ });
708
+
709
+ describe('Instance capacity notice', () => {
710
+ beforeEach(() => {
711
+ enableMcpSettings();
712
+ mcpStore.getInstanceClientStats.mockResolvedValue(null);
713
+ });
714
+
715
+ it('should render the notice for an instance owner when atCapacity is true', async () => {
716
+ rbacStore.hasScope.mockReturnValue(true);
717
+ mcpStore.instanceClientStats = { count: 2, limit: 2, atCapacity: true };
718
+
719
+ const { findByTestId } = createComponent({ pinia });
720
+
721
+ const notice = await findByTestId('mcp-instance-capacity-notice');
722
+ expect(notice).toBeVisible();
723
+ expect(notice.textContent).toContain('2/2');
724
+ });
725
+
726
+ it('should NOT render the notice for a non-admin member', async () => {
727
+ rbacStore.hasScope.mockReturnValue(false);
728
+ mcpStore.instanceClientStats = { count: 2, limit: 2, atCapacity: true };
729
+
730
+ const { queryByTestId } = createComponent({ pinia });
731
+ await nextTick();
732
+
733
+ expect(queryByTestId('mcp-instance-capacity-notice')).not.toBeInTheDocument();
734
+ });
735
+
736
+ it('should NOT render the notice when atCapacity is false', async () => {
737
+ rbacStore.hasScope.mockReturnValue(true);
738
+ mcpStore.instanceClientStats = { count: 1, limit: 5, atCapacity: false };
739
+
740
+ const { queryByTestId } = createComponent({ pinia });
741
+ await nextTick();
742
+
743
+ expect(queryByTestId('mcp-instance-capacity-notice')).not.toBeInTheDocument();
744
+ });
745
+
746
+ it('should fetch instance stats on mount for an admin/owner', async () => {
747
+ rbacStore.hasScope.mockReturnValue(true);
748
+
749
+ createComponent({ pinia });
750
+ await nextTick();
751
+
752
+ expect(mcpStore.getInstanceClientStats).toHaveBeenCalled();
753
+ });
754
+
755
+ it('should not fetch instance stats on mount for a regular member', async () => {
756
+ rbacStore.hasScope.mockReturnValue(false);
757
+
758
+ createComponent({ pinia });
759
+ await nextTick();
760
+
761
+ expect(mcpStore.getInstanceClientStats).not.toHaveBeenCalled();
762
+ });
763
+ });
764
+
765
+ describe('auto-expose toggle', () => {
766
+ beforeEach(() => {
767
+ enableMcpSettings();
768
+ });
769
+
770
+ it('renders for a user with mcp:manage when the experiment is on', async () => {
771
+ rbacStore.hasScope.mockReturnValue(true);
772
+ exposeAllOffer.isEnabled.mockReturnValue(true);
773
+
774
+ const { getByTestId } = createComponent({ pinia });
775
+ await waitAllPromises();
776
+
777
+ expect(getByTestId('mcp-auto-expose-toggle')).toBeTruthy();
778
+ });
779
+
780
+ it('is hidden without the experiment flag', async () => {
781
+ rbacStore.hasScope.mockReturnValue(true);
782
+ exposeAllOffer.isEnabled.mockReturnValue(false);
783
+
784
+ const { queryByTestId } = createComponent({ pinia });
785
+ await waitAllPromises();
786
+
787
+ expect(queryByTestId('mcp-auto-expose-toggle')).toBeNull();
788
+ });
789
+
790
+ it('is hidden for a user without mcp:manage', async () => {
791
+ rbacStore.hasScope.mockReturnValue(false);
792
+ exposeAllOffer.isEnabled.mockReturnValue(true);
793
+
794
+ const { queryByTestId } = createComponent({ pinia });
795
+ await waitAllPromises();
796
+
797
+ expect(queryByTestId('mcp-auto-expose-toggle')).toBeNull();
798
+ });
799
+
800
+ it('persists the new state and tracks the resulting value', async () => {
801
+ rbacStore.hasScope.mockReturnValue(true);
802
+ exposeAllOffer.isEnabled.mockReturnValue(true);
803
+ mcpStore.setAutoExposeNewWorkflows.mockResolvedValue(true);
804
+
805
+ const { getByTestId } = createComponent({ pinia });
806
+ await waitAllPromises();
807
+
808
+ await userEvent.click(getByTestId('mcp-auto-expose-toggle').querySelector('input')!);
809
+
810
+ expect(mcpStore.setAutoExposeNewWorkflows).toHaveBeenCalledWith(true);
811
+ expect(trackAutoExposeToggledSpy).toHaveBeenCalledWith({ enabled: true, source: 'settings' });
812
+ });
813
+
814
+ it('shows a toast error and does not track when persisting fails', async () => {
815
+ rbacStore.hasScope.mockReturnValue(true);
816
+ exposeAllOffer.isEnabled.mockReturnValue(true);
817
+ mcpStore.setAutoExposeNewWorkflows.mockRejectedValueOnce(new Error('nope'));
818
+
819
+ const { getByTestId } = createComponent({ pinia });
820
+ await waitAllPromises();
821
+
822
+ await userEvent.click(getByTestId('mcp-auto-expose-toggle').querySelector('input')!);
823
+ await waitAllPromises();
824
+
825
+ expect(trackAutoExposeToggledSpy).not.toHaveBeenCalled();
826
+ expect(useToast().showError).toHaveBeenCalledWith(
827
+ expect.anything(),
828
+ 'Could not update setting',
829
+ );
830
+ });
831
+ });
832
+ });