@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,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>