@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,359 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue';
3
+ import { CollapsibleRoot } from 'reka-ui';
4
+ import { useI18n } from '@n8n/i18n';
5
+ import {
6
+ N8nAnimatedCollapsibleContent,
7
+ N8nButton,
8
+ N8nDialog,
9
+ N8nDialogFooter,
10
+ N8nInput,
11
+ N8nRadioGroup,
12
+ N8nRadioGroupItem,
13
+ N8nText,
14
+ } from '@n8n/design-system';
15
+ import { validateRedirectUri } from '../redirect-uris.utils';
16
+
17
+ type UrlMode = 'all' | 'trusted';
18
+
19
+ interface UrlDraft {
20
+ id: number;
21
+ value: string;
22
+ }
23
+
24
+ const props = defineProps<{
25
+ open: boolean;
26
+ /** Currently persisted allow-list; empty means every callback URL is allowed. */
27
+ uris: string[];
28
+ saving?: boolean;
29
+ }>();
30
+
31
+ const emit = defineEmits<{
32
+ 'update:open': [value: boolean];
33
+ /** Emits the new allow-list; empty array = allow all. */
34
+ save: [uris: string[]];
35
+ }>();
36
+
37
+ const i18n = useI18n();
38
+
39
+ const mode = ref<UrlMode>('all');
40
+
41
+ // Rows carry their own identity: keyed by index, removing a middle row would
42
+ // re-key every row below it and defeat the enter/leave transitions.
43
+ let nextDraftId = 0;
44
+ const createDraft = (value = ''): UrlDraft => ({ id: nextDraftId++, value });
45
+
46
+ const drafts = ref<UrlDraft[]>([createDraft()]);
47
+
48
+ function resetFromUris() {
49
+ mode.value = props.uris.length > 0 ? 'trusted' : 'all';
50
+ drafts.value =
51
+ props.uris.length > 0 ? props.uris.map((uri) => createDraft(uri)) : [createDraft()];
52
+ }
53
+
54
+ // Sync when the dialog opens AND whenever the persisted list changes while open:
55
+ // the allow-list is loaded async, so a dialog opened before that resolves must
56
+ // pick up the loaded value rather than keep the default "All" (which would erase
57
+ // an existing trusted list on save). The store only mutates `uris` via load or a
58
+ // successful save (which closes the dialog), so this never clobbers live edits.
59
+ watch(
60
+ [() => props.open, () => props.uris],
61
+ ([open]) => {
62
+ if (!open) return;
63
+ resetFromUris();
64
+ },
65
+ { immediate: true },
66
+ );
67
+
68
+ const trimmedDrafts = computed(() =>
69
+ drafts.value.map((draft) => draft.value.trim()).filter((uri) => uri.length > 0),
70
+ );
71
+
72
+ const validationError = computed(() => {
73
+ if (mode.value !== 'trusted') return null;
74
+ for (const uri of trimmedDrafts.value) {
75
+ const error = validateRedirectUri(uri);
76
+ if (error) {
77
+ return i18n.baseText(`settings.mcp.allowedRedirectUris.validation.${error}`, {
78
+ interpolate: { url: uri },
79
+ });
80
+ }
81
+ }
82
+ return null;
83
+ });
84
+
85
+ const result = computed(() => (mode.value === 'all' ? [] : trimmedDrafts.value));
86
+
87
+ const canSave = computed(() => {
88
+ if (validationError.value) return false;
89
+ if (mode.value === 'trusted' && trimmedDrafts.value.length === 0) return false;
90
+ // Saving an unchanged selection (e.g. the default "All") is allowed and
91
+ // idempotent, so the user never has to tweak something just to enable Save.
92
+ return true;
93
+ });
94
+
95
+ const addUrl = () => {
96
+ drafts.value.push(createDraft());
97
+ };
98
+
99
+ const removeUrl = (id: number) => {
100
+ // The list never goes empty. Clearing the last row keeps its identity, so the
101
+ // input just empties instead of leaving and re-entering.
102
+ if (drafts.value.length === 1) {
103
+ drafts.value[0].value = '';
104
+ return;
105
+ }
106
+ drafts.value = drafts.value.filter((draft) => draft.id !== id);
107
+ };
108
+
109
+ const onCancel = () => emit('update:open', false);
110
+ const onSave = () => emit('save', result.value);
111
+ </script>
112
+
113
+ <template>
114
+ <N8nDialog
115
+ :open="open"
116
+ size="medium"
117
+ :header="i18n.baseText('settings.mcp.callbackUrls.dialog.title')"
118
+ :description="i18n.baseText('settings.mcp.callbackUrls.dialog.description')"
119
+ data-test-id="mcp-callback-urls-dialog"
120
+ @update:open="emit('update:open', $event)"
121
+ >
122
+ <div :class="$style.body">
123
+ <N8nRadioGroup v-model="mode" orientation="vertical" :class="$style.modes">
124
+ <div
125
+ v-for="option in ['all', 'trusted'] as const"
126
+ :key="option"
127
+ :class="[$style['mode-card'], mode === option && $style['mode-card-active']]"
128
+ :data-test-id="`mcp-callback-urls-mode-${option}`"
129
+ @click="mode = option"
130
+ >
131
+ <N8nRadioGroupItem
132
+ :value="option"
133
+ :label="i18n.baseText(`settings.mcp.callbackUrls.mode.${option}.label`)"
134
+ :description="i18n.baseText(`settings.mcp.callbackUrls.mode.${option}.description`)"
135
+ />
136
+ </div>
137
+ </N8nRadioGroup>
138
+
139
+ <!-- The URL list unfolds with the settings-surface blur motion (the same
140
+ N8nSettingsRow's expand region uses) so the dialog grows and shrinks
141
+ with it instead of jumping between the two heights. -->
142
+ <CollapsibleRoot :open="mode === 'trusted'">
143
+ <N8nAnimatedCollapsibleContent
144
+ :class="$style['trusted-content']"
145
+ blur
146
+ data-test-id="mcp-callback-urls-trusted"
147
+ >
148
+ <div :class="$style.trusted">
149
+ <N8nText size="small" color="text-dark" bold :class="$style['trusted-label']">
150
+ {{ i18n.baseText('settings.mcp.callbackUrls.trusted.label') }}
151
+ </N8nText>
152
+ <!-- Rows grow in and shrink out with the same motion as the section, so
153
+ adding or removing a URL never snaps the dialog to a new height. -->
154
+ <TransitionGroup
155
+ tag="div"
156
+ :class="$style['url-list']"
157
+ :enter-from-class="$style['slot-collapsed']"
158
+ :enter-active-class="$style['slot-animating']"
159
+ :leave-active-class="$style['slot-animating']"
160
+ :leave-to-class="$style['slot-collapsed']"
161
+ >
162
+ <div
163
+ v-for="draft in drafts"
164
+ :key="draft.id"
165
+ :class="$style.slot"
166
+ data-test-id="mcp-callback-url-row"
167
+ >
168
+ <div :class="$style['slot-clip']">
169
+ <div :class="$style['url-row']">
170
+ <N8nInput
171
+ v-model="draft.value"
172
+ type="text"
173
+ :placeholder="i18n.baseText('settings.mcp.callbackUrls.trusted.placeholder')"
174
+ data-test-id="mcp-callback-url-input"
175
+ />
176
+ <N8nButton
177
+ v-if="drafts.length > 1 || draft.value.length > 0"
178
+ variant="ghost"
179
+ size="small"
180
+ icon-only
181
+ icon="x"
182
+ :aria-label="i18n.baseText('generic.delete')"
183
+ data-test-id="mcp-callback-url-remove"
184
+ @click="removeUrl(draft.id)"
185
+ />
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </TransitionGroup>
190
+ <Transition
191
+ :enter-from-class="$style['slot-collapsed']"
192
+ :enter-active-class="$style['slot-animating']"
193
+ :leave-active-class="$style['slot-animating']"
194
+ :leave-to-class="$style['slot-collapsed']"
195
+ >
196
+ <div v-if="validationError" :class="$style.slot">
197
+ <div :class="$style['slot-clip']">
198
+ <N8nText
199
+ size="small"
200
+ color="danger"
201
+ :class="$style.error"
202
+ data-test-id="mcp-callback-urls-error"
203
+ >
204
+ {{ validationError }}
205
+ </N8nText>
206
+ </div>
207
+ </div>
208
+ </Transition>
209
+ <div>
210
+ <N8nButton
211
+ variant="outline"
212
+ size="small"
213
+ icon="plus"
214
+ :label="i18n.baseText('settings.mcp.callbackUrls.trusted.addUrl')"
215
+ data-test-id="mcp-callback-url-add"
216
+ @click="addUrl"
217
+ />
218
+ </div>
219
+ </div>
220
+ </N8nAnimatedCollapsibleContent>
221
+ </CollapsibleRoot>
222
+ </div>
223
+
224
+ <N8nDialogFooter>
225
+ <N8nButton variant="outline" :label="i18n.baseText('generic.cancel')" @click="onCancel" />
226
+ <N8nButton
227
+ variant="solid"
228
+ :label="i18n.baseText('settings.mcp.callbackUrls.dialog.save')"
229
+ :disabled="!canSave"
230
+ :loading="saving"
231
+ data-test-id="mcp-callback-urls-save"
232
+ @click="onSave"
233
+ />
234
+ </N8nDialogFooter>
235
+ </N8nDialog>
236
+ </template>
237
+
238
+ <style lang="scss" module>
239
+ @use '@n8n/design-system/css/mixins/motion';
240
+
241
+ // The settings-surface blur motion, shared with N8nSettingsRow's expand region
242
+ // and the collapsible section above, so the rows and the section move as one.
243
+ $slot-duration: motion.$blur-motion-duration;
244
+ $slot-easing: motion.$blur-motion-easing;
245
+
246
+ /* No flex gap here: the collapsible root stays in the flow while closed, so a
247
+ gap would leave a blank band under the cards and snap shut a frame after the
248
+ slide-up ends. The section's own top padding travels with the animated height. */
249
+ .body {
250
+ display: flex;
251
+ flex-direction: column;
252
+ margin-block: var(--spacing--xs);
253
+ }
254
+
255
+ .modes {
256
+ display: grid;
257
+ grid-template-columns: 1fr;
258
+ gap: var(--spacing--xs);
259
+ }
260
+
261
+ .mode-card {
262
+ padding: var(--spacing--sm);
263
+ border: var(--border);
264
+ border-radius: var(--radius--lg);
265
+ cursor: pointer;
266
+ }
267
+
268
+ .mode-card-active {
269
+ border-color: var(--color--primary);
270
+ }
271
+
272
+ /* The collapsible clips its overflow while it animates, which would cut the
273
+ inputs' focus ring at the dialog edges. Widen the clip box past the ring and
274
+ pull the content back in with matching padding. */
275
+ .trusted-content {
276
+ margin-inline: calc(-1 * var(--spacing--3xs));
277
+ padding-inline: var(--spacing--3xs);
278
+ }
279
+
280
+ /* No gap between the children either: every animated slot carries its own
281
+ spacing inside the animated height (see .url-row / .error), so a row's gap
282
+ shrinks away with the row instead of snapping shut when it unmounts. */
283
+ .trusted {
284
+ display: flex;
285
+ flex-direction: column;
286
+ padding-block-start: var(--spacing--sm);
287
+ }
288
+
289
+ .trusted-label {
290
+ margin-block-end: var(--spacing--2xs);
291
+ }
292
+
293
+ .url-list {
294
+ display: flex;
295
+ flex-direction: column;
296
+ }
297
+
298
+ /* A slot animates its real height with the grid 0fr→1fr technique (no
299
+ `height: auto` to interpolate), paired with the opacity fade and subtle blur
300
+ of the settings-surface motion. Identical to N8nSettingsRow's expand region. */
301
+ .slot {
302
+ display: grid;
303
+ grid-template-rows: 1fr;
304
+ opacity: 1;
305
+ /* `none`, not `blur(0)`: a non-none filter would keep a stacking context
306
+ * active on every settled row. `blur(4px) → none` still animates. */
307
+ filter: none;
308
+ }
309
+
310
+ .slot-collapsed {
311
+ grid-template-rows: 0fr;
312
+ opacity: 0;
313
+ filter: blur(4px);
314
+ }
315
+
316
+ .slot-animating {
317
+ transition:
318
+ grid-template-rows $slot-duration $slot-easing,
319
+ opacity $slot-duration $slot-easing,
320
+ filter $slot-duration $slot-easing;
321
+ }
322
+
323
+ /* `min-height: 0` lets the grid track shrink below the content. Clipping only
324
+ applies while a slot animates: at rest the inputs' focus ring must not be cut,
325
+ and while animating the clip box is widened past the ring, matching the
326
+ section's own treatment. */
327
+ .slot-clip {
328
+ min-height: 0;
329
+ margin-inline: calc(-1 * var(--spacing--3xs));
330
+ padding-inline: var(--spacing--3xs);
331
+ }
332
+
333
+ .slot-animating .slot-clip {
334
+ overflow: hidden;
335
+ }
336
+
337
+ .url-row {
338
+ display: flex;
339
+ align-items: center;
340
+ gap: var(--spacing--2xs);
341
+ padding-block-end: var(--spacing--2xs);
342
+ }
343
+
344
+ .error {
345
+ display: block;
346
+ padding-block-end: var(--spacing--2xs);
347
+ }
348
+
349
+ @media (prefers-reduced-motion: reduce) {
350
+ /* Drop the blur and the height animation; keep only a simple, quick fade. */
351
+ .slot-collapsed {
352
+ filter: none;
353
+ }
354
+
355
+ .slot-animating {
356
+ transition: opacity $slot-duration linear;
357
+ }
358
+ }
359
+ </style>
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, onUnmounted, ref } from 'vue';
3
+ import type { Component } from 'vue';
4
+ import { N8nIcon } from '@n8n/design-system';
5
+
6
+ import ClaudeIcon from '../assets/client-icons/claude.svg?component';
7
+ import CursorIcon from '../assets/client-icons/cursor.svg?component';
8
+ import OpenAiIcon from '../assets/client-icons/openai.svg?component';
9
+ import VsCodeIcon from '../assets/client-icons/vscode.svg?component';
10
+
11
+ const CLIENT_ICONS: Component[] = [ClaudeIcon, CursorIcon, VsCodeIcon, OpenAiIcon];
12
+
13
+ // Staggered fade/blur cycle through the known client logos on the side cards,
14
+ // same animation as the secrets-providers empty state.
15
+ const leftIconIndex = ref(0);
16
+ const rightIconIndex = ref(2);
17
+ const leftFading = ref(false);
18
+ const rightFading = ref(false);
19
+ let animationInterval: ReturnType<typeof setInterval> | null = null;
20
+
21
+ function animateLeft() {
22
+ leftFading.value = true;
23
+ setTimeout(() => {
24
+ leftIconIndex.value = (leftIconIndex.value + 1) % CLIENT_ICONS.length;
25
+ leftFading.value = false;
26
+ }, 300);
27
+ }
28
+
29
+ function animateRight() {
30
+ rightFading.value = true;
31
+ setTimeout(() => {
32
+ rightIconIndex.value = (rightIconIndex.value + 1) % CLIENT_ICONS.length;
33
+ rightFading.value = false;
34
+ }, 300);
35
+ }
36
+
37
+ onMounted(() => {
38
+ animationInterval = setInterval(() => {
39
+ animateLeft();
40
+ setTimeout(() => {
41
+ animateRight();
42
+ }, 1500);
43
+ }, 3000);
44
+ });
45
+
46
+ onUnmounted(() => {
47
+ if (animationInterval) {
48
+ clearInterval(animationInterval);
49
+ }
50
+ });
51
+ </script>
52
+
53
+ <template>
54
+ <div :class="$style.iconCardContainer">
55
+ <div :class="$style.iconCard">
56
+ <component
57
+ :is="CLIENT_ICONS[leftIconIndex]"
58
+ :class="[$style.clientLogo, { [$style.fading]: leftFading }]"
59
+ />
60
+ </div>
61
+ <div :class="$style.iconCard">
62
+ <N8nIcon icon="mcp" :class="$style.mcpIcon" />
63
+ </div>
64
+ <div :class="$style.iconCard">
65
+ <component
66
+ :is="CLIENT_ICONS[rightIconIndex]"
67
+ :class="[$style.clientLogo, { [$style.fading]: rightFading }]"
68
+ />
69
+ </div>
70
+ </div>
71
+ </template>
72
+
73
+ <style lang="scss" module>
74
+ .iconCardContainer {
75
+ display: flex;
76
+ justify-content: center;
77
+ align-items: center;
78
+ }
79
+
80
+ .iconCard {
81
+ width: 2.5rem;
82
+ height: 2.5rem;
83
+ border: var(--border);
84
+ display: flex;
85
+ justify-content: center;
86
+ align-items: center;
87
+ /* fixed white tile so dark brand marks stay visible on the dark theme */
88
+ background: var(--color--neutral-white);
89
+ box-shadow: var(--shadow--xs);
90
+ border-radius: var(--radius);
91
+ overflow: hidden;
92
+
93
+ &:nth-child(1) {
94
+ transform: rotate(-8deg);
95
+ }
96
+
97
+ &:nth-child(2) {
98
+ z-index: 1;
99
+ margin-top: -0.3rem;
100
+ }
101
+
102
+ &:nth-child(3) {
103
+ transform: rotate(8deg);
104
+ }
105
+ }
106
+
107
+ .clientLogo {
108
+ width: var(--spacing--lg);
109
+ height: var(--spacing--lg);
110
+ object-fit: contain;
111
+ opacity: 1;
112
+ filter: blur(0);
113
+ transition:
114
+ opacity 300ms ease-in-out,
115
+ filter 300ms ease-in-out;
116
+ }
117
+
118
+ .mcpIcon {
119
+ font-size: var(--spacing--lg);
120
+ /* the tile is always white, so the MCP glyph must stay dark in both themes */
121
+ color: var(--color--neutral-black);
122
+ }
123
+
124
+ .fading {
125
+ opacity: 0;
126
+ filter: blur(4px);
127
+ }
128
+
129
+ @media (prefers-reduced-motion: reduce) {
130
+ .clientLogo {
131
+ transition: none;
132
+ }
133
+ }
134
+ </style>
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import { useClipboard } from '@n8n/composables/useClipboard';
5
+ import { N8nButton, N8nMarkdown, N8nTooltip } from '@n8n/design-system';
6
+ import { MCP_TOOLTIP_DELAY } from '../mcp.constants';
7
+
8
+ const props = defineProps<{
9
+ /** Raw config snippet; rendered as a copyable code block. */
10
+ value: string;
11
+ /** Fence language for syntax highlighting. */
12
+ language?: string;
13
+ /** Hide the copy affordance (e.g. while the value is still resolving). */
14
+ disabled?: boolean;
15
+ }>();
16
+
17
+ const emit = defineEmits<{
18
+ copy: [value: string];
19
+ }>();
20
+
21
+ const i18n = useI18n();
22
+ const { copy, copied, isSupported } = useClipboard();
23
+
24
+ const code = computed(() => `\`\`\`${props.language ?? 'json'}\n${props.value}\n\`\`\``);
25
+
26
+ const handleCopy = async () => {
27
+ await copy(props.value);
28
+ emit('copy', props.value);
29
+ };
30
+ </script>
31
+
32
+ <template>
33
+ <div :class="$style.container" data-test-id="mcp-config-snippet">
34
+ <N8nMarkdown :content="code" />
35
+ <div :class="$style['copy-wrapper']">
36
+ <N8nTooltip
37
+ placement="bottom-end"
38
+ :disabled="!isSupported"
39
+ :content="copied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy')"
40
+ :show-after="MCP_TOOLTIP_DELAY"
41
+ >
42
+ <N8nButton
43
+ v-if="isSupported && !disabled"
44
+ variant="ghost"
45
+ icon-only
46
+ :icon="copied ? 'check' : 'copy'"
47
+ :class="$style['copy-button']"
48
+ data-test-id="mcp-config-snippet-copy"
49
+ @click="handleCopy"
50
+ />
51
+ </N8nTooltip>
52
+ </div>
53
+ </div>
54
+ </template>
55
+
56
+ <style lang="scss" module>
57
+ .container {
58
+ position: relative;
59
+
60
+ :global(.n8n-markdown) {
61
+ width: 100%;
62
+ }
63
+
64
+ code {
65
+ display: block;
66
+ color: var(--color--text) !important;
67
+ font-size: var(--font-size--2xs);
68
+ padding: var(--spacing--2xs) !important;
69
+ tab-size: 1;
70
+ background: none !important;
71
+ overflow-x: auto;
72
+ }
73
+ }
74
+
75
+ .copy-wrapper {
76
+ position: absolute;
77
+ top: var(--spacing--2xs);
78
+ right: var(--spacing--2xs);
79
+ }
80
+
81
+ /* Icon-only buttons are forced to a square (width = button height) with the icon
82
+ centred, leaving dead space each side; shrink to the glyph so it sits flush.
83
+ The !importants beat the DS's `.button.iconOnly` width rules; the ghost variant
84
+ already handles background (transparent at rest, hover feedback kept). */
85
+ .copy-button {
86
+ width: auto !important;
87
+
88
+ > * {
89
+ width: auto !important;
90
+ }
91
+ }
92
+ </style>
@@ -0,0 +1,115 @@
1
+ import { createTestingPinia } from '@pinia/testing';
2
+ import { within, waitFor } from '@testing-library/vue';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { createComponentRenderer, mockedStore, type MockedStore } from '@n8n/frontend-test-utils';
5
+ import McpConnectClientDialog from './McpConnectClientDialog.vue';
6
+ import { useMCPStore } from '../mcp.store';
7
+ import { useRootStore } from '@n8n/stores/useRootStore';
8
+
9
+ let pinia: ReturnType<typeof createTestingPinia>;
10
+ let mcpStore: MockedStore<typeof useMCPStore>;
11
+
12
+ const renderComponent = createComponentRenderer(McpConnectClientDialog, {
13
+ global: {
14
+ stubs: {
15
+ McpAccessTokenTab: {
16
+ template: '<div data-test-id="token-tab-stub" />',
17
+ },
18
+ },
19
+ },
20
+ });
21
+
22
+ // The dialog teleports to document.body.
23
+ const body = () => within(document.body);
24
+
25
+ describe('McpConnectClientDialog', () => {
26
+ beforeEach(() => {
27
+ pinia = createTestingPinia();
28
+ const rootStore = mockedStore(useRootStore);
29
+ rootStore.urlBaseEditor = 'https://acme.app.n8n.cloud/';
30
+ mcpStore = mockedStore(useMCPStore);
31
+ mcpStore.connectPopoverOpen = true;
32
+ });
33
+
34
+ it('should default to Claude.ai and show the web setup steps', async () => {
35
+ renderComponent({ pinia });
36
+
37
+ // The dialog root is renderless; wait for its content instead.
38
+ await waitFor(() => {
39
+ expect(body().getByTestId('mcp-connect-client-picker-trigger')).toBeInTheDocument();
40
+ });
41
+ expect(body().getByTestId('mcp-connect-client-picker-trigger')).toHaveTextContent('Claude.ai');
42
+ // Web client: one-click connector + mandatory server URL, no CLI/auth steps.
43
+ expect(body().getByTestId('mcp-connect-one-click')).toHaveAttribute(
44
+ 'href',
45
+ 'https://claude.ai/directory/connectors/n8n',
46
+ );
47
+ expect(body().getByText('Server URL')).toBeInTheDocument();
48
+ expect(body().queryByText('Or configure manually')).not.toBeInTheDocument();
49
+ expect(body().queryByText('Authenticate')).not.toBeInTheDocument();
50
+ });
51
+
52
+ it('should switch to IDE setup steps when picking Cursor', async () => {
53
+ renderComponent({ pinia });
54
+
55
+ await waitFor(() => {
56
+ expect(body().getByTestId('mcp-connect-client-picker-trigger')).toBeInTheDocument();
57
+ });
58
+ await userEvent.click(body().getByTestId('mcp-connect-client-picker-trigger'));
59
+ await waitFor(() => {
60
+ expect(body().getByText('Cursor')).toBeInTheDocument();
61
+ });
62
+ await userEvent.click(body().getByText('Cursor'));
63
+
64
+ await waitFor(() => {
65
+ expect(body().getByText('One-click setup')).toBeInTheDocument();
66
+ });
67
+ expect(body().getByText('Server URL')).toBeInTheDocument();
68
+ expect(body().queryByText('Add n8n to Cursor')).not.toBeInTheDocument();
69
+ expect(body().queryByText('Authenticate')).not.toBeInTheDocument();
70
+ });
71
+
72
+ it('should show only the server URL (no one-click) for web clients without a connector URL', async () => {
73
+ renderComponent({ pinia });
74
+
75
+ await waitFor(() => {
76
+ expect(body().getByTestId('mcp-connect-client-picker-trigger')).toBeInTheDocument();
77
+ });
78
+ await userEvent.click(body().getByTestId('mcp-connect-client-picker-trigger'));
79
+ await waitFor(() => {
80
+ expect(body().getByText('ChatGPT')).toBeInTheDocument();
81
+ });
82
+ await userEvent.click(body().getByText('ChatGPT'));
83
+
84
+ // ChatGPT has no one-click connector, so only the mandatory server URL shows.
85
+ await waitFor(() => {
86
+ expect(body().getByText('Server URL')).toBeInTheDocument();
87
+ });
88
+ expect(body().queryByTestId('mcp-connect-one-click')).not.toBeInTheDocument();
89
+ });
90
+
91
+ it('should show the OAuth client setup by default and switch to the API key tab', async () => {
92
+ renderComponent({ pinia });
93
+
94
+ await waitFor(() => {
95
+ expect(body().getByTestId('mcp-connect-client-picker-trigger')).toBeInTheDocument();
96
+ });
97
+ expect(body().queryByTestId('token-tab-stub')).not.toBeInTheDocument();
98
+
99
+ await userEvent.click(body().getByText('API key'));
100
+
101
+ expect(body().getByTestId('token-tab-stub')).toBeInTheDocument();
102
+ expect(body().queryByTestId('mcp-connect-client-picker-trigger')).not.toBeInTheDocument();
103
+ });
104
+
105
+ it('should close through the store', async () => {
106
+ renderComponent({ pinia });
107
+
108
+ await waitFor(() => {
109
+ expect(body().getByTestId('mcp-connect-client-picker-trigger')).toBeInTheDocument();
110
+ });
111
+ await userEvent.keyboard('{Escape}');
112
+
113
+ expect(mcpStore.closeConnectPopover).toHaveBeenCalled();
114
+ });
115
+ });