@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,432 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import type { BaseTextKey } from '@n8n/i18n';
5
+ import {
6
+ N8nButton,
7
+ N8nDialog,
8
+ N8nDropdownMenu,
9
+ N8nIcon,
10
+ N8nSettingsRow,
11
+ N8nSettingsRowGroup,
12
+ N8nTabs,
13
+ N8nText,
14
+ } from '@n8n/design-system';
15
+ import type { DropdownMenuItemProps, TabOptions } from '@n8n/design-system';
16
+ import { useTelemetry } from '@n8n/composables/useTelemetry';
17
+ import { useMCPStore } from '../mcp.store';
18
+ import { getMcpClientCatalog } from '../mcp.clients.catalog';
19
+ import type { McpClientSetup } from '../mcp.clients.catalog';
20
+ import ConnectionParameter from './ConnectionParameter.vue';
21
+ import McpConfigSnippet from './McpConfigSnippet.vue';
22
+ import McpAccessTokenTab from './McpAccessTokenTab.vue';
23
+
24
+ const i18n = useI18n();
25
+ const telemetry = useTelemetry();
26
+ const mcpStore = useMCPStore();
27
+
28
+ // Prefer the backend's dedicated MCP server URL (falls back to the editor URL in
29
+ // the store), so every copied command/config and the token tab target the endpoint
30
+ // the instance actually exposes.
31
+ const serverUrl = computed(() => mcpStore.serverUrl);
32
+ const catalog = computed(() => getMcpClientCatalog(serverUrl.value));
33
+ const clientsById = computed(
34
+ () =>
35
+ new Map<string, McpClientSetup>(
36
+ catalog.value.flatMap((group) => group.clients.map((client) => [client.id, client])),
37
+ ),
38
+ );
39
+
40
+ type ConnectMethod = 'oauth' | 'apiKey';
41
+ const activeMethod = ref<ConnectMethod>('oauth');
42
+ const methodTabs = computed<Array<TabOptions<ConnectMethod>>>(() => [
43
+ { label: i18n.baseText('settings.mcp.connectDialog.method.oauth'), value: 'oauth' },
44
+ { label: i18n.baseText('settings.mcp.connectDialog.method.apiKey'), value: 'apiKey' },
45
+ ]);
46
+
47
+ const activeClientId = ref('claude-ai');
48
+
49
+ const activeClient = computed(() => clientsById.value.get(activeClientId.value));
50
+
51
+ type MenuItemData = { kind: 'header' } | { kind: 'client'; client: McpClientSetup };
52
+
53
+ // Grouped picker items: each category is a disabled, divided header item
54
+ // (non-selectable) followed by its clients; the active client carries `checked`.
55
+ const clientMenuItems = computed<Array<DropdownMenuItemProps<string, MenuItemData>>>(() => {
56
+ const items: Array<DropdownMenuItemProps<string, MenuItemData>> = [];
57
+ for (const group of catalog.value) {
58
+ items.push({
59
+ id: `header:${group.id}`,
60
+ label: i18n.baseText(`settings.mcp.connectDialog.category.${group.id}` as BaseTextKey),
61
+ disabled: true,
62
+ divided: items.length > 0,
63
+ data: { kind: 'header' },
64
+ });
65
+ for (const client of group.clients) {
66
+ items.push({
67
+ id: client.id,
68
+ label: client.name,
69
+ checked: client.id === activeClientId.value,
70
+ data: { kind: 'client', client },
71
+ });
72
+ }
73
+ }
74
+ return items;
75
+ });
76
+
77
+ const onSelectClient = (id: string) => {
78
+ const item = clientMenuItems.value.find((menuItem) => menuItem.id === id);
79
+ if (item?.data?.kind !== 'client') return;
80
+ activeClientId.value = id;
81
+ telemetry.track('User selected MCP client in connect dialog', { client: id });
82
+ };
83
+
84
+ const onOpenChange = (open: boolean) => {
85
+ if (open) {
86
+ mcpStore.openConnectPopover();
87
+ } else {
88
+ mcpStore.closeConnectPopover();
89
+ mcpStore.resetCurrentUserMCPKey();
90
+ activeMethod.value = 'oauth';
91
+ }
92
+ };
93
+
94
+ const trackCopy = (parameter: string) => {
95
+ telemetry.track('User copied MCP connection parameter', {
96
+ parameter,
97
+ source: 'connect-dialog',
98
+ });
99
+ };
100
+
101
+ const handleTokenTabCopy = (type: 'serverUrl' | 'accessToken' | 'mcpJson') => {
102
+ const itemMap = { serverUrl: 'server-url', accessToken: 'access-token', mcpJson: 'mcp-json' };
103
+ trackCopy(itemMap[type]);
104
+ };
105
+
106
+ const installTitle = computed(() =>
107
+ i18n.baseText('settings.mcp.connectDialog.install.title', {
108
+ interpolate: { client: activeClient.value?.name ?? '' },
109
+ }),
110
+ );
111
+ const installDescription = computed(() =>
112
+ i18n.baseText('settings.mcp.connectDialog.install.description', {
113
+ interpolate: { client: activeClient.value?.name ?? '' },
114
+ }),
115
+ );
116
+ const authenticateDescription = computed(() =>
117
+ activeClient.value?.authCommand
118
+ ? i18n.baseText('settings.mcp.connectDialog.authenticate.description.command')
119
+ : i18n.baseText('settings.mcp.connectDialog.authenticate.description.mcpCommand', {
120
+ interpolate: { client: activeClient.value?.name ?? '' },
121
+ }),
122
+ );
123
+ const oneClickDescription = computed(() =>
124
+ activeClient.value?.category === 'web'
125
+ ? i18n.baseText('settings.mcp.connectDialog.oneClick.description.web', {
126
+ interpolate: { client: activeClient.value?.name ?? '' },
127
+ })
128
+ : i18n.baseText('settings.mcp.connectDialog.oneClick.description.ide', {
129
+ interpolate: { client: activeClient.value?.name ?? '' },
130
+ }),
131
+ );
132
+ const addButtonLabel = computed(() =>
133
+ i18n.baseText('settings.mcp.connectDialog.oneClick.button', {
134
+ interpolate: { client: activeClient.value?.name ?? '' },
135
+ }),
136
+ );
137
+ const serverUrlDescription = computed(() =>
138
+ i18n.baseText('settings.mcp.connectDialog.serverUrl.description', {
139
+ interpolate: { client: activeClient.value?.name ?? '' },
140
+ }),
141
+ );
142
+ </script>
143
+
144
+ <template>
145
+ <N8nDialog
146
+ :open="mcpStore.connectPopoverOpen"
147
+ size="large"
148
+ :header="i18n.baseText('settings.mcp.connectDialog.title')"
149
+ :description="i18n.baseText('settings.mcp.connectDialog.description')"
150
+ data-test-id="mcp-connect-dialog"
151
+ @update:open="onOpenChange"
152
+ >
153
+ <div :class="$style.body">
154
+ <N8nTabs
155
+ :model-value="activeMethod"
156
+ :options="methodTabs"
157
+ :class="$style.tabs"
158
+ data-test-id="mcp-connect-method-tabs"
159
+ @update:model-value="activeMethod = $event"
160
+ />
161
+
162
+ <N8nSettingsRowGroup v-if="activeMethod === 'oauth'">
163
+ <N8nSettingsRow
164
+ :title="i18n.baseText('settings.mcp.connectDialog.yourClient.title')"
165
+ :description="i18n.baseText('settings.mcp.connectDialog.yourClient.description')"
166
+ :show-divider="!!activeClient"
167
+ >
168
+ <template #action>
169
+ <N8nDropdownMenu
170
+ :items="clientMenuItems"
171
+ placement="bottom-end"
172
+ max-height="min(var(--reka-dropdown-menu-content-available-height), 30rem)"
173
+ :extra-popper-class="$style['picker-menu']"
174
+ data-test-id="mcp-connect-client-picker"
175
+ @select="onSelectClient"
176
+ >
177
+ <template #trigger>
178
+ <N8nButton
179
+ variant="outline"
180
+ size="medium"
181
+ :aria-label="i18n.baseText('settings.mcp.connectDialog.yourClient.title')"
182
+ data-test-id="mcp-connect-client-picker-trigger"
183
+ >
184
+ <span :class="$style['picker-trigger']">
185
+ <component
186
+ :is="activeClient.icon"
187
+ v-if="activeClient?.icon"
188
+ :class="$style['brand-icon']"
189
+ />
190
+ <N8nIcon v-else icon="mcp" size="small" color="text-light" />
191
+ {{ activeClient?.name }}
192
+ <N8nIcon icon="chevron-down" size="small" />
193
+ </span>
194
+ </N8nButton>
195
+ </template>
196
+ <template #item-leading="{ item }">
197
+ <template v-if="item.data?.kind === 'client'">
198
+ <component
199
+ :is="item.data.client.icon"
200
+ v-if="item.data.client.icon"
201
+ :class="$style['brand-icon']"
202
+ />
203
+ <N8nIcon v-else icon="mcp" size="large" color="text-light" />
204
+ </template>
205
+ </template>
206
+ <template #item-label="{ item }">
207
+ <span v-if="item.data?.kind === 'header'" :class="$style.category">
208
+ {{ item.label }}
209
+ </span>
210
+ <N8nText v-else size="medium" color="text-dark" :class="$style['client-label']">
211
+ {{ item.label }}
212
+ </N8nText>
213
+ </template>
214
+ </N8nDropdownMenu>
215
+ </template>
216
+ </N8nSettingsRow>
217
+
218
+ <!-- CLI: one command adds the server (requires the client installed and
219
+ writes its config), OR edit the config file manually → Authenticate. -->
220
+ <template v-if="activeClient?.category === 'cli'">
221
+ <N8nSettingsRow
222
+ layout="vertical"
223
+ :show-divider="false"
224
+ :title="installTitle"
225
+ :description="installDescription"
226
+ >
227
+ <template #action>
228
+ <ConnectionParameter
229
+ id="mcp-install-command"
230
+ :class="$style['copy-field']"
231
+ :label="''"
232
+ :value="activeClient.installCommand ?? ''"
233
+ @copy="trackCopy('install-command')"
234
+ />
235
+ </template>
236
+ </N8nSettingsRow>
237
+ <N8nSettingsRow
238
+ layout="vertical"
239
+ :show-divider="false"
240
+ :title="i18n.baseText('settings.mcp.connectDialog.configure.titleAlt')"
241
+ :description="i18n.baseText('settings.mcp.connectDialog.configure.descriptionAlt')"
242
+ >
243
+ <template #action>
244
+ <McpConfigSnippet
245
+ :class="$style['copy-field']"
246
+ :value="activeClient.configSnippet ?? ''"
247
+ :language="activeClient.id === 'codex' ? 'toml' : 'json'"
248
+ @copy="trackCopy('config')"
249
+ />
250
+ </template>
251
+ </N8nSettingsRow>
252
+ <N8nSettingsRow
253
+ layout="vertical"
254
+ :show-divider="false"
255
+ :title="i18n.baseText('settings.mcp.connectDialog.authenticate.title')"
256
+ :description="authenticateDescription"
257
+ >
258
+ <template #action>
259
+ <ConnectionParameter
260
+ id="mcp-auth-command"
261
+ :class="$style['copy-field']"
262
+ :label="''"
263
+ :value="activeClient.authCommand ?? '/mcp'"
264
+ @copy="trackCopy('auth-command')"
265
+ />
266
+ </template>
267
+ </N8nSettingsRow>
268
+ </template>
269
+
270
+ <!-- Web: one-click connector (when supported) + the mandatory server URL to paste in. -->
271
+ <template v-else-if="activeClient?.category === 'web'">
272
+ <N8nSettingsRow
273
+ v-if="activeClient.addUrl"
274
+ :show-divider="false"
275
+ :title="i18n.baseText('settings.mcp.connectDialog.oneClick.title')"
276
+ :description="oneClickDescription"
277
+ >
278
+ <template #action>
279
+ <N8nButton
280
+ variant="outline"
281
+ size="medium"
282
+ :href="activeClient.addUrl"
283
+ target="_blank"
284
+ data-test-id="mcp-connect-one-click"
285
+ >
286
+ <component
287
+ :is="activeClient.icon"
288
+ v-if="activeClient.icon"
289
+ :class="$style['brand-icon']"
290
+ />
291
+ {{ addButtonLabel }}
292
+ </N8nButton>
293
+ </template>
294
+ </N8nSettingsRow>
295
+ <N8nSettingsRow
296
+ layout="vertical"
297
+ :show-divider="false"
298
+ :title="i18n.baseText('settings.mcp.connectDialog.serverUrl')"
299
+ :description="serverUrlDescription"
300
+ >
301
+ <template #action>
302
+ <ConnectionParameter
303
+ id="mcp-web-server-url"
304
+ :class="$style['copy-field']"
305
+ :label="''"
306
+ :value="serverUrl"
307
+ @copy="trackCopy('server-url')"
308
+ />
309
+ </template>
310
+ </N8nSettingsRow>
311
+ </template>
312
+
313
+ <!-- IDE: one-click deep link (when supported) + manual config -->
314
+ <template v-else-if="activeClient">
315
+ <N8nSettingsRow
316
+ v-if="activeClient.deepLink"
317
+ :show-divider="false"
318
+ :title="i18n.baseText('settings.mcp.connectDialog.oneClick.title')"
319
+ :description="oneClickDescription"
320
+ >
321
+ <template #action>
322
+ <N8nButton
323
+ variant="outline"
324
+ size="medium"
325
+ :href="activeClient.deepLink"
326
+ data-test-id="mcp-connect-one-click"
327
+ >
328
+ <component
329
+ :is="activeClient.icon"
330
+ v-if="activeClient.icon"
331
+ :class="$style['brand-icon']"
332
+ />
333
+ {{ addButtonLabel }}
334
+ </N8nButton>
335
+ </template>
336
+ </N8nSettingsRow>
337
+ <N8nSettingsRow
338
+ layout="vertical"
339
+ :show-divider="false"
340
+ :title="i18n.baseText('settings.mcp.connectDialog.serverUrl')"
341
+ :description="serverUrlDescription"
342
+ >
343
+ <template #action>
344
+ <ConnectionParameter
345
+ id="mcp-server-url"
346
+ :class="$style['copy-field']"
347
+ :label="''"
348
+ :value="serverUrl"
349
+ @copy="trackCopy('server-url')"
350
+ />
351
+ </template>
352
+ </N8nSettingsRow>
353
+ <N8nSettingsRow
354
+ layout="vertical"
355
+ :show-divider="false"
356
+ :title="i18n.baseText('settings.mcp.connectDialog.configure.title')"
357
+ :description="i18n.baseText('settings.mcp.connectDialog.configure.description')"
358
+ >
359
+ <template #action>
360
+ <McpConfigSnippet
361
+ :class="$style['copy-field']"
362
+ :value="activeClient.configSnippet ?? ''"
363
+ @copy="trackCopy('config')"
364
+ />
365
+ </template>
366
+ </N8nSettingsRow>
367
+ </template>
368
+ </N8nSettingsRowGroup>
369
+
370
+ <div v-else :class="$style['token-setup']" data-test-id="mcp-connect-token-setup">
371
+ <McpAccessTokenTab :server-url="serverUrl" @copied="handleTokenTabCopy" />
372
+ </div>
373
+ </div>
374
+ </N8nDialog>
375
+ </template>
376
+
377
+ <style lang="scss" module>
378
+ /* Body-only scroll so the dialog header stays pinned and tall clients
379
+ (e.g. Claude Code's config block) never run off-screen. */
380
+ .body {
381
+ max-height: calc(100dvh - 16rem);
382
+ /* Extra breathing room so the OAuth/API-key tabs don't crowd the description. */
383
+ margin-top: var(--spacing--md);
384
+ overflow-y: auto;
385
+ }
386
+
387
+ .tabs {
388
+ margin-bottom: var(--spacing--sm);
389
+ }
390
+
391
+ .picker-trigger {
392
+ display: inline-flex;
393
+ align-items: center;
394
+ gap: var(--spacing--2xs);
395
+ }
396
+
397
+ /* Give the menu room so client names (e.g. "Claude Code") never wrap. */
398
+ .picker-menu {
399
+ min-width: 15rem;
400
+ }
401
+
402
+ .client-label {
403
+ white-space: nowrap;
404
+ }
405
+
406
+ .brand-icon {
407
+ width: 1em;
408
+ height: 1em;
409
+ flex: 0 0 auto;
410
+ font-size: var(--font-size--md);
411
+ }
412
+
413
+ .category {
414
+ display: block;
415
+ padding-block: var(--spacing--5xs);
416
+ font-size: var(--font-size--3xs);
417
+ font-weight: var(--font-weight--bold);
418
+ letter-spacing: 0.06em;
419
+ text-transform: uppercase;
420
+ color: var(--color--text--tint-1);
421
+ }
422
+
423
+ /* Vertical settings rows hug the action to its content; the copy fields
424
+ should span the full row width. */
425
+ .copy-field {
426
+ width: 100%;
427
+ }
428
+
429
+ .token-setup {
430
+ margin-top: var(--spacing--2xs);
431
+ }
432
+ </style>
@@ -0,0 +1,86 @@
1
+ import userEvent from '@testing-library/user-event';
2
+ import { createTestingPinia } from '@pinia/testing';
3
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
4
+ import McpConnectedClientRow from './McpConnectedClientRow.vue';
5
+ import { createOAuthClient } from '../mcp.test.utils';
6
+
7
+ const createComponent = createComponentRenderer(McpConnectedClientRow, {
8
+ pinia: createTestingPinia(),
9
+ global: {
10
+ stubs: {
11
+ N8nTimeAgo: {
12
+ props: ['date'],
13
+ template: '<span>2 minutes ago</span>',
14
+ },
15
+ },
16
+ },
17
+ });
18
+
19
+ describe('McpConnectedClientRow', () => {
20
+ afterEach(() => {
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ it('renders the client with its type, connection time and access summary', () => {
25
+ const { getByTestId } = createComponent({
26
+ props: {
27
+ client: createOAuthClient({
28
+ name: 'Cursor',
29
+ scopes: ['workflow:read', 'execution:read', 'workflow:write'],
30
+ }),
31
+ },
32
+ });
33
+
34
+ const row = getByTestId('mcp-client-preview-row');
35
+ expect(getByTestId('mcp-client-preview-name')).toHaveTextContent('Cursor');
36
+ expect(row).toHaveTextContent('IDE · Connected 2 minutes ago');
37
+ expect(getByTestId('mcp-client-preview-access')).toHaveTextContent(
38
+ 'List workflows, Get execution details +1',
39
+ );
40
+ });
41
+
42
+ it('omits the type for clients without a recognized brand', () => {
43
+ const { getByTestId } = createComponent({
44
+ props: { client: createOAuthClient({ name: 'Some Unknown Client' }) },
45
+ });
46
+
47
+ expect(getByTestId('mcp-client-preview-row')).not.toHaveTextContent('·');
48
+ expect(getByTestId('mcp-client-preview-row')).toHaveTextContent('Connected 2 minutes ago');
49
+ });
50
+
51
+ it('reads a grant covering every offered scope as full access', () => {
52
+ const { getByTestId } = createComponent({
53
+ props: {
54
+ client: createOAuthClient({ scopes: ['workflow:read', 'execution:read'] }),
55
+ scopeTools: { 'workflow:read': ['list_workflows'], 'execution:read': ['get_execution'] },
56
+ },
57
+ });
58
+
59
+ expect(getByTestId('mcp-client-preview-access')).toHaveTextContent('Full access');
60
+ });
61
+
62
+ it('emits click when the row is activated', async () => {
63
+ const { getByTestId, emitted } = createComponent({
64
+ props: { client: createOAuthClient() },
65
+ });
66
+
67
+ await userEvent.click(getByTestId('mcp-client-preview-name'));
68
+
69
+ expect(emitted('click')).toHaveLength(1);
70
+ expect(emitted('revoke')).toBeUndefined();
71
+ });
72
+
73
+ it('emits revoke, and not click, when the revoke action is used', async () => {
74
+ const { getByTestId, emitted } = createComponent({
75
+ props: { client: createOAuthClient({ name: 'Claude Code' }) },
76
+ });
77
+
78
+ const button = getByTestId('mcp-client-preview-revoke-button');
79
+ expect(button).toHaveAccessibleName('Revoke access for Claude Code');
80
+
81
+ await userEvent.click(button);
82
+
83
+ expect(emitted('revoke')).toHaveLength(1);
84
+ expect(emitted('click')).toBeUndefined();
85
+ });
86
+ });
@@ -0,0 +1,152 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { I18nT } from 'vue-i18n';
4
+ import type { OAuthClientResponseDto } from '@n8n/api-types';
5
+ import { N8nButton, N8nIcon, N8nSettingsRow, N8nText, N8nTimeAgo } from '@n8n/design-system';
6
+ import { useI18n } from '@n8n/i18n';
7
+ import type { BaseTextKey } from '@n8n/i18n';
8
+ import { useRootStore } from '@n8n/stores/useRootStore';
9
+
10
+ import { getAccessSummary, getClientBrand } from '../clients.utils';
11
+
12
+ /**
13
+ * One of the current user's connected clients, previewed inline on the MCP
14
+ * settings overview. The row opens the client's details; the hover-revealed
15
+ * action starts the revoke flow.
16
+ */
17
+ const props = defineProps<{
18
+ client: OAuthClientResponseDto;
19
+ /** Tool names each scope unlocks on this instance; decides when a grant reads as "Full access". */
20
+ scopeTools?: Record<string, string[]>;
21
+ }>();
22
+
23
+ const emit = defineEmits<{
24
+ click: [];
25
+ revoke: [];
26
+ }>();
27
+
28
+ const i18n = useI18n();
29
+ const rootStore = useRootStore();
30
+
31
+ const brand = computed(() => getClientBrand(props.client.name));
32
+
33
+ const typeLabel = computed(() => {
34
+ const type = brand.value.type;
35
+ if (!type) return null;
36
+ return i18n.baseText(`settings.mcp.oAuthClients.clientType.${type}` as BaseTextKey);
37
+ });
38
+
39
+ const offeredScopes = computed(() =>
40
+ props.scopeTools ? Object.keys(props.scopeTools) : undefined,
41
+ );
42
+
43
+ const accessSummary = computed(() => getAccessSummary(i18n, props.client, offeredScopes.value));
44
+
45
+ const grantedAt = computed(() => new Date(props.client.grantedAt).toISOString());
46
+
47
+ // One translatable phrase per shape ("IDE · Connected 2 minutes ago" / "Connected
48
+ // 2 minutes ago"), so locales own the separator and word order; the relative time
49
+ // is a component, hence slot interpolation rather than a string.
50
+ const metaKeypath = computed(() =>
51
+ typeLabel.value
52
+ ? 'settings.mcp.connectedClients.preview.meta'
53
+ : 'settings.mcp.connectedClients.preview.metaWithoutType',
54
+ );
55
+
56
+ const revokeLabel = computed(() =>
57
+ i18n.baseText('settings.mcp.oAuthClients.table.action.revokeAccessFor', {
58
+ interpolate: { name: props.client.name },
59
+ }),
60
+ );
61
+ </script>
62
+
63
+ <template>
64
+ <N8nSettingsRow
65
+ :title="client.name"
66
+ show-visual
67
+ clickable
68
+ reveal-actions-on-hover
69
+ data-test-id="mcp-client-preview-row"
70
+ @click="emit('click')"
71
+ >
72
+ <template #visual>
73
+ <span :class="$style.tile">
74
+ <component :is="brand.icon" v-if="brand.icon" :class="$style.icon" />
75
+ <N8nIcon v-else icon="mcp" :class="$style.icon" />
76
+ </span>
77
+ </template>
78
+ <template #info>
79
+ <div :class="$style.info">
80
+ <N8nText
81
+ bold
82
+ size="medium"
83
+ color="text-dark"
84
+ :class="$style.line"
85
+ data-test-id="mcp-client-preview-name"
86
+ >
87
+ {{ client.name }}
88
+ </N8nText>
89
+ <N8nText size="small" color="text-light" :class="$style.line">
90
+ <I18nT :keypath="metaKeypath" scope="global" tag="span">
91
+ <template v-if="typeLabel" #type>{{ typeLabel }}</template>
92
+ <template #timeAgo
93
+ ><N8nTimeAgo :date="grantedAt" :locale="rootStore.defaultLocale"
94
+ /></template>
95
+ </I18nT>
96
+ </N8nText>
97
+ <N8nText
98
+ size="xsmall"
99
+ color="text-light"
100
+ :class="$style.line"
101
+ data-test-id="mcp-client-preview-access"
102
+ >
103
+ {{ accessSummary }}
104
+ </N8nText>
105
+ </div>
106
+ </template>
107
+ <template #action>
108
+ <N8nButton
109
+ variant="outline"
110
+ size="small"
111
+ :label="i18n.baseText('settings.mcp.oAuthClients.table.action.revokeAccess')"
112
+ :aria-label="revokeLabel"
113
+ data-test-id="mcp-client-preview-revoke-button"
114
+ @click="emit('revoke')"
115
+ />
116
+ </template>
117
+ </N8nSettingsRow>
118
+ </template>
119
+
120
+ <style lang="scss" module>
121
+ /* Fills the row's bordered visual box. Fixed white so dark brand marks stay visible on the dark theme. */
122
+ .tile {
123
+ display: inline-flex;
124
+ align-items: center;
125
+ justify-content: center;
126
+ width: 100%;
127
+ height: 100%;
128
+ background-color: var(--color--neutral-white);
129
+ }
130
+
131
+ .icon {
132
+ width: var(--spacing--md);
133
+ height: var(--spacing--md);
134
+ /* the tile is always white, so the fallback MCP glyph must stay dark in both themes */
135
+ color: var(--color--neutral-black);
136
+ }
137
+
138
+ /* Mirrors the row's own title/description stack so the extra access line slots in beneath them. */
139
+ .info {
140
+ display: flex;
141
+ flex-direction: column;
142
+ gap: var(--spacing--5xs);
143
+ min-width: 0;
144
+ }
145
+
146
+ .line {
147
+ display: block;
148
+ overflow: hidden;
149
+ text-overflow: ellipsis;
150
+ white-space: nowrap;
151
+ }
152
+ </style>