@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,473 @@
1
+ <script setup lang="ts">
2
+ import { useI18n } from '@n8n/i18n';
3
+ import type { BaseTextKey } from '@n8n/i18n';
4
+ import type { OAuthClientResponseDto } from '@n8n/api-types';
5
+ import {
6
+ N8nButton,
7
+ N8nDataTableServer,
8
+ N8nIcon,
9
+ N8nInput,
10
+ N8nLoading,
11
+ N8nTabs,
12
+ N8nText,
13
+ N8nTimeAgo,
14
+ } from '@n8n/design-system';
15
+ import type { IUser, TabOptions, TableHeader } from '@n8n/design-system';
16
+ import { computed, ref } from 'vue';
17
+ import debounce from 'lodash/debounce';
18
+ import { useMCPStore } from '../../mcp.store';
19
+ import { useRBACStore } from '@n8n/stores/rbac.store';
20
+ import { useUsersStore } from '@n8n/stores/users.store';
21
+ import { useRootStore } from '@n8n/stores/useRootStore';
22
+ import { getDebounceTime } from '@n8n/composables/useDebounce';
23
+ import { DEBOUNCE_TIME } from '@n8n/frontend-constants/durations';
24
+ import { EMPTY_OAUTH_CLIENT_FILTERS, getAccessSummary, getClientBrand } from '../../clients.utils';
25
+ import type { OAuthClientFilters } from '../../clients.utils';
26
+ import McpEmptyStateCard from '../McpEmptyStateCard.vue';
27
+ import OAuthClientDetailsModal from '../OAuthClientDetailsModal.vue';
28
+ import OAuthClientOwnerCell from './OAuthClientOwnerCell.vue';
29
+ import OAuthClientsFilters from './OAuthClientsFilters.vue';
30
+
31
+ const i18n = useI18n();
32
+ const mcpStore = useMCPStore();
33
+ const rbacStore = useRBACStore();
34
+ const usersStore = useUsersStore();
35
+ const rootStore = useRootStore();
36
+
37
+ type Props = {
38
+ clients: OAuthClientResponseDto[];
39
+ loading: boolean;
40
+ scopeTools?: Record<string, string[]>;
41
+ };
42
+
43
+ const props = defineProps<Props>();
44
+
45
+ const emit = defineEmits<{
46
+ revokeClient: [client: OAuthClientResponseDto];
47
+ 'update:ownership': [ownership: 'mine' | 'all'];
48
+ 'update:filters': [filters: OAuthClientFilters];
49
+ 'update:options': [options: { page: number; itemsPerPage: number }];
50
+ }>();
51
+
52
+ // The store is the source of truth for pagination; setters route through the
53
+ // parent so it can wrap the refetch in its loading state.
54
+ const page = computed({
55
+ get: () => mcpStore.oauthClientsPage,
56
+ set: (value: number) => emit('update:options', { page: value, itemsPerPage: itemsPerPage.value }),
57
+ });
58
+ const itemsPerPage = computed({
59
+ get: () => mcpStore.oauthClientsPageSize,
60
+ set: (value: number) => emit('update:options', { page: page.value, itemsPerPage: value }),
61
+ });
62
+
63
+ const detailsClient = ref<OAuthClientResponseDto | null>(null);
64
+ const detailsOpen = ref(false);
65
+
66
+ const canManageAllClients = computed(() => rbacStore.hasScope('mcp:manage'));
67
+ const ownership = computed(() => mcpStore.oauthClientsOwnership);
68
+ const offeredScopes = computed(() =>
69
+ props.scopeTools ? Object.keys(props.scopeTools) : undefined,
70
+ );
71
+
72
+ // Badges show the unfiltered totals so a search-narrowed "Mine (0)" doesn't read
73
+ // as "no connected clients" when there are clients that just don't match.
74
+ const tabOptions = computed<Array<TabOptions<'mine' | 'all'>>>(() => [
75
+ {
76
+ label: i18n.baseText('settings.mcp.oAuthClients.tabs.mine'),
77
+ value: 'mine' as const,
78
+ tag: String(mcpStore.oauthClientTotals.mine),
79
+ },
80
+ {
81
+ label: i18n.baseText('settings.mcp.oAuthClients.tabs.all'),
82
+ value: 'all' as const,
83
+ tag: String(mcpStore.oauthClientTotals.all ?? 0),
84
+ },
85
+ ]);
86
+
87
+ // Local UI state of the search + popover; every change is emitted so the
88
+ // parent can push it to the store and refetch server-side.
89
+ const filters = ref<OAuthClientFilters>({ ...EMPTY_OAUTH_CLIENT_FILTERS });
90
+ const searchQuery = ref('');
91
+
92
+ const hasActiveFilters = computed(
93
+ () =>
94
+ filters.value.search.trim() !== '' ||
95
+ filters.value.type !== null ||
96
+ filters.value.ownerId !== null ||
97
+ filters.value.connected !== null,
98
+ );
99
+
100
+ // Aggregate total across the tabs a manager can see (own count for members).
101
+ const totalClients = computed(
102
+ () => mcpStore.oauthClientTotals.all ?? mcpStore.oauthClientTotals.mine,
103
+ );
104
+
105
+ // Show only the empty state (hiding tabs, toolbar and table) when there are no
106
+ // clients anywhere — so a manager with 0 of their own but clients under "All"
107
+ // still gets the tabs. A search that returns nothing keeps the toolbar
108
+ // (hasActiveFilters) so the user can clear it.
109
+ const showEmptyState = computed(
110
+ () => props.clients.length === 0 && totalClients.value === 0 && !hasActiveFilters.value,
111
+ );
112
+
113
+ function onFiltersChange(newFilters: OAuthClientFilters) {
114
+ filters.value = newFilters;
115
+ emit('update:filters', newFilters);
116
+ }
117
+
118
+ const applySearch = debounce((value: string) => {
119
+ onFiltersChange({ ...filters.value, search: value });
120
+ }, getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH));
121
+
122
+ function onSearchInput(value: string) {
123
+ searchQuery.value = value;
124
+ void applySearch(value);
125
+ }
126
+
127
+ const ownerOptions = computed<IUser[]>(() =>
128
+ mcpStore.oauthClientOwners.map((owner) => ({
129
+ id: owner.id,
130
+ firstName: owner.firstName,
131
+ lastName: owner.lastName,
132
+ email: owner.email,
133
+ fullName: [owner.firstName, owner.lastName].filter(Boolean).join(' ') || undefined,
134
+ })),
135
+ );
136
+
137
+ function onOwnershipChange(newOwnership: 'mine' | 'all') {
138
+ if (newOwnership === ownership.value) return;
139
+ // Drop any queued search so a stale term can't re-filter after the switch.
140
+ applySearch.cancel();
141
+ filters.value = { ...EMPTY_OAUTH_CLIENT_FILTERS };
142
+ searchQuery.value = '';
143
+ emit('update:ownership', newOwnership);
144
+ }
145
+
146
+ // A consent row is (client × owner): the same client id can appear once per
147
+ // owner in the All view, so row identity must include the owner.
148
+ function rowId(row: OAuthClientResponseDto) {
149
+ return `${row.id}:${row.owner?.id ?? 'mine'}`;
150
+ }
151
+
152
+ const tableHeaders = computed<Array<TableHeader<OAuthClientResponseDto>>>(() => [
153
+ {
154
+ title: i18n.baseText('settings.mcp.oAuthClients.table.clientName'),
155
+ key: 'name',
156
+ width: 190,
157
+ disableSort: true,
158
+ value() {
159
+ return;
160
+ },
161
+ },
162
+ ...(ownership.value === 'all'
163
+ ? [
164
+ {
165
+ title: i18n.baseText('settings.mcp.oAuthClients.table.connectedBy'),
166
+ key: 'owner',
167
+ width: 200,
168
+ disableSort: true,
169
+ value() {
170
+ return;
171
+ },
172
+ } satisfies TableHeader<OAuthClientResponseDto>,
173
+ ]
174
+ : []),
175
+ {
176
+ title: i18n.baseText('settings.mcp.oAuthClients.table.access'),
177
+ key: 'scopes',
178
+ disableSort: true,
179
+ value() {
180
+ return;
181
+ },
182
+ },
183
+ {
184
+ title: i18n.baseText('settings.mcp.oAuthClients.table.connectedAt'),
185
+ key: 'grantedAt',
186
+ width: 110,
187
+ disableSort: true,
188
+ value() {
189
+ return;
190
+ },
191
+ },
192
+ {
193
+ title: '',
194
+ key: 'actions',
195
+ align: 'end',
196
+ width: 140,
197
+ disableSort: true,
198
+ value() {
199
+ return;
200
+ },
201
+ },
202
+ ]);
203
+
204
+ function accessSummary(client: OAuthClientResponseDto): string {
205
+ return getAccessSummary(i18n, client, offeredScopes.value);
206
+ }
207
+
208
+ function revokeLabel(client: OAuthClientResponseDto): string {
209
+ return i18n.baseText('settings.mcp.oAuthClients.table.action.revokeAccessFor', {
210
+ interpolate: { name: client.name },
211
+ });
212
+ }
213
+
214
+ function clientTypeLabel(client: OAuthClientResponseDto): string | null {
215
+ const type = getClientBrand(client.name).type;
216
+ if (!type) return null;
217
+ return i18n.baseText(`settings.mcp.oAuthClients.clientType.${type}` as BaseTextKey);
218
+ }
219
+
220
+ function openDetails(item: OAuthClientResponseDto) {
221
+ detailsClient.value = item;
222
+ detailsOpen.value = true;
223
+ }
224
+
225
+ function onRevoke(item: OAuthClientResponseDto) {
226
+ emit('revokeClient', item);
227
+ }
228
+ </script>
229
+
230
+ <template>
231
+ <div data-test-id="oauth-clients-table">
232
+ <div v-if="props.loading">
233
+ <N8nLoading :loading="props.loading" variant="h1" class="mb-l" />
234
+ <N8nLoading :loading="props.loading" variant="p" :rows="5" :shrink-last="false" />
235
+ </div>
236
+ <McpEmptyStateCard
237
+ v-else-if="showEmptyState"
238
+ data-test-id="mcp-clients-empty"
239
+ :title="i18n.baseText('settings.mcp.connectedClients.empty.title')"
240
+ :description="i18n.baseText('settings.mcp.connectedClients.empty.description')"
241
+ />
242
+ <div v-else class="mt-s mb-xl">
243
+ <div :class="$style.toolbar">
244
+ <N8nTabs
245
+ v-if="canManageAllClients"
246
+ :model-value="ownership"
247
+ :options="tabOptions"
248
+ data-test-id="mcp-clients-tabs"
249
+ @update:model-value="onOwnershipChange"
250
+ />
251
+ <div v-else />
252
+ <div :class="$style.filters">
253
+ <N8nInput
254
+ :model-value="searchQuery"
255
+ :placeholder="i18n.baseText('settings.mcp.oAuthClients.search.placeholder')"
256
+ :class="$style.search"
257
+ size="medium"
258
+ clearable
259
+ data-test-id="mcp-clients-search"
260
+ @update:model-value="onSearchInput"
261
+ >
262
+ <template #prefix>
263
+ <N8nIcon icon="search" />
264
+ </template>
265
+ </N8nInput>
266
+ <OAuthClientsFilters
267
+ :model-value="filters"
268
+ :owners="ownerOptions"
269
+ :show-owner-filter="ownership === 'all'"
270
+ :current-user-id="usersStore.currentUser?.id"
271
+ @update:model-value="onFiltersChange"
272
+ />
273
+ </div>
274
+ </div>
275
+ <N8nDataTableServer
276
+ v-model:page="page"
277
+ v-model:items-per-page="itemsPerPage"
278
+ :class="$style.table"
279
+ data-test-id="oauth-clients-data-table"
280
+ :headers="tableHeaders"
281
+ :items="props.clients"
282
+ :items-length="mcpStore.oauthClientsCount"
283
+ :item-value="rowId"
284
+ @click:row="(_, { item }) => openDetails(item)"
285
+ >
286
+ <template v-if="mcpStore.oauthClientsCount === 0" #cover>
287
+ <div :class="$style['empty-state']">
288
+ <N8nText data-test-id="mcp-clients-no-results" size="small" color="text-base">
289
+ {{ i18n.baseText('settings.mcp.oAuthClients.search.noResults') }}
290
+ </N8nText>
291
+ </div>
292
+ </template>
293
+ <template #[`item.name`]="{ item }">
294
+ <div :class="$style.client">
295
+ <span :class="$style['client-icon-chip']">
296
+ <component
297
+ :is="getClientBrand(item.name).icon"
298
+ v-if="getClientBrand(item.name).icon"
299
+ :class="$style['client-icon']"
300
+ />
301
+ <N8nIcon v-else icon="mcp" :class="$style['client-icon']" />
302
+ </span>
303
+ <div :class="$style['client-name']">
304
+ <N8nText data-test-id="mcp-client-name" color="text-dark">
305
+ {{ item.name }}
306
+ </N8nText>
307
+ <N8nText
308
+ v-if="clientTypeLabel(item)"
309
+ data-test-id="mcp-client-type"
310
+ size="xsmall"
311
+ color="text-light"
312
+ >
313
+ {{ clientTypeLabel(item) }}
314
+ </N8nText>
315
+ </div>
316
+ </div>
317
+ </template>
318
+ <template #[`item.owner`]="{ item }">
319
+ <OAuthClientOwnerCell
320
+ v-if="item.owner"
321
+ :owner="item.owner"
322
+ :is-current-user="item.owner.id === usersStore.currentUser?.id"
323
+ />
324
+ </template>
325
+ <template #[`item.scopes`]="{ item }">
326
+ <N8nText data-test-id="mcp-client-access" color="text-light" :class="$style.access">
327
+ {{ accessSummary(item) }}
328
+ </N8nText>
329
+ </template>
330
+ <template #[`item.grantedAt`]="{ item }">
331
+ <N8nText data-test-id="mcp-client-created-at" color="text-base">
332
+ <N8nTimeAgo
333
+ :date="new Date(item.grantedAt).toISOString()"
334
+ capitalize
335
+ :locale="rootStore.defaultLocale"
336
+ />
337
+ </N8nText>
338
+ </template>
339
+ <template #[`item.actions`]="{ item }">
340
+ <div :class="$style['row-actions']">
341
+ <N8nButton
342
+ :class="$style['revoke-action']"
343
+ variant="outline"
344
+ size="small"
345
+ :aria-label="revokeLabel(item)"
346
+ data-test-id="mcp-oauth-client-revoke-button"
347
+ @click.stop="onRevoke(item)"
348
+ >
349
+ {{ i18n.baseText('settings.mcp.oAuthClients.table.action.revokeAccess') }}
350
+ </N8nButton>
351
+ </div>
352
+ </template>
353
+ </N8nDataTableServer>
354
+ </div>
355
+
356
+ <OAuthClientDetailsModal
357
+ v-model:open="detailsOpen"
358
+ :client="detailsClient"
359
+ @revoke="onRevoke"
360
+ />
361
+ </div>
362
+ </template>
363
+
364
+ <style lang="scss" module>
365
+ .header {
366
+ display: flex;
367
+ justify-content: space-between;
368
+ align-items: center;
369
+ }
370
+
371
+ .toolbar {
372
+ display: flex;
373
+ justify-content: space-between;
374
+ align-items: center;
375
+ gap: var(--spacing--sm);
376
+ margin-bottom: var(--spacing--sm);
377
+ }
378
+
379
+ .filters {
380
+ display: flex;
381
+ align-items: center;
382
+ gap: var(--spacing--2xs);
383
+ }
384
+
385
+ .search {
386
+ max-width: 240px;
387
+ }
388
+
389
+ .client {
390
+ display: flex;
391
+ align-items: center;
392
+ gap: var(--spacing--2xs);
393
+ }
394
+
395
+ .client-icon-chip {
396
+ display: inline-flex;
397
+ align-items: center;
398
+ justify-content: center;
399
+ width: var(--spacing--lg);
400
+ height: var(--spacing--lg);
401
+ flex-shrink: 0;
402
+ /* fixed white tile so dark brand marks stay visible on the dark theme */
403
+ background-color: var(--color--neutral-white);
404
+ border: var(--border);
405
+ border-radius: var(--radius);
406
+ }
407
+
408
+ .client-icon {
409
+ width: var(--spacing--sm);
410
+ height: var(--spacing--sm);
411
+ /* the tile is always white, so the fallback MCP glyph must stay dark in both themes */
412
+ color: var(--color--neutral-black);
413
+ }
414
+
415
+ /* Access summary stays on one line and truncates within its column (the fixed
416
+ table layout below gives the cell a real width). The row opens the details modal. */
417
+ .access {
418
+ display: block;
419
+ overflow: hidden;
420
+ white-space: nowrap;
421
+ text-overflow: ellipsis;
422
+ }
423
+
424
+ .access:hover {
425
+ color: var(--color--primary);
426
+ text-decoration: underline;
427
+ cursor: pointer;
428
+ }
429
+
430
+ .client-name {
431
+ display: flex;
432
+ flex-direction: column;
433
+ }
434
+
435
+ .empty-state {
436
+ display: flex;
437
+ flex-direction: column;
438
+ align-items: center;
439
+ justify-content: center;
440
+ gap: var(--spacing--sm);
441
+ padding: var(--spacing--lg) 0;
442
+ min-height: 250px;
443
+ }
444
+
445
+ /* The button is block-level, so the column's `align: end` (text-align) can't push it
446
+ to the table's trailing edge; the flex wrapper does. Same as the API keys table. */
447
+ .row-actions {
448
+ display: flex;
449
+ justify-content: flex-end;
450
+ }
451
+
452
+ /* The whole row opens the details modal, so hint it with a pointer... */
453
+ .table :global(tbody tr) {
454
+ cursor: pointer;
455
+ }
456
+
457
+ /* ...and keep the revoke button out of the way until the row is hovered/focused. */
458
+ .table :global(tbody tr) .revoke-action {
459
+ opacity: 0;
460
+ transition: opacity var(--duration--snappy) var(--easing--ease-out);
461
+ }
462
+
463
+ .table :global(tbody tr:hover) .revoke-action,
464
+ .table :global(tbody tr:focus-within) .revoke-action {
465
+ opacity: 1;
466
+ }
467
+
468
+ @media (prefers-reduced-motion: reduce) {
469
+ .table :global(tbody tr) .revoke-action {
470
+ transition: none;
471
+ }
472
+ }
473
+ </style>