@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,329 @@
1
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
2
+ import WorkflowLocation from './WorkflowLocation.vue';
3
+ import { createHomeProject, createParentFolder } from '../mcp.test.utils';
4
+
5
+ vi.mock('vue-router', async (importOriginal) => ({
6
+ ...(await importOriginal()),
7
+ useRouter: () => ({
8
+ resolve: vi.fn(({ name, params }) => {
9
+ if (name === 'NodeViewExisting') {
10
+ return { fullPath: `/workflows/${params.workflowId}` };
11
+ }
12
+ if (name === 'ProjectsWorkflows') {
13
+ return { fullPath: `/projects/${params.projectId}` };
14
+ }
15
+ return { fullPath: '/' };
16
+ }),
17
+ }),
18
+ }));
19
+
20
+ const createComponent = createComponentRenderer(WorkflowLocation);
21
+
22
+ describe('WorkflowLocation', () => {
23
+ afterEach(() => {
24
+ vi.clearAllMocks();
25
+ });
26
+
27
+ describe('Project rendering', () => {
28
+ it('should render project name for team project', () => {
29
+ const { getByTestId } = createComponent({
30
+ props: {
31
+ workflowId: 'workflow-1',
32
+ workflowName: 'My Workflow',
33
+ homeProject: createHomeProject({ name: 'My Team Project', type: 'team' }),
34
+ },
35
+ });
36
+
37
+ expect(getByTestId('workflow-location-project-name')).toHaveTextContent('My Team Project');
38
+ });
39
+
40
+ it('should render "Personal" for personal project type', () => {
41
+ const { getByTestId } = createComponent({
42
+ props: {
43
+ workflowId: 'workflow-1',
44
+ workflowName: 'My Workflow',
45
+ homeProject: createHomeProject({ name: 'User<user@n8n.io>', type: 'personal' }),
46
+ },
47
+ });
48
+
49
+ expect(getByTestId('workflow-location-project-name')).toHaveTextContent('Personal');
50
+ });
51
+
52
+ it('should not render project when homeProject is not provided', () => {
53
+ const { queryByTestId } = createComponent({
54
+ props: {
55
+ workflowId: 'workflow-1',
56
+ workflowName: 'My Workflow',
57
+ },
58
+ });
59
+
60
+ expect(queryByTestId('workflow-location-project-name')).not.toBeInTheDocument();
61
+ });
62
+ });
63
+
64
+ describe('Folder rendering', () => {
65
+ it('should render folder name when parentFolder is provided', () => {
66
+ const { getByTestId } = createComponent({
67
+ props: {
68
+ workflowId: 'workflow-1',
69
+ workflowName: 'My Workflow',
70
+ homeProject: createHomeProject(),
71
+ parentFolder: createParentFolder({ name: 'My Folder' }),
72
+ },
73
+ });
74
+
75
+ expect(getByTestId('workflow-location-folder-name')).toHaveTextContent('My Folder');
76
+ });
77
+
78
+ it('should not render folder when parentFolder is not provided', () => {
79
+ const { queryByTestId } = createComponent({
80
+ props: {
81
+ workflowId: 'workflow-1',
82
+ workflowName: 'My Workflow',
83
+ homeProject: createHomeProject(),
84
+ },
85
+ });
86
+
87
+ expect(queryByTestId('workflow-location-folder-name')).not.toBeInTheDocument();
88
+ });
89
+
90
+ it('should render ellipsis when folder has a grandparent folder', () => {
91
+ const { getByTestId } = createComponent({
92
+ props: {
93
+ workflowId: 'workflow-1',
94
+ workflowName: 'My Workflow',
95
+ homeProject: createHomeProject(),
96
+ parentFolder: createParentFolder({ parentFolderId: 'grandparent-folder-id' }),
97
+ },
98
+ });
99
+
100
+ expect(getByTestId('workflow-location-grandparent')).toBeVisible();
101
+ expect(getByTestId('workflow-location-ellipsis-separator')).toBeVisible();
102
+ });
103
+
104
+ it('should not render ellipsis when folder is in project root', () => {
105
+ const { queryByTestId } = createComponent({
106
+ props: {
107
+ workflowId: 'workflow-1',
108
+ workflowName: 'My Workflow',
109
+ homeProject: createHomeProject(),
110
+ parentFolder: createParentFolder({ parentFolderId: null }),
111
+ },
112
+ });
113
+
114
+ expect(queryByTestId('workflow-location-grandparent')).not.toBeInTheDocument();
115
+ });
116
+ });
117
+
118
+ describe('Workflow name rendering', () => {
119
+ it('should render workflow name', () => {
120
+ const { getByTestId } = createComponent({
121
+ props: {
122
+ workflowId: 'workflow-1',
123
+ workflowName: 'My Awesome Workflow',
124
+ homeProject: createHomeProject(),
125
+ },
126
+ });
127
+
128
+ expect(getByTestId('workflow-location-workflow-name')).toHaveTextContent(
129
+ 'My Awesome Workflow',
130
+ );
131
+ });
132
+
133
+ it('should not render workflow name when empty', () => {
134
+ const { queryByTestId } = createComponent({
135
+ props: {
136
+ workflowId: 'workflow-1',
137
+ workflowName: '',
138
+ homeProject: createHomeProject(),
139
+ },
140
+ });
141
+
142
+ expect(queryByTestId('workflow-location-workflow-name')).not.toBeInTheDocument();
143
+ });
144
+ });
145
+
146
+ describe('Separators', () => {
147
+ it('should render separator between project and workflow name', () => {
148
+ const { getByTestId } = createComponent({
149
+ props: {
150
+ workflowId: 'workflow-1',
151
+ workflowName: 'My Workflow',
152
+ homeProject: createHomeProject(),
153
+ },
154
+ });
155
+
156
+ expect(getByTestId('workflow-location-separator')).toBeVisible();
157
+ });
158
+
159
+ it('should render separator between project and folder', () => {
160
+ const { getAllByText } = createComponent({
161
+ props: {
162
+ workflowId: 'workflow-1',
163
+ workflowName: 'My Workflow',
164
+ homeProject: createHomeProject(),
165
+ parentFolder: createParentFolder(),
166
+ },
167
+ });
168
+
169
+ // Should have multiple separators (project/folder and folder/workflow)
170
+ const separators = getAllByText('/');
171
+ expect(separators.length).toBeGreaterThanOrEqual(2);
172
+ });
173
+
174
+ it('should not render separator when no workflow name or folder', () => {
175
+ const { queryByTestId } = createComponent({
176
+ props: {
177
+ workflowId: 'workflow-1',
178
+ workflowName: '',
179
+ homeProject: createHomeProject(),
180
+ },
181
+ });
182
+
183
+ expect(queryByTestId('workflow-location-separator')).not.toBeInTheDocument();
184
+ });
185
+ });
186
+
187
+ describe('Links rendering (asLinks=true)', () => {
188
+ it('should render project as link when asLinks is true', () => {
189
+ const { getByTestId } = createComponent({
190
+ props: {
191
+ workflowId: 'workflow-1',
192
+ workflowName: 'My Workflow',
193
+ homeProject: createHomeProject({ id: 'project-123' }),
194
+ asLinks: true,
195
+ },
196
+ });
197
+
198
+ const projectLink = getByTestId('workflow-location-project-link');
199
+ expect(projectLink).toBeInTheDocument();
200
+ expect(projectLink).toHaveAttribute('href', '/projects/project-123');
201
+ });
202
+
203
+ it('should render folder as link when asLinks is true', () => {
204
+ const { getByTestId } = createComponent({
205
+ props: {
206
+ workflowId: 'workflow-1',
207
+ workflowName: 'My Workflow',
208
+ homeProject: createHomeProject({ id: 'project-123' }),
209
+ parentFolder: createParentFolder({ id: 'folder-456' }),
210
+ asLinks: true,
211
+ },
212
+ });
213
+
214
+ const folderLink = getByTestId('workflow-location-folder-link');
215
+ expect(folderLink).toBeInTheDocument();
216
+ expect(folderLink).toHaveAttribute(
217
+ 'href',
218
+ '/projects/project-123/folders/folder-456/workflows',
219
+ );
220
+ });
221
+
222
+ it('should render workflow name as link when asLinks is true', () => {
223
+ const { getByTestId } = createComponent({
224
+ props: {
225
+ workflowId: 'workflow-789',
226
+ workflowName: 'My Workflow',
227
+ homeProject: createHomeProject(),
228
+ asLinks: true,
229
+ },
230
+ });
231
+
232
+ const workflowLink = getByTestId('workflow-location-workflow-link');
233
+ expect(workflowLink).toBeInTheDocument();
234
+ expect(workflowLink).toHaveAttribute('href', '/workflows/workflow-789');
235
+ });
236
+ });
237
+
238
+ describe('Text rendering (asLinks=false)', () => {
239
+ it('should render project as plain text when asLinks is false', () => {
240
+ const { getByTestId, queryByTestId } = createComponent({
241
+ props: {
242
+ workflowId: 'workflow-1',
243
+ workflowName: 'My Workflow',
244
+ homeProject: createHomeProject(),
245
+ asLinks: false,
246
+ },
247
+ });
248
+
249
+ expect(getByTestId('workflow-location-project-name')).toBeInTheDocument();
250
+ expect(queryByTestId('workflow-location-project-link')).not.toBeInTheDocument();
251
+ });
252
+
253
+ it('should render folder as plain text when asLinks is false', () => {
254
+ const { getByTestId, queryByTestId } = createComponent({
255
+ props: {
256
+ workflowId: 'workflow-1',
257
+ workflowName: 'My Workflow',
258
+ homeProject: createHomeProject(),
259
+ parentFolder: createParentFolder(),
260
+ asLinks: false,
261
+ },
262
+ });
263
+
264
+ expect(getByTestId('workflow-location-folder-name')).toBeInTheDocument();
265
+ expect(queryByTestId('workflow-location-folder-link')).not.toBeInTheDocument();
266
+ });
267
+
268
+ it('should render workflow name as plain text when asLinks is false', () => {
269
+ const { getByTestId, queryByTestId } = createComponent({
270
+ props: {
271
+ workflowId: 'workflow-1',
272
+ workflowName: 'My Workflow',
273
+ homeProject: createHomeProject(),
274
+ asLinks: false,
275
+ },
276
+ });
277
+
278
+ expect(getByTestId('workflow-location-workflow-name')).toBeInTheDocument();
279
+ expect(queryByTestId('workflow-location-workflow-link')).not.toBeInTheDocument();
280
+ });
281
+ });
282
+
283
+ describe('Full location path rendering', () => {
284
+ it('should render complete path: Project / Folder / Workflow', () => {
285
+ const { getByTestId } = createComponent({
286
+ props: {
287
+ workflowId: 'workflow-1',
288
+ workflowName: 'My Workflow',
289
+ homeProject: createHomeProject({ name: 'My Project' }),
290
+ parentFolder: createParentFolder({ name: 'My Folder' }),
291
+ },
292
+ });
293
+
294
+ expect(getByTestId('workflow-location-project-name')).toHaveTextContent('My Project');
295
+ expect(getByTestId('workflow-location-folder-name')).toHaveTextContent('My Folder');
296
+ expect(getByTestId('workflow-location-workflow-name')).toHaveTextContent('My Workflow');
297
+ });
298
+
299
+ it('should render path with ellipsis for nested folder: Project / ... / Folder / Workflow', () => {
300
+ const { getByTestId } = createComponent({
301
+ props: {
302
+ workflowId: 'workflow-1',
303
+ workflowName: 'My Workflow',
304
+ homeProject: createHomeProject({ name: 'My Project' }),
305
+ parentFolder: createParentFolder({ name: 'Nested Folder', parentFolderId: 'parent-id' }),
306
+ },
307
+ });
308
+
309
+ expect(getByTestId('workflow-location-project-name')).toHaveTextContent('My Project');
310
+ expect(getByTestId('workflow-location-grandparent')).toBeVisible();
311
+ expect(getByTestId('workflow-location-folder-name')).toHaveTextContent('Nested Folder');
312
+ expect(getByTestId('workflow-location-workflow-name')).toHaveTextContent('My Workflow');
313
+ });
314
+
315
+ it('should render minimal path: Project only', () => {
316
+ const { getByTestId, queryByTestId } = createComponent({
317
+ props: {
318
+ workflowId: 'workflow-1',
319
+ workflowName: '',
320
+ homeProject: createHomeProject({ name: 'My Project' }),
321
+ },
322
+ });
323
+
324
+ expect(getByTestId('workflow-location-project-name')).toHaveTextContent('My Project');
325
+ expect(queryByTestId('workflow-location-folder-name')).not.toBeInTheDocument();
326
+ expect(queryByTestId('workflow-location-workflow-name')).not.toBeInTheDocument();
327
+ });
328
+ });
329
+ });
@@ -0,0 +1,198 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import { N8nLink, N8nText } from '@n8n/design-system';
5
+ import { VIEWS } from '@n8n/frontend-constants/views';
6
+ import type { ProjectSharingData } from 'n8n-workflow';
7
+ import { useRouter } from 'vue-router';
8
+
9
+ type ParentFolder = {
10
+ id: string;
11
+ name: string;
12
+ parentFolderId: string | null;
13
+ };
14
+
15
+ // If workflow name is not provided, only project and folder (if any) will be shown.
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ workflowId: string;
19
+ workflowName?: string;
20
+ homeProject?: ProjectSharingData;
21
+ parentFolder?: ParentFolder;
22
+ asLinks?: boolean;
23
+ }>(),
24
+ {
25
+ workflowName: undefined,
26
+ homeProject: undefined,
27
+ parentFolder: undefined,
28
+ asLinks: false,
29
+ },
30
+ );
31
+
32
+ const i18n = useI18n();
33
+ const router = useRouter();
34
+
35
+ const projectName = computed(() => {
36
+ if (props.homeProject?.type === 'personal') {
37
+ return i18n.baseText('projects.menu.personal');
38
+ }
39
+ return props.homeProject?.name ?? '';
40
+ });
41
+
42
+ const projectLink = computed(() => {
43
+ if (!props.homeProject) return '';
44
+ return router.resolve({
45
+ name: VIEWS.PROJECTS_WORKFLOWS,
46
+ params: { projectId: props.homeProject.id },
47
+ }).fullPath;
48
+ });
49
+
50
+ const folderLink = computed(() => {
51
+ if (!props.homeProject || !props.parentFolder) return '';
52
+ return `/projects/${props.homeProject.id}/folders/${props.parentFolder.id}/workflows`;
53
+ });
54
+
55
+ const workflowLink = computed(() => {
56
+ if (!props.workflowId) return '';
57
+ return router.resolve({
58
+ name: VIEWS.WORKFLOW,
59
+ params: { workflowId: props.workflowId },
60
+ }).fullPath;
61
+ });
62
+
63
+ const hasGrandparentFolder = computed(() => !!props.parentFolder?.parentFolderId);
64
+ </script>
65
+
66
+ <template>
67
+ <div :class="$style['location-container']">
68
+ <!-- Project -->
69
+ <span v-if="homeProject" :class="$style.truncate">
70
+ <N8nLink
71
+ v-if="asLinks"
72
+ data-test-id="workflow-location-project-link"
73
+ :to="projectLink"
74
+ :theme="'text'"
75
+ :class="[$style['location-link'], $style.truncate]"
76
+ :new-window="true"
77
+ >
78
+ <N8nText :class="$style.truncate" data-test-id="workflow-location-project-name">
79
+ {{ projectName }}
80
+ </N8nText>
81
+ </N8nLink>
82
+ <N8nText v-else :class="$style.truncate" data-test-id="workflow-location-project-name">
83
+ {{ projectName }}
84
+ </N8nText>
85
+ </span>
86
+
87
+ <!-- Separator after project -->
88
+ <span
89
+ v-if="parentFolder || workflowName"
90
+ :class="$style.separator"
91
+ data-test-id="workflow-location-separator"
92
+ >
93
+ /
94
+ </span>
95
+
96
+ <!-- Ellipsis for grandparent folder -->
97
+ <span
98
+ v-if="hasGrandparentFolder"
99
+ :class="$style.grandparent"
100
+ data-test-id="workflow-location-grandparent"
101
+ >
102
+ <span :class="$style.ellipsis">...</span>
103
+ <span :class="$style.separator" data-test-id="workflow-location-ellipsis-separator">/</span>
104
+ </span>
105
+
106
+ <!-- Parent folder -->
107
+ <span v-if="parentFolder" :class="$style['parent-folder']">
108
+ <N8nLink
109
+ v-if="asLinks && homeProject"
110
+ data-test-id="workflow-location-folder-link"
111
+ :to="folderLink"
112
+ :theme="'text'"
113
+ :class="[$style['location-link'], $style.truncate]"
114
+ :new-window="true"
115
+ >
116
+ <N8nText :class="$style.truncate" data-test-id="workflow-location-folder-name">
117
+ {{ parentFolder.name }}
118
+ </N8nText>
119
+ </N8nLink>
120
+ <N8nText v-else :class="$style.truncate" data-test-id="workflow-location-folder-name">
121
+ {{ parentFolder.name }}
122
+ </N8nText>
123
+ </span>
124
+
125
+ <!-- Separator before workflow name -->
126
+ <span v-if="parentFolder && workflowName" :class="$style.separator">/</span>
127
+
128
+ <!-- Workflow name -->
129
+ <span v-if="workflowName" :class="[$style['workflow-name'], $style.truncate]">
130
+ <N8nLink
131
+ v-if="asLinks && workflowId"
132
+ data-test-id="workflow-location-workflow-link"
133
+ :to="workflowLink"
134
+ :theme="'text'"
135
+ :class="[$style['location-link'], $style.truncate]"
136
+ :new-window="true"
137
+ >
138
+ <N8nText :class="$style.truncate" data-test-id="workflow-location-workflow-name">
139
+ {{ workflowName }}
140
+ </N8nText>
141
+ </N8nLink>
142
+ <N8nText v-else :class="$style.truncate" data-test-id="workflow-location-workflow-name">
143
+ {{ workflowName }}
144
+ </N8nText>
145
+ </span>
146
+ </div>
147
+ </template>
148
+
149
+ <style module lang="scss">
150
+ .location-container {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: var(--spacing--4xs);
154
+ color: var(--color--text);
155
+ min-width: 0;
156
+ overflow: hidden;
157
+ }
158
+
159
+ .ellipsis {
160
+ padding-right: var(--spacing--4xs);
161
+ }
162
+
163
+ .ellipsis,
164
+ .separator {
165
+ user-select: none;
166
+ color: var(--color--text--tint-1);
167
+ }
168
+
169
+ .grandparent {
170
+ display: flex;
171
+ align-items: center;
172
+ flex-shrink: 0;
173
+ }
174
+
175
+ .parent-folder {
176
+ display: flex;
177
+ align-items: center;
178
+ gap: var(--spacing--4xs);
179
+ min-width: 0;
180
+ overflow: hidden;
181
+ }
182
+
183
+ .truncate {
184
+ display: block;
185
+ overflow: hidden;
186
+ text-overflow: ellipsis;
187
+ white-space: nowrap;
188
+ min-width: 0;
189
+ }
190
+
191
+ .location-link {
192
+ color: var(--color--text);
193
+ }
194
+
195
+ .workflow-name {
196
+ color: var(--color--text--shade-1);
197
+ }
198
+ </style>