@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.
- package/LICENSE.md +88 -0
- package/README.md +51 -0
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +72 -0
- package/src/SettingsMCPAgentsView.test.ts +256 -0
- package/src/SettingsMCPAgentsView.vue +223 -0
- package/src/SettingsMCPClientsView.test.ts +280 -0
- package/src/SettingsMCPClientsView.vue +168 -0
- package/src/SettingsMCPView.test.ts +832 -0
- package/src/SettingsMCPView.vue +496 -0
- package/src/SettingsMCPWorkflowsView.test.ts +274 -0
- package/src/SettingsMCPWorkflowsView.vue +245 -0
- package/src/__tests__/design-system-icons.test.ts +24 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/assets/client-icons/claude.svg +1 -0
- package/src/assets/client-icons/codex.svg +1 -0
- package/src/assets/client-icons/cursor.svg +1 -0
- package/src/assets/client-icons/gemini.svg +1 -0
- package/src/assets/client-icons/mistral.svg +1 -0
- package/src/assets/client-icons/openai.svg +3 -0
- package/src/assets/client-icons/vscode.svg +1 -0
- package/src/clients.utils.test.ts +85 -0
- package/src/clients.utils.ts +124 -0
- package/src/components/ConnectionParameter.vue +213 -0
- package/src/components/MCPAgentsSelect.vue +176 -0
- package/src/components/MCPEmptyState.vue +75 -0
- package/src/components/MCPWorkflowsSelect.test.ts +384 -0
- package/src/components/MCPWorkflowsSelect.vue +159 -0
- package/src/components/McpAccessToggle.vue +77 -0
- package/src/components/McpAccessTokenTab.test.ts +220 -0
- package/src/components/McpAccessTokenTab.vue +201 -0
- package/src/components/McpAllowedCallbackUrlsDialog.test.ts +122 -0
- package/src/components/McpAllowedCallbackUrlsDialog.vue +359 -0
- package/src/components/McpClientLogoCards.vue +134 -0
- package/src/components/McpConfigSnippet.vue +92 -0
- package/src/components/McpConnectClientDialog.test.ts +115 -0
- package/src/components/McpConnectClientDialog.vue +432 -0
- package/src/components/McpConnectedClientRow.test.ts +86 -0
- package/src/components/McpConnectedClientRow.vue +152 -0
- package/src/components/McpEmptyStateCard.vue +83 -0
- package/src/components/McpStatusControl.test.ts +33 -0
- package/src/components/McpStatusControl.vue +96 -0
- package/src/components/OAuthClientDetailsModal.vue +182 -0
- package/src/components/RevokeOAuthClientConfirmModal.vue +60 -0
- package/src/components/WorkflowLocation.test.ts +329 -0
- package/src/components/WorkflowLocation.vue +198 -0
- package/src/components/tabs/AgentsTable.vue +312 -0
- package/src/components/tabs/OAuthClientOwnerCell.vue +71 -0
- package/src/components/tabs/OAuthClientsFilters.test.ts +86 -0
- package/src/components/tabs/OAuthClientsFilters.vue +201 -0
- package/src/components/tabs/OAuthClientsTable.test.ts +394 -0
- package/src/components/tabs/OAuthClientsTable.vue +473 -0
- package/src/components/tabs/WorkflowsTable.test.ts +661 -0
- package/src/components/tabs/WorkflowsTable.vue +398 -0
- package/src/composables/useMcp.test.ts +59 -0
- package/src/composables/useMcp.ts +61 -0
- package/src/composables/useOAuthClientRevoke.ts +90 -0
- package/src/index.ts +10 -0
- package/src/mcp.api.test.ts +81 -0
- package/src/mcp.api.ts +206 -0
- package/src/mcp.clients.catalog.test.ts +63 -0
- package/src/mcp.clients.catalog.ts +183 -0
- package/src/mcp.constants.ts +45 -0
- package/src/mcp.module.test.ts +106 -0
- package/src/mcp.module.ts +90 -0
- package/src/mcp.store.test.ts +628 -0
- package/src/mcp.store.ts +490 -0
- package/src/mcp.test.utils.ts +57 -0
- package/src/mcp.types.ts +20 -0
- package/src/modals/MCPConnectAgentsModal.test.ts +141 -0
- package/src/modals/MCPConnectAgentsModal.vue +122 -0
- package/src/modals/MCPConnectWorkflowsModal.test.ts +303 -0
- package/src/modals/MCPConnectWorkflowsModal.vue +101 -0
- package/src/redirect-uris.utils.ts +22 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +52 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { setActivePinia, createPinia } from 'pinia';
|
|
3
|
+
import { capabilities, capabilityRegistry } from '@n8n/frontend-module-sdk';
|
|
4
|
+
|
|
5
|
+
import * as mcpApi from './mcp.api';
|
|
6
|
+
import { useMCPStore } from './mcp.store';
|
|
7
|
+
import { useSettingsStore } from '@n8n/stores/settings.store';
|
|
8
|
+
import { createOAuthClient, createWorkflow } from './mcp.test.utils';
|
|
9
|
+
|
|
10
|
+
vi.mock('@n8n/stores/useRootStore', () => ({
|
|
11
|
+
useRootStore: () => ({ restApiContext: {} }),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// There is no shell in a module test, so stub the capability the store calls.
|
|
15
|
+
const syncWorkflowMcpAccess = vi.fn<(workflowIds: string[], availableInMCP: boolean) => void>();
|
|
16
|
+
|
|
17
|
+
describe('mcp.store', () => {
|
|
18
|
+
let store: ReturnType<typeof useMCPStore>;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
setActivePinia(createPinia());
|
|
23
|
+
store = useMCPStore();
|
|
24
|
+
capabilityRegistry.provide(capabilities.workflowMcpAccessSync, syncWorkflowMcpAccess);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
capabilityRegistry.clear();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('fetchWorkflowsAvailableForMCP', () => {
|
|
32
|
+
it('returns paginated workflows and total count', async () => {
|
|
33
|
+
const workflow = createWorkflow({ id: 'wf-1' });
|
|
34
|
+
const fetchSpy = vi.spyOn(mcpApi, 'fetchMcpExposedWorkflows').mockResolvedValue({
|
|
35
|
+
data: [workflow],
|
|
36
|
+
count: 11,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await expect(store.fetchWorkflowsAvailableForMCP(2, 25)).resolves.toEqual({
|
|
40
|
+
data: [workflow],
|
|
41
|
+
count: 11,
|
|
42
|
+
});
|
|
43
|
+
expect(fetchSpy).toHaveBeenCalledWith({}, { skip: 25, take: 25 });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('fetchAgentsAvailableForMCP', () => {
|
|
48
|
+
it('fetches exposed agents through the permission-aware MCP endpoint', async () => {
|
|
49
|
+
const fetchSpy = vi.spyOn(mcpApi, 'fetchMcpAgents').mockResolvedValue({
|
|
50
|
+
data: [],
|
|
51
|
+
count: 11,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await expect(store.fetchAgentsAvailableForMCP(2, 25)).resolves.toEqual({
|
|
55
|
+
data: [],
|
|
56
|
+
count: 11,
|
|
57
|
+
});
|
|
58
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
59
|
+
{},
|
|
60
|
+
{
|
|
61
|
+
skip: 25,
|
|
62
|
+
take: 25,
|
|
63
|
+
availableInMCP: true,
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps the eligible-agent query on the default availability filter', async () => {
|
|
69
|
+
const fetchSpy = vi.spyOn(mcpApi, 'fetchMcpAgents').mockResolvedValue({
|
|
70
|
+
data: [],
|
|
71
|
+
count: 0,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await store.getMcpEligibleAgents({ take: 10, query: 'sales' });
|
|
75
|
+
|
|
76
|
+
expect(fetchSpy).toHaveBeenCalledWith({}, { take: 10, query: 'sales' });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('toggleWorkflowMcpAccess', () => {
|
|
81
|
+
it('syncs the confirmed workflow into the shell stores', async () => {
|
|
82
|
+
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
|
83
|
+
updatedCount: 1,
|
|
84
|
+
updatedIds: ['wf-1'],
|
|
85
|
+
unchangedCount: 0,
|
|
86
|
+
unchangedIds: [],
|
|
87
|
+
skippedCount: 0,
|
|
88
|
+
failedCount: 0,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await store.toggleWorkflowMcpAccess('wf-1', true);
|
|
92
|
+
|
|
93
|
+
expect(syncWorkflowMcpAccess).toHaveBeenCalledWith(['wf-1'], true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('throws when the backend silently skipped the workflow', async () => {
|
|
97
|
+
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
|
98
|
+
updatedCount: 0,
|
|
99
|
+
updatedIds: [],
|
|
100
|
+
unchangedCount: 0,
|
|
101
|
+
unchangedIds: [],
|
|
102
|
+
skippedCount: 1,
|
|
103
|
+
failedCount: 0,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await expect(store.toggleWorkflowMcpAccess('wf-1', true)).rejects.toThrow(
|
|
107
|
+
/could not be updated/i,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Local state must remain untouched when the backend rejected the change.
|
|
111
|
+
expect(syncWorkflowMcpAccess).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('propagates network errors without patching local state', async () => {
|
|
115
|
+
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockRejectedValue(new Error('network'));
|
|
116
|
+
|
|
117
|
+
await expect(store.toggleWorkflowMcpAccess('wf-1', true)).rejects.toThrow('network');
|
|
118
|
+
|
|
119
|
+
expect(syncWorkflowMcpAccess).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('fetchOAuthClientsPreview', () => {
|
|
124
|
+
it("fetches the user's own first clients regardless of the list's ownership and filters", async () => {
|
|
125
|
+
const client = createOAuthClient();
|
|
126
|
+
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
|
127
|
+
data: [client],
|
|
128
|
+
count: 1,
|
|
129
|
+
totals: { mine: 1, all: 3 },
|
|
130
|
+
scopeTools: { 'workflow:read': ['list_workflows'] },
|
|
131
|
+
});
|
|
132
|
+
store.oauthClientsOwnership = 'all';
|
|
133
|
+
store.oauthClientsPage = 2;
|
|
134
|
+
store.oauthClientsFilters = {
|
|
135
|
+
search: 'claude',
|
|
136
|
+
type: 'cli',
|
|
137
|
+
ownerId: 'user-1',
|
|
138
|
+
connected: 'last7',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
await store.fetchOAuthClientsPreview();
|
|
142
|
+
|
|
143
|
+
expect(fetchSpy).toHaveBeenCalledWith({}, { ownership: 'mine', skip: 0, take: 3 });
|
|
144
|
+
expect(store.oauthClientsPreview).toEqual([client]);
|
|
145
|
+
expect(store.oauthClientTotals).toEqual({ mine: 1, all: 3 });
|
|
146
|
+
expect(store.oauthClientScopeTools).toEqual({ 'workflow:read': ['list_workflows'] });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('discards a stale preview response superseded by a newer one', async () => {
|
|
150
|
+
let resolveStale!: (value: Awaited<ReturnType<typeof mcpApi.fetchOAuthClients>>) => void;
|
|
151
|
+
const stale = new Promise<Awaited<ReturnType<typeof mcpApi.fetchOAuthClients>>>((resolve) => {
|
|
152
|
+
resolveStale = resolve;
|
|
153
|
+
});
|
|
154
|
+
vi.spyOn(mcpApi, 'fetchOAuthClients')
|
|
155
|
+
.mockReturnValueOnce(stale)
|
|
156
|
+
.mockResolvedValueOnce({
|
|
157
|
+
data: [createOAuthClient({ id: 'new' })],
|
|
158
|
+
count: 1,
|
|
159
|
+
totals: { mine: 1 },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const staleCall = store.fetchOAuthClientsPreview(); // in flight
|
|
163
|
+
await store.fetchOAuthClientsPreview(); // newer request commits 'new'
|
|
164
|
+
resolveStale({
|
|
165
|
+
data: [createOAuthClient({ id: 'stale' })],
|
|
166
|
+
count: 1,
|
|
167
|
+
totals: { mine: 99 },
|
|
168
|
+
});
|
|
169
|
+
await staleCall;
|
|
170
|
+
|
|
171
|
+
expect(store.oauthClientsPreview.map((client) => client.id)).toEqual(['new']);
|
|
172
|
+
expect(store.oauthClientTotals).toEqual({ mine: 1 });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("does not let a slow preview overwrite the list's newer totals and scope tools", async () => {
|
|
176
|
+
let resolvePreview!: (value: Awaited<ReturnType<typeof mcpApi.fetchOAuthClients>>) => void;
|
|
177
|
+
const preview = new Promise<Awaited<ReturnType<typeof mcpApi.fetchOAuthClients>>>(
|
|
178
|
+
(resolve) => {
|
|
179
|
+
resolvePreview = resolve;
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
vi.spyOn(mcpApi, 'fetchOAuthClients')
|
|
183
|
+
.mockReturnValueOnce(preview)
|
|
184
|
+
.mockResolvedValueOnce({
|
|
185
|
+
data: [],
|
|
186
|
+
count: 0,
|
|
187
|
+
totals: { mine: 2, all: 5 },
|
|
188
|
+
scopeTools: { 'workflow:read': ['search_workflows'] },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const previewCall = store.fetchOAuthClientsPreview(); // in flight
|
|
192
|
+
await store.getAllOAuthClients(); // newer list fetch commits its metadata
|
|
193
|
+
resolvePreview({
|
|
194
|
+
data: [createOAuthClient()],
|
|
195
|
+
count: 1,
|
|
196
|
+
totals: { mine: 1, all: 4 },
|
|
197
|
+
scopeTools: { 'workflow:read': ['stale'] },
|
|
198
|
+
});
|
|
199
|
+
await previewCall;
|
|
200
|
+
|
|
201
|
+
// The preview rows are still applied (no newer preview exists)...
|
|
202
|
+
expect(store.oauthClientsPreview).toHaveLength(1);
|
|
203
|
+
// ...but the instance-wide metadata keeps the newer list response.
|
|
204
|
+
expect(store.oauthClientTotals).toEqual({ mine: 2, all: 5 });
|
|
205
|
+
expect(store.oauthClientScopeTools).toEqual({ 'workflow:read': ['search_workflows'] });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('clears the cached preview', async () => {
|
|
209
|
+
vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
|
210
|
+
data: [createOAuthClient()],
|
|
211
|
+
count: 1,
|
|
212
|
+
totals: { mine: 1 },
|
|
213
|
+
});
|
|
214
|
+
await store.fetchOAuthClientsPreview();
|
|
215
|
+
expect(store.oauthClientsPreview).toHaveLength(1);
|
|
216
|
+
|
|
217
|
+
store.clearOAuthClientsPreview();
|
|
218
|
+
|
|
219
|
+
expect(store.oauthClientsPreview).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('keeps a fetch that was in flight when the preview was cleared from repopulating it', async () => {
|
|
223
|
+
let resolveInFlight!: (value: Awaited<ReturnType<typeof mcpApi.fetchOAuthClients>>) => void;
|
|
224
|
+
vi.spyOn(mcpApi, 'fetchOAuthClients').mockReturnValue(
|
|
225
|
+
new Promise((resolve) => {
|
|
226
|
+
resolveInFlight = resolve;
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const inFlight = store.fetchOAuthClientsPreview();
|
|
231
|
+
store.clearOAuthClientsPreview(); // e.g. the overview unmounted
|
|
232
|
+
resolveInFlight({ data: [createOAuthClient()], count: 1, totals: { mine: 1 } });
|
|
233
|
+
await inFlight;
|
|
234
|
+
|
|
235
|
+
expect(store.oauthClientsPreview).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("leaves the clients page's list state untouched", async () => {
|
|
239
|
+
const listed = createOAuthClient({ id: 'listed' });
|
|
240
|
+
vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
|
241
|
+
data: [createOAuthClient({ id: 'previewed' })],
|
|
242
|
+
count: 1,
|
|
243
|
+
totals: { mine: 1 },
|
|
244
|
+
});
|
|
245
|
+
store.oauthClients = [listed];
|
|
246
|
+
store.oauthClientsOwnership = 'all';
|
|
247
|
+
store.oauthClientsPage = 2;
|
|
248
|
+
store.oauthClientsCount = 40;
|
|
249
|
+
|
|
250
|
+
await store.fetchOAuthClientsPreview();
|
|
251
|
+
|
|
252
|
+
expect(store.oauthClients).toEqual([listed]);
|
|
253
|
+
expect(store.oauthClientsOwnership).toBe('all');
|
|
254
|
+
expect(store.oauthClientsPage).toBe(2);
|
|
255
|
+
expect(store.oauthClientsCount).toBe(40);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('OAuth clients ownership', () => {
|
|
260
|
+
it('fetches the current page with ownership, pagination and filters', async () => {
|
|
261
|
+
const client = createOAuthClient();
|
|
262
|
+
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
|
263
|
+
data: [client],
|
|
264
|
+
count: 1,
|
|
265
|
+
totals: { mine: 1, all: 3 },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await store.getAllOAuthClients();
|
|
269
|
+
|
|
270
|
+
expect(fetchSpy).toHaveBeenCalledWith({}, { ownership: 'mine', skip: 0, take: 10 });
|
|
271
|
+
expect(store.oauthClients).toEqual([client]);
|
|
272
|
+
expect(store.oauthClientTotals).toEqual({ mine: 1, all: 3 });
|
|
273
|
+
expect(store.oauthClientsCount).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('switches ownership, resetting page and filters, and refetches', async () => {
|
|
277
|
+
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
|
278
|
+
data: [],
|
|
279
|
+
count: 0,
|
|
280
|
+
totals: { mine: 0, all: 0 },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
store.oauthClientsFilters = {
|
|
284
|
+
search: 'claude',
|
|
285
|
+
type: 'cli',
|
|
286
|
+
ownerId: 'user-1',
|
|
287
|
+
connected: 'last7',
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
await store.setOAuthClientsOwnership('all');
|
|
291
|
+
|
|
292
|
+
expect(store.oauthClientsOwnership).toBe('all');
|
|
293
|
+
expect(store.oauthClientsFilters).toEqual({
|
|
294
|
+
search: '',
|
|
295
|
+
type: null,
|
|
296
|
+
ownerId: null,
|
|
297
|
+
connected: null,
|
|
298
|
+
});
|
|
299
|
+
expect(fetchSpy).toHaveBeenCalledWith({}, { ownership: 'all', skip: 0, take: 10 });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('sends the active filters as query params and resets the page', async () => {
|
|
303
|
+
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
|
304
|
+
data: [],
|
|
305
|
+
count: 0,
|
|
306
|
+
totals: { mine: 0 },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
store.oauthClientsPage = 2;
|
|
310
|
+
await store.setOAuthClientsFilters({
|
|
311
|
+
search: ' claude ',
|
|
312
|
+
type: 'cli',
|
|
313
|
+
ownerId: 'user-1',
|
|
314
|
+
connected: 'last30',
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(store.oauthClientsPage).toBe(0);
|
|
318
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
319
|
+
{},
|
|
320
|
+
{
|
|
321
|
+
ownership: 'mine',
|
|
322
|
+
skip: 0,
|
|
323
|
+
take: 10,
|
|
324
|
+
name: 'claude',
|
|
325
|
+
ownerId: 'user-1',
|
|
326
|
+
type: 'cli',
|
|
327
|
+
connected: 'last30',
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('paginates server-side and restarts from the first page on a page-size change', async () => {
|
|
333
|
+
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
|
334
|
+
data: [createOAuthClient()],
|
|
335
|
+
count: 40,
|
|
336
|
+
totals: { mine: 40 },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await store.setOAuthClientsPagination(2, 10);
|
|
340
|
+
expect(fetchSpy).toHaveBeenLastCalledWith({}, { ownership: 'mine', skip: 20, take: 10 });
|
|
341
|
+
|
|
342
|
+
await store.setOAuthClientsPagination(2, 25);
|
|
343
|
+
expect(store.oauthClientsPage).toBe(0);
|
|
344
|
+
expect(fetchSpy).toHaveBeenLastCalledWith({}, { ownership: 'mine', skip: 0, take: 25 });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('clamps to the last page when the requested one shrank away', async () => {
|
|
348
|
+
const fetchSpy = vi
|
|
349
|
+
.spyOn(mcpApi, 'fetchOAuthClients')
|
|
350
|
+
.mockResolvedValueOnce({ data: [], count: 11, totals: { mine: 11 } })
|
|
351
|
+
.mockResolvedValueOnce({
|
|
352
|
+
data: [createOAuthClient()],
|
|
353
|
+
count: 11,
|
|
354
|
+
totals: { mine: 11 },
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
await store.setOAuthClientsPagination(5, 10);
|
|
358
|
+
|
|
359
|
+
expect(store.oauthClientsPage).toBe(1);
|
|
360
|
+
expect(fetchSpy).toHaveBeenLastCalledWith({}, { ownership: 'mine', skip: 10, take: 10 });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('stores the distinct owners returned by the server', async () => {
|
|
364
|
+
const jane = { id: 'user-1', firstName: 'Jane', lastName: 'Doe', email: 'jane@n8n.io' };
|
|
365
|
+
const adam = { id: 'user-2', firstName: 'Adam', lastName: 'Ant', email: 'adam@n8n.io' };
|
|
366
|
+
vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
|
367
|
+
data: [createOAuthClient({ id: 'a', owner: jane })],
|
|
368
|
+
count: 1,
|
|
369
|
+
totals: { mine: 1, all: 3 },
|
|
370
|
+
owners: [adam, jane],
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await store.getAllOAuthClients();
|
|
374
|
+
|
|
375
|
+
expect(store.oauthClientOwners).toEqual([adam, jane]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('passes the target userId on revoke and refetches the list', async () => {
|
|
379
|
+
const deleteSpy = vi.spyOn(mcpApi, 'deleteOAuthClient').mockResolvedValue({
|
|
380
|
+
success: true,
|
|
381
|
+
message: 'ok',
|
|
382
|
+
});
|
|
383
|
+
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
|
384
|
+
data: [],
|
|
385
|
+
count: 0,
|
|
386
|
+
totals: { mine: 0, all: 0 },
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await store.removeOAuthClient('client-1', 'user-2');
|
|
390
|
+
|
|
391
|
+
expect(deleteSpy).toHaveBeenCalledWith({}, 'client-1', 'user-2');
|
|
392
|
+
expect(fetchSpy).toHaveBeenCalled();
|
|
393
|
+
expect(store.oauthClientTotals).toEqual({ mine: 0, all: 0 });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('keeps a successful revoke successful when the totals refetch fails', async () => {
|
|
397
|
+
vi.spyOn(mcpApi, 'deleteOAuthClient').mockResolvedValue({ success: true, message: 'ok' });
|
|
398
|
+
vi.spyOn(mcpApi, 'fetchOAuthClients').mockRejectedValue(new Error('refetch failed'));
|
|
399
|
+
|
|
400
|
+
await expect(store.removeOAuthClient('client-1')).resolves.toEqual({
|
|
401
|
+
success: true,
|
|
402
|
+
message: 'ok',
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("skips the list refetch when the caller doesn't show the list", async () => {
|
|
407
|
+
vi.spyOn(mcpApi, 'deleteOAuthClient').mockResolvedValue({ success: true, message: 'ok' });
|
|
408
|
+
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients');
|
|
409
|
+
|
|
410
|
+
await store.removeOAuthClient('client-1', undefined, { refreshList: false });
|
|
411
|
+
|
|
412
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('ignores a stale in-flight list response superseded by a newer request', async () => {
|
|
416
|
+
let resolveStale!: (value: Awaited<ReturnType<typeof mcpApi.fetchOAuthClients>>) => void;
|
|
417
|
+
const stale = new Promise<Awaited<ReturnType<typeof mcpApi.fetchOAuthClients>>>((resolve) => {
|
|
418
|
+
resolveStale = resolve;
|
|
419
|
+
});
|
|
420
|
+
vi.spyOn(mcpApi, 'fetchOAuthClients')
|
|
421
|
+
.mockReturnValueOnce(stale)
|
|
422
|
+
.mockResolvedValueOnce({
|
|
423
|
+
data: [createOAuthClient({ id: 'new' })],
|
|
424
|
+
count: 1,
|
|
425
|
+
totals: { mine: 1 },
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const staleCall = store.getAllOAuthClients(); // in flight
|
|
429
|
+
await store.getAllOAuthClients(); // newer request commits 'new'
|
|
430
|
+
|
|
431
|
+
resolveStale({
|
|
432
|
+
data: [createOAuthClient({ id: 'old' })],
|
|
433
|
+
count: 1,
|
|
434
|
+
totals: { mine: 99 },
|
|
435
|
+
});
|
|
436
|
+
await staleCall;
|
|
437
|
+
|
|
438
|
+
// the superseded response must not overwrite the newer selection
|
|
439
|
+
expect(store.oauthClients.map((client) => client.id)).toEqual(['new']);
|
|
440
|
+
expect(store.oauthClientTotals).toEqual({ mine: 1 });
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe('toggleWorkflowsMcpAccess (bulk)', () => {
|
|
445
|
+
it('syncs only the workflows the backend confirmed', async () => {
|
|
446
|
+
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
|
447
|
+
updatedCount: 1,
|
|
448
|
+
updatedIds: ['wf-1'],
|
|
449
|
+
unchangedCount: 1,
|
|
450
|
+
unchangedIds: ['wf-2'],
|
|
451
|
+
skippedCount: 1,
|
|
452
|
+
failedCount: 0,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const response = await store.toggleWorkflowsMcpAccess(
|
|
456
|
+
{ workflowIds: ['wf-1', 'wf-2', 'wf-3'] },
|
|
457
|
+
true,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
expect(response.updatedIds).toEqual(['wf-1']);
|
|
461
|
+
expect(response.unchangedIds).toEqual(['wf-2']);
|
|
462
|
+
// The skipped workflow is left out of the sync.
|
|
463
|
+
expect(syncWorkflowMcpAccess).toHaveBeenCalledWith(['wf-1', 'wf-2'], true);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('does not throw when none of the targeted workflows were updated', async () => {
|
|
467
|
+
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
|
468
|
+
updatedCount: 0,
|
|
469
|
+
updatedIds: [],
|
|
470
|
+
unchangedCount: 2,
|
|
471
|
+
unchangedIds: ['wf-1', 'wf-2'],
|
|
472
|
+
skippedCount: 0,
|
|
473
|
+
failedCount: 0,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
await expect(
|
|
477
|
+
store.toggleWorkflowsMcpAccess({ workflowIds: ['wf-1', 'wf-2'] }, true),
|
|
478
|
+
).resolves.toEqual({
|
|
479
|
+
updatedCount: 0,
|
|
480
|
+
updatedIds: [],
|
|
481
|
+
unchangedCount: 2,
|
|
482
|
+
unchangedIds: ['wf-1', 'wf-2'],
|
|
483
|
+
skippedCount: 0,
|
|
484
|
+
failedCount: 0,
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('forwards projectId scope to the API', async () => {
|
|
489
|
+
// Project-scoped responses from the backend omit `updatedIds`.
|
|
490
|
+
const spy = vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
|
491
|
+
updatedCount: 0,
|
|
492
|
+
unchangedCount: 0,
|
|
493
|
+
skippedCount: 0,
|
|
494
|
+
failedCount: 0,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await store.toggleWorkflowsMcpAccess({ projectId: 'project-1' }, false);
|
|
498
|
+
|
|
499
|
+
expect(spy).toHaveBeenCalledWith({}, { projectId: 'project-1' }, false);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('forwards folderId scope to the API', async () => {
|
|
503
|
+
// Folder-scoped responses from the backend omit `updatedIds`.
|
|
504
|
+
const spy = vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
|
505
|
+
updatedCount: 0,
|
|
506
|
+
unchangedCount: 0,
|
|
507
|
+
skippedCount: 0,
|
|
508
|
+
failedCount: 0,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await store.toggleWorkflowsMcpAccess({ folderId: 'folder-1' }, true);
|
|
512
|
+
|
|
513
|
+
expect(spy).toHaveBeenCalledWith({}, { folderId: 'folder-1' }, true);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('does not sync when the response omits updatedIds (scope mode)', async () => {
|
|
517
|
+
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
|
518
|
+
updatedCount: 5,
|
|
519
|
+
unchangedCount: 0,
|
|
520
|
+
skippedCount: 0,
|
|
521
|
+
failedCount: 0,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
await expect(
|
|
525
|
+
store.toggleWorkflowsMcpAccess({ projectId: 'project-1' }, true),
|
|
526
|
+
).resolves.toEqual({ updatedCount: 5, unchangedCount: 0, skippedCount: 0, failedCount: 0 });
|
|
527
|
+
|
|
528
|
+
expect(syncWorkflowMcpAccess).not.toHaveBeenCalled();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('surfaces partial failures from the backend via failedCount', async () => {
|
|
532
|
+
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
|
533
|
+
updatedCount: 500,
|
|
534
|
+
unchangedCount: 0,
|
|
535
|
+
skippedCount: 0,
|
|
536
|
+
failedCount: 100,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await expect(
|
|
540
|
+
store.toggleWorkflowsMcpAccess({ projectId: 'big-project' }, true),
|
|
541
|
+
).resolves.toEqual({
|
|
542
|
+
updatedCount: 500,
|
|
543
|
+
unchangedCount: 0,
|
|
544
|
+
skippedCount: 0,
|
|
545
|
+
failedCount: 100,
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe('setMcpAccessEnabled', () => {
|
|
551
|
+
it.each([
|
|
552
|
+
{
|
|
553
|
+
desc: 'syncs both values from the response when enabling (auto-expose surfaced)',
|
|
554
|
+
response: { mcpAccessEnabled: true, autoExposeNewWorkflows: true },
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
desc: 'syncs both values from the response when disabling (auto-expose gated to false)',
|
|
558
|
+
response: { mcpAccessEnabled: false, autoExposeNewWorkflows: false },
|
|
559
|
+
},
|
|
560
|
+
])('$desc', async ({ response }) => {
|
|
561
|
+
const updateSpy = vi.spyOn(mcpApi, 'updateMcpSettings').mockResolvedValue(response);
|
|
562
|
+
const settingsStore = useSettingsStore();
|
|
563
|
+
const getModuleSettings = vi.spyOn(settingsStore, 'getModuleSettings').mockResolvedValue();
|
|
564
|
+
settingsStore.moduleSettings.mcp = {
|
|
565
|
+
mcpAccessEnabled: !response.mcpAccessEnabled,
|
|
566
|
+
mcpManagedByEnv: false,
|
|
567
|
+
autoExposeNewWorkflows: !response.autoExposeNewWorkflows,
|
|
568
|
+
serverUrl: 'https://example.com/mcp',
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const result = await useMCPStore().setMcpAccessEnabled(response.mcpAccessEnabled);
|
|
572
|
+
|
|
573
|
+
expect(updateSpy).toHaveBeenCalledWith(expect.anything(), {
|
|
574
|
+
mcpAccessEnabled: response.mcpAccessEnabled,
|
|
575
|
+
});
|
|
576
|
+
expect(result).toBe(response.mcpAccessEnabled);
|
|
577
|
+
expect(settingsStore.moduleSettings.mcp).toEqual({
|
|
578
|
+
mcpAccessEnabled: response.mcpAccessEnabled,
|
|
579
|
+
mcpManagedByEnv: false,
|
|
580
|
+
autoExposeNewWorkflows: response.autoExposeNewWorkflows,
|
|
581
|
+
serverUrl: 'https://example.com/mcp',
|
|
582
|
+
});
|
|
583
|
+
// The response carries both values, so no follow-up module-settings fetch.
|
|
584
|
+
expect(getModuleSettings).not.toHaveBeenCalled();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('defaults sibling fields when no local settings exist yet', async () => {
|
|
588
|
+
vi.spyOn(mcpApi, 'updateMcpSettings').mockResolvedValue({
|
|
589
|
+
mcpAccessEnabled: true,
|
|
590
|
+
autoExposeNewWorkflows: false,
|
|
591
|
+
});
|
|
592
|
+
const settingsStore = useSettingsStore();
|
|
593
|
+
settingsStore.moduleSettings.mcp = undefined;
|
|
594
|
+
|
|
595
|
+
const result = await useMCPStore().setMcpAccessEnabled(true);
|
|
596
|
+
|
|
597
|
+
expect(result).toBe(true);
|
|
598
|
+
expect(settingsStore.moduleSettings.mcp).toEqual({
|
|
599
|
+
mcpAccessEnabled: true,
|
|
600
|
+
mcpManagedByEnv: false,
|
|
601
|
+
autoExposeNewWorkflows: false,
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
describe('autoExposeNewWorkflows', () => {
|
|
607
|
+
it('patches only autoExposeNewWorkflows and syncs both values from the response', async () => {
|
|
608
|
+
const updateSpy = vi
|
|
609
|
+
.spyOn(mcpApi, 'updateMcpSettings')
|
|
610
|
+
.mockResolvedValue({ mcpAccessEnabled: true, autoExposeNewWorkflows: true });
|
|
611
|
+
const settingsStore = useSettingsStore();
|
|
612
|
+
settingsStore.moduleSettings.mcp = {
|
|
613
|
+
mcpAccessEnabled: true,
|
|
614
|
+
mcpManagedByEnv: false,
|
|
615
|
+
autoExposeNewWorkflows: false,
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const result = await useMCPStore().setAutoExposeNewWorkflows(true);
|
|
619
|
+
|
|
620
|
+
expect(updateSpy).toHaveBeenCalledWith(expect.anything(), {
|
|
621
|
+
autoExposeNewWorkflows: true,
|
|
622
|
+
});
|
|
623
|
+
expect(result).toBe(true);
|
|
624
|
+
expect(settingsStore.moduleSettings.mcp?.autoExposeNewWorkflows).toBe(true);
|
|
625
|
+
expect(settingsStore.moduleSettings.mcp?.mcpAccessEnabled).toBe(true);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
});
|