@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,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
|
+
});
|