@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,384 @@
|
|
|
1
|
+
import { nextTick } from 'vue';
|
|
2
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
3
|
+
import { waitFor } from '@testing-library/vue';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { createComponentRenderer, mockedStore, type MockedStore } from '@n8n/frontend-test-utils';
|
|
6
|
+
import MCPWorkflowsSelect from './MCPWorkflowsSelect.vue';
|
|
7
|
+
import { useMCPStore } from '../mcp.store';
|
|
8
|
+
import { createWorkflow } from '../mcp.test.utils';
|
|
9
|
+
import type { McpWorkflow } from '../mcp.types';
|
|
10
|
+
|
|
11
|
+
vi.mock('vue-router', async (importOriginal) => ({
|
|
12
|
+
...(await importOriginal()),
|
|
13
|
+
useRouter: () => ({
|
|
14
|
+
resolve: vi.fn(({ name, params }) => {
|
|
15
|
+
if (name === 'NodeViewExisting') {
|
|
16
|
+
return { fullPath: `/workflows/${params.workflowId}` };
|
|
17
|
+
}
|
|
18
|
+
if (name === 'ProjectsWorkflows') {
|
|
19
|
+
return { fullPath: `/projects/${params.projectId}` };
|
|
20
|
+
}
|
|
21
|
+
return { fullPath: '/' };
|
|
22
|
+
}),
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
let pinia: ReturnType<typeof createTestingPinia>;
|
|
27
|
+
let mcpStore: MockedStore<typeof useMCPStore>;
|
|
28
|
+
|
|
29
|
+
const createComponent = createComponentRenderer(MCPWorkflowsSelect);
|
|
30
|
+
|
|
31
|
+
describe('MCPWorkflowsSelect', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
pinia = createTestingPinia();
|
|
34
|
+
mcpStore = mockedStore(useMCPStore);
|
|
35
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
36
|
+
count: 0,
|
|
37
|
+
data: [],
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('Initial rendering', () => {
|
|
46
|
+
it('should render the select component', async () => {
|
|
47
|
+
const { getByTestId } = createComponent({ pinia });
|
|
48
|
+
await nextTick();
|
|
49
|
+
|
|
50
|
+
expect(getByTestId('mcp-workflows-select')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should render with placeholder prop', async () => {
|
|
54
|
+
const { getByTestId } = createComponent({
|
|
55
|
+
pinia,
|
|
56
|
+
props: {
|
|
57
|
+
placeholder: 'Search workflows...',
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
await nextTick();
|
|
61
|
+
|
|
62
|
+
const select = getByTestId('mcp-workflows-select');
|
|
63
|
+
// In multiple mode the placeholder lives on the main input, not the inline filter input
|
|
64
|
+
const inputs = Array.from(select.querySelectorAll('input'));
|
|
65
|
+
expect(
|
|
66
|
+
inputs.some((input) => input.getAttribute('placeholder') === 'Search workflows...'),
|
|
67
|
+
).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should render as disabled when disabled prop is true', async () => {
|
|
71
|
+
const { container } = createComponent({
|
|
72
|
+
pinia,
|
|
73
|
+
props: {
|
|
74
|
+
disabled: true,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
await nextTick();
|
|
78
|
+
|
|
79
|
+
// The disabled class is applied to the inner el-select element
|
|
80
|
+
const elSelect = container.querySelector('.el-select');
|
|
81
|
+
expect(elSelect).toHaveClass('el-select--disabled');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('Fetching workflows on mount', () => {
|
|
86
|
+
it('should fetch workflows on mount', async () => {
|
|
87
|
+
createComponent({ pinia });
|
|
88
|
+
await nextTick();
|
|
89
|
+
|
|
90
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalledWith({
|
|
91
|
+
take: 10,
|
|
92
|
+
query: undefined,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should handle async workflow fetching', async () => {
|
|
97
|
+
// Create a promise that doesn't resolve immediately
|
|
98
|
+
let resolvePromise: (value: { count: number; data: McpWorkflow[] }) => void;
|
|
99
|
+
mcpStore.getMcpEligibleWorkflows.mockImplementation(
|
|
100
|
+
async () =>
|
|
101
|
+
await new Promise((resolve) => {
|
|
102
|
+
resolvePromise = resolve;
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const { getByTestId } = createComponent({ pinia });
|
|
107
|
+
await nextTick();
|
|
108
|
+
|
|
109
|
+
// The component should be rendered while loading
|
|
110
|
+
expect(getByTestId('mcp-workflows-select')).toBeInTheDocument();
|
|
111
|
+
|
|
112
|
+
// Resolve the promise with workflows
|
|
113
|
+
const workflows = [createWorkflow({ id: 'wf-1', name: 'Test Workflow' })];
|
|
114
|
+
resolvePromise!({ count: 1, data: workflows });
|
|
115
|
+
|
|
116
|
+
await nextTick();
|
|
117
|
+
|
|
118
|
+
// Verify the API was called
|
|
119
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('Workflow options rendering', () => {
|
|
124
|
+
it('should render workflow options after fetching', async () => {
|
|
125
|
+
const workflows = [
|
|
126
|
+
createWorkflow({ id: 'wf-1', name: 'Workflow One' }),
|
|
127
|
+
createWorkflow({ id: 'wf-2', name: 'Workflow Two' }),
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
131
|
+
count: 2,
|
|
132
|
+
data: workflows,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const { getByTestId } = createComponent({ pinia });
|
|
136
|
+
await nextTick();
|
|
137
|
+
|
|
138
|
+
// Wait for loading to complete
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const select = getByTestId('mcp-workflows-select');
|
|
144
|
+
await userEvent.click(select);
|
|
145
|
+
|
|
146
|
+
await waitFor(() => {
|
|
147
|
+
const options = document.querySelectorAll('.el-select-dropdown__item');
|
|
148
|
+
expect(options.length).toBe(2);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should show empty state when no workflows are returned', async () => {
|
|
153
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
154
|
+
count: 0,
|
|
155
|
+
data: [],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const { getByTestId } = createComponent({ pinia });
|
|
159
|
+
await nextTick();
|
|
160
|
+
|
|
161
|
+
// Wait for the fetch to complete
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const select = getByTestId('mcp-workflows-select');
|
|
167
|
+
await userEvent.click(select);
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
const emptyText = document.querySelector('.el-select-dropdown__empty');
|
|
171
|
+
expect(emptyText).toBeInTheDocument();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('Remote search', () => {
|
|
177
|
+
it('should search workflows when user types in input', async () => {
|
|
178
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
179
|
+
count: 0,
|
|
180
|
+
data: [],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const { getByTestId } = createComponent({ pinia });
|
|
184
|
+
await nextTick();
|
|
185
|
+
|
|
186
|
+
// Wait for initial fetch
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Clear mocks to track new calls
|
|
192
|
+
mcpStore.getMcpEligibleWorkflows.mockClear();
|
|
193
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
194
|
+
count: 1,
|
|
195
|
+
data: [createWorkflow({ id: 'wf-1', name: 'Searched Workflow' })],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const select = getByTestId('mcp-workflows-select');
|
|
199
|
+
const input = select.querySelector('input');
|
|
200
|
+
|
|
201
|
+
if (input) {
|
|
202
|
+
await userEvent.type(input, 'test');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await waitFor(() => {
|
|
206
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalledWith({
|
|
207
|
+
take: 10,
|
|
208
|
+
query: 'test',
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('v-model binding', () => {
|
|
215
|
+
it('should collect selected options into the modelValue array', async () => {
|
|
216
|
+
const workflows = [
|
|
217
|
+
createWorkflow({ id: 'wf-1', name: 'Selectable Workflow' }),
|
|
218
|
+
createWorkflow({ id: 'wf-2', name: 'Another Workflow' }),
|
|
219
|
+
];
|
|
220
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
221
|
+
count: 2,
|
|
222
|
+
data: workflows,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const { getByTestId, emitted } = createComponent({ pinia });
|
|
226
|
+
await nextTick();
|
|
227
|
+
|
|
228
|
+
// Wait for fetch to complete
|
|
229
|
+
await waitFor(() => {
|
|
230
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const select = getByTestId('mcp-workflows-select');
|
|
234
|
+
await userEvent.click(select);
|
|
235
|
+
|
|
236
|
+
await waitFor(() => {
|
|
237
|
+
const options = document.querySelectorAll('.el-select-dropdown__item');
|
|
238
|
+
expect(options.length).toBe(2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const options = document.querySelectorAll('.el-select-dropdown__item');
|
|
242
|
+
await userEvent.click(options[0]);
|
|
243
|
+
|
|
244
|
+
await waitFor(() => {
|
|
245
|
+
expect(emitted('update:modelValue')).toBeTruthy();
|
|
246
|
+
expect(emitted('update:modelValue')[0]).toEqual([['wf-1']]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await userEvent.click(options[1]);
|
|
250
|
+
|
|
251
|
+
await waitFor(() => {
|
|
252
|
+
expect(emitted('update:modelValue')[1]).toEqual([['wf-1', 'wf-2']]);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('WorkflowLocation rendering in options', () => {
|
|
258
|
+
it('should render workflow location with project name', async () => {
|
|
259
|
+
const workflows = [
|
|
260
|
+
createWorkflow({
|
|
261
|
+
id: 'wf-1',
|
|
262
|
+
name: 'My Workflow',
|
|
263
|
+
homeProject: {
|
|
264
|
+
id: 'project-1',
|
|
265
|
+
type: 'team',
|
|
266
|
+
name: 'Team Project',
|
|
267
|
+
icon: null,
|
|
268
|
+
createdAt: '2025-01-01',
|
|
269
|
+
updatedAt: '2025-01-01',
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
275
|
+
count: 1,
|
|
276
|
+
data: workflows,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const { getByTestId } = createComponent({ pinia });
|
|
280
|
+
await nextTick();
|
|
281
|
+
|
|
282
|
+
// Wait for fetch to complete
|
|
283
|
+
await waitFor(() => {
|
|
284
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const select = getByTestId('mcp-workflows-select');
|
|
288
|
+
await userEvent.click(select);
|
|
289
|
+
|
|
290
|
+
await waitFor(() => {
|
|
291
|
+
const projectName = document.querySelector(
|
|
292
|
+
'[data-test-id="workflow-location-project-name"]',
|
|
293
|
+
);
|
|
294
|
+
expect(projectName).toBeInTheDocument();
|
|
295
|
+
expect(projectName).toHaveTextContent('Team Project');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should render workflow location with folder when parentFolder is provided', async () => {
|
|
300
|
+
const workflows = [
|
|
301
|
+
createWorkflow({
|
|
302
|
+
id: 'wf-1',
|
|
303
|
+
name: 'My Workflow',
|
|
304
|
+
homeProject: {
|
|
305
|
+
id: 'project-1',
|
|
306
|
+
type: 'team',
|
|
307
|
+
name: 'Team Project',
|
|
308
|
+
icon: null,
|
|
309
|
+
createdAt: '2025-01-01',
|
|
310
|
+
updatedAt: '2025-01-01',
|
|
311
|
+
},
|
|
312
|
+
parentFolder: {
|
|
313
|
+
id: 'folder-1',
|
|
314
|
+
name: 'My Folder',
|
|
315
|
+
parentFolderId: null,
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
321
|
+
count: 1,
|
|
322
|
+
data: workflows,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const { getByTestId } = createComponent({ pinia });
|
|
326
|
+
await nextTick();
|
|
327
|
+
|
|
328
|
+
// Wait for fetch to complete
|
|
329
|
+
await waitFor(() => {
|
|
330
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const select = getByTestId('mcp-workflows-select');
|
|
334
|
+
await userEvent.click(select);
|
|
335
|
+
|
|
336
|
+
await waitFor(() => {
|
|
337
|
+
const folderName = document.querySelector('[data-test-id="workflow-location-folder-name"]');
|
|
338
|
+
expect(folderName).toBeInTheDocument();
|
|
339
|
+
expect(folderName).toHaveTextContent('My Folder');
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should render Personal for personal project type', async () => {
|
|
344
|
+
const workflows = [
|
|
345
|
+
createWorkflow({
|
|
346
|
+
id: 'wf-1',
|
|
347
|
+
name: 'My Workflow',
|
|
348
|
+
homeProject: {
|
|
349
|
+
id: 'personal-project',
|
|
350
|
+
type: 'personal',
|
|
351
|
+
name: 'User<user@n8n.io>',
|
|
352
|
+
icon: null,
|
|
353
|
+
createdAt: '2025-01-01',
|
|
354
|
+
updatedAt: '2025-01-01',
|
|
355
|
+
},
|
|
356
|
+
}),
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
360
|
+
count: 1,
|
|
361
|
+
data: workflows,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const { getByTestId } = createComponent({ pinia });
|
|
365
|
+
await nextTick();
|
|
366
|
+
|
|
367
|
+
// Wait for fetch to complete
|
|
368
|
+
await waitFor(() => {
|
|
369
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const select = getByTestId('mcp-workflows-select');
|
|
373
|
+
await userEvent.click(select);
|
|
374
|
+
|
|
375
|
+
await waitFor(() => {
|
|
376
|
+
const projectName = document.querySelector(
|
|
377
|
+
'[data-test-id="workflow-location-project-name"]',
|
|
378
|
+
);
|
|
379
|
+
expect(projectName).toBeInTheDocument();
|
|
380
|
+
expect(projectName).toHaveTextContent('Personal');
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useMCPStore } from '../mcp.store';
|
|
3
|
+
import { LOADING_INDICATOR_TIMEOUT } from '../mcp.constants';
|
|
4
|
+
import { N8nSelect, N8nOption } from '@n8n/design-system';
|
|
5
|
+
import { computed, onMounted, ref, useCssModule } from 'vue';
|
|
6
|
+
import type { McpWorkflow } from '../mcp.types';
|
|
7
|
+
import WorkflowLocation from './WorkflowLocation.vue';
|
|
8
|
+
import { useI18n } from '@n8n/i18n';
|
|
9
|
+
import { useToast } from '@n8n/composables/useToast';
|
|
10
|
+
|
|
11
|
+
defineProps<{
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const i18n = useI18n();
|
|
17
|
+
const toast = useToast();
|
|
18
|
+
|
|
19
|
+
const modelValue = defineModel<string[]>({ default: () => [] });
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
ready: [];
|
|
23
|
+
confirm: [];
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
const mcpStore = useMCPStore();
|
|
27
|
+
|
|
28
|
+
const isLoading = ref(false);
|
|
29
|
+
const hasFetched = ref(false);
|
|
30
|
+
const isDropdownVisible = ref(false);
|
|
31
|
+
const selectRef = ref<InstanceType<typeof N8nSelect>>();
|
|
32
|
+
const workflowOptions = ref<McpWorkflow[]>([]);
|
|
33
|
+
let loadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
34
|
+
|
|
35
|
+
const showEmptyState = computed(() => {
|
|
36
|
+
return !isLoading.value && hasFetched.value && workflowOptions.value.length === 0;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const $style = useCssModule();
|
|
40
|
+
|
|
41
|
+
// `popper-class` is a string prop, so the classes are joined here rather than
|
|
42
|
+
// passed as an object binding.
|
|
43
|
+
const popperClass = computed(() =>
|
|
44
|
+
[
|
|
45
|
+
isLoading.value ? $style['mcp-workflows-select-loading'] : '',
|
|
46
|
+
showEmptyState.value ? $style['mcp-workflows-select-empty'] : '',
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join(' '),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
async function searchWorkflows(query?: string) {
|
|
53
|
+
if (loadingTimeoutId) {
|
|
54
|
+
clearTimeout(loadingTimeoutId);
|
|
55
|
+
loadingTimeoutId = null;
|
|
56
|
+
}
|
|
57
|
+
isLoading.value = true;
|
|
58
|
+
hasFetched.value = false;
|
|
59
|
+
try {
|
|
60
|
+
const response = await mcpStore.getMcpEligibleWorkflows({
|
|
61
|
+
take: 10,
|
|
62
|
+
query: query ?? undefined,
|
|
63
|
+
});
|
|
64
|
+
workflowOptions.value = response?.data ?? [];
|
|
65
|
+
} catch (e) {
|
|
66
|
+
toast.showError(e, i18n.baseText('settings.mcp.connectWorkflows.error'));
|
|
67
|
+
} finally {
|
|
68
|
+
await waitFor(LOADING_INDICATOR_TIMEOUT);
|
|
69
|
+
isLoading.value = false;
|
|
70
|
+
hasFetched.value = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function waitFor(timeout: number) {
|
|
75
|
+
await new Promise<void>((resolve) => {
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
resolve();
|
|
78
|
+
}, timeout);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function focusOnInput() {
|
|
83
|
+
selectRef.value?.focusOnInput();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onVisibleChange(visible: boolean) {
|
|
87
|
+
isDropdownVisible.value = visible;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onKeydownCapture(event: KeyboardEvent) {
|
|
91
|
+
if (event.key === 'Enter' && !isDropdownVisible.value && modelValue.value.length > 0) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
event.stopPropagation();
|
|
94
|
+
emit('confirm');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
onMounted(async () => {
|
|
99
|
+
await searchWorkflows();
|
|
100
|
+
emit('ready');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
defineExpose({
|
|
104
|
+
focusOnInput,
|
|
105
|
+
});
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<template>
|
|
109
|
+
<div @keydown.enter.capture="onKeydownCapture">
|
|
110
|
+
<N8nSelect
|
|
111
|
+
ref="selectRef"
|
|
112
|
+
v-model="modelValue"
|
|
113
|
+
data-test-id="mcp-workflows-select"
|
|
114
|
+
:placeholder="placeholder"
|
|
115
|
+
:disabled="disabled"
|
|
116
|
+
:loading="isLoading"
|
|
117
|
+
:multiple="true"
|
|
118
|
+
:filterable="true"
|
|
119
|
+
:remote="true"
|
|
120
|
+
:remote-method="searchWorkflows"
|
|
121
|
+
size="medium"
|
|
122
|
+
:popper-class="popperClass"
|
|
123
|
+
:teleported="false"
|
|
124
|
+
@visible-change="onVisibleChange"
|
|
125
|
+
>
|
|
126
|
+
<N8nOption v-if="showEmptyState" value="" disabled :class="$style['empty-option']">
|
|
127
|
+
{{ i18n.baseText('settings.mcp.connectWorkflows.emptyState') }}
|
|
128
|
+
</N8nOption>
|
|
129
|
+
<N8nOption
|
|
130
|
+
v-for="workflow in workflowOptions"
|
|
131
|
+
:key="workflow.id"
|
|
132
|
+
:value="workflow.id"
|
|
133
|
+
:label="workflow.name"
|
|
134
|
+
>
|
|
135
|
+
<WorkflowLocation
|
|
136
|
+
:workflow-id="workflow.id"
|
|
137
|
+
:workflow-name="workflow.name"
|
|
138
|
+
:home-project="workflow.homeProject"
|
|
139
|
+
:parent-folder="workflow.parentFolder"
|
|
140
|
+
/>
|
|
141
|
+
</N8nOption>
|
|
142
|
+
</N8nSelect>
|
|
143
|
+
</div>
|
|
144
|
+
</template>
|
|
145
|
+
|
|
146
|
+
<style module lang="scss">
|
|
147
|
+
.mcp-workflows-select-loading,
|
|
148
|
+
.mcp-workflows-select-empty {
|
|
149
|
+
display: flex;
|
|
150
|
+
min-height: var(--spacing--5xl);
|
|
151
|
+
align-items: center;
|
|
152
|
+
justify-content: space-around;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.empty-option {
|
|
156
|
+
cursor: default !important;
|
|
157
|
+
color: var(--color--text) !important;
|
|
158
|
+
}
|
|
159
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ElSwitch } from 'element-plus';
|
|
3
|
+
import { N8nText, N8nTooltip } from '@n8n/design-system';
|
|
4
|
+
import { useI18n } from '@n8n/i18n';
|
|
5
|
+
|
|
6
|
+
import { computed } from 'vue';
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
modelValue: boolean;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
managedByEnv?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
16
|
+
disabled: false,
|
|
17
|
+
loading: false,
|
|
18
|
+
managedByEnv: false,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
disableMcpAccess: [];
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
const i18n = useI18n();
|
|
26
|
+
|
|
27
|
+
const disabledTooltip = computed(() =>
|
|
28
|
+
props.managedByEnv
|
|
29
|
+
? i18n.baseText('settings.mcp.managedByEnv.tooltip')
|
|
30
|
+
: i18n.baseText('settings.mcp.toggle.disabled.tooltip'),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const onUpdateMCPEnabled = () => {
|
|
34
|
+
emit('disableMcpAccess');
|
|
35
|
+
};
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div :class="$style['main-toggle-container']">
|
|
40
|
+
<div :class="$style['main-toggle-info']">
|
|
41
|
+
<N8nText :bold="true" :color="modelValue ? `success` : `text-light`" size="small">
|
|
42
|
+
{{
|
|
43
|
+
modelValue
|
|
44
|
+
? i18n.baseText('settings.mcp.header.toggle.enabled')
|
|
45
|
+
: i18n.baseText('settings.mcp.header.toggle.disabled')
|
|
46
|
+
}}
|
|
47
|
+
</N8nText>
|
|
48
|
+
</div>
|
|
49
|
+
<div :class="$style['main-toggle']" data-test-id="mcp-toggle-container">
|
|
50
|
+
<N8nTooltip :content="disabledTooltip" :disabled="!props.disabled" placement="top">
|
|
51
|
+
<ElSwitch
|
|
52
|
+
size="large"
|
|
53
|
+
data-test-id="mcp-access-toggle"
|
|
54
|
+
:model-value="props.modelValue"
|
|
55
|
+
:disabled="props.disabled"
|
|
56
|
+
:loading="props.loading"
|
|
57
|
+
@update:model-value="onUpdateMCPEnabled"
|
|
58
|
+
/>
|
|
59
|
+
</N8nTooltip>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<style module lang="scss">
|
|
65
|
+
.main-toggle-container {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: var(--spacing--2xs);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.main-toggle {
|
|
72
|
+
display: flex;
|
|
73
|
+
justify-content: flex-end;
|
|
74
|
+
align-items: center;
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
}
|
|
77
|
+
</style>
|