@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,83 @@
1
+ <script setup lang="ts">
2
+ import { N8nText } from '@n8n/design-system';
3
+ import McpClientLogoCards from './McpClientLogoCards.vue';
4
+
5
+ withDefaults(
6
+ defineProps<{
7
+ title: string;
8
+ description: string;
9
+ /** White surface instead of the page background (for the standalone disabled state). */
10
+ surface?: boolean;
11
+ dataTestId?: string;
12
+ }>(),
13
+ { surface: false, dataTestId: undefined },
14
+ );
15
+ </script>
16
+
17
+ <template>
18
+ <div :class="[$style.card, surface && $style.surface]" :data-test-id="dataTestId">
19
+ <McpClientLogoCards :class="$style.cards" />
20
+ <div :class="$style.copy">
21
+ <N8nText bold size="large" color="text-dark">{{ title }}</N8nText>
22
+ <N8nText size="small" color="text-light">{{ description }}</N8nText>
23
+ </div>
24
+ <div v-if="$slots.actions" :class="$style.actions">
25
+ <slot name="actions" />
26
+ </div>
27
+ </div>
28
+ </template>
29
+
30
+ <style lang="scss" module>
31
+ .card {
32
+ display: flex;
33
+ flex-direction: column;
34
+ align-items: center;
35
+ text-align: center;
36
+ gap: var(--spacing--sm);
37
+ padding: var(--spacing--2xl) var(--spacing--xl);
38
+ border: var(--border-width) dashed var(--border-color);
39
+ border-radius: var(--radius--lg);
40
+ /* Gentle entrance, matching the prototype's empty states. */
41
+ animation: mcp-reveal-in var(--duration--snappy) var(--easing--ease-out);
42
+ }
43
+
44
+ .surface {
45
+ background: var(--background--surface);
46
+ }
47
+
48
+ @keyframes mcp-reveal-in {
49
+ from {
50
+ opacity: 0;
51
+ transform: translateY(var(--spacing--2xs));
52
+ }
53
+
54
+ to {
55
+ opacity: 1;
56
+ transform: translateY(0);
57
+ }
58
+ }
59
+
60
+ @media (prefers-reduced-motion: reduce) {
61
+ .card {
62
+ animation: none;
63
+ }
64
+ }
65
+
66
+ .cards {
67
+ margin-bottom: var(--spacing--sm);
68
+ }
69
+
70
+ .copy {
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: var(--spacing--3xs);
74
+ max-width: 32rem;
75
+ }
76
+
77
+ .actions {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ gap: var(--spacing--2xs);
82
+ }
83
+ </style>
@@ -0,0 +1,33 @@
1
+ import { within, waitFor } from '@testing-library/vue';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
4
+ import McpStatusControl from './McpStatusControl.vue';
5
+
6
+ const renderComponent = createComponentRenderer(McpStatusControl);
7
+
8
+ describe('McpStatusControl', () => {
9
+ it('should render the Enabled trigger', () => {
10
+ const { getByTestId } = renderComponent();
11
+
12
+ expect(getByTestId('mcp-status-control-trigger')).toHaveTextContent('Enabled');
13
+ });
14
+
15
+ it('should emit disable when the menu item is selected', async () => {
16
+ const { getByTestId, emitted } = renderComponent();
17
+
18
+ await userEvent.click(getByTestId('mcp-status-control-trigger'));
19
+ // The dropdown menu teleports to document.body.
20
+ await waitFor(() => {
21
+ expect(within(document.body).getByText('Disable')).toBeInTheDocument();
22
+ });
23
+ await userEvent.click(within(document.body).getByText('Disable'));
24
+
25
+ expect(emitted('disable')).toHaveLength(1);
26
+ });
27
+
28
+ it('should disable the trigger when the user cannot toggle MCP', () => {
29
+ const { getByTestId } = renderComponent({ props: { disabled: true } });
30
+
31
+ expect(getByTestId('mcp-status-control-trigger')).toBeDisabled();
32
+ });
33
+ });
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import { N8nButton, N8nDropdownMenu, N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
5
+
6
+ const props = withDefaults(
7
+ defineProps<{
8
+ /** User cannot toggle MCP (member, or setting managed by env). */
9
+ disabled?: boolean;
10
+ loading?: boolean;
11
+ managedByEnv?: boolean;
12
+ }>(),
13
+ { disabled: false, loading: false, managedByEnv: false },
14
+ );
15
+
16
+ const emit = defineEmits<{ disable: [] }>();
17
+
18
+ const i18n = useI18n();
19
+
20
+ const disabledTooltip = computed(() =>
21
+ props.managedByEnv
22
+ ? i18n.baseText('settings.mcp.managedByEnv.tooltip')
23
+ : i18n.baseText('settings.mcp.toggle.disabled.tooltip'),
24
+ );
25
+
26
+ // The leading icon is rendered by the #item-leading slot below, so the item
27
+ // itself carries no icon.
28
+ const menuItems = computed(() => [
29
+ {
30
+ id: 'disable',
31
+ label: i18n.baseText('settings.mcp.status.disable'),
32
+ },
33
+ ]);
34
+
35
+ const onSelect = (id: string) => {
36
+ if (id === 'disable') emit('disable');
37
+ };
38
+ </script>
39
+
40
+ <template>
41
+ <N8nTooltip :content="disabledTooltip" :disabled="!props.disabled" placement="top">
42
+ <N8nDropdownMenu
43
+ :items="menuItems"
44
+ placement="bottom-end"
45
+ :disabled="props.disabled || props.loading"
46
+ data-test-id="mcp-status-control"
47
+ @select="onSelect"
48
+ >
49
+ <template #trigger>
50
+ <N8nButton
51
+ variant="outline"
52
+ size="medium"
53
+ :disabled="props.disabled"
54
+ :loading="props.loading"
55
+ :aria-label="i18n.baseText('settings.mcp.status.ariaLabel')"
56
+ data-test-id="mcp-status-control-trigger"
57
+ >
58
+ <span :class="$style.dot" />
59
+ {{ i18n.baseText('settings.mcp.header.toggle.enabled') }}
60
+ <N8nIcon icon="chevron-down" size="small" />
61
+ </N8nButton>
62
+ </template>
63
+ <template #item-leading>
64
+ <N8nIcon icon="power" size="large" color="danger" />
65
+ </template>
66
+ <template #item-label="{ item }">
67
+ <N8nText size="medium" color="danger" data-test-id="mcp-status-disable-item">
68
+ {{ item.label }}
69
+ </N8nText>
70
+ </template>
71
+ </N8nDropdownMenu>
72
+ </N8nTooltip>
73
+ </template>
74
+
75
+ <style lang="scss" module>
76
+ .dot {
77
+ width: 8px;
78
+ height: 8px;
79
+ flex-shrink: 0;
80
+ border-radius: 50%;
81
+ background-color: var(--color--success);
82
+ animation: mcp-status-pulse 2s ease-in-out infinite;
83
+ }
84
+
85
+ @keyframes mcp-status-pulse {
86
+ 50% {
87
+ opacity: 0.4;
88
+ }
89
+ }
90
+
91
+ @media (prefers-reduced-motion: reduce) {
92
+ .dot {
93
+ animation: none;
94
+ }
95
+ }
96
+ </style>
@@ -0,0 +1,182 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+
4
+ import type { OAuthClientResponseDto } from '@n8n/api-types';
5
+ import {
6
+ N8nButton,
7
+ N8nDialog,
8
+ N8nDialogDescription,
9
+ N8nDialogFooter,
10
+ N8nDialogHeader,
11
+ N8nDialogTitle,
12
+ N8nIcon,
13
+ N8nText,
14
+ N8nTimeAgo,
15
+ } from '@n8n/design-system';
16
+ import { useI18n } from '@n8n/i18n';
17
+ import type { BaseTextKey } from '@n8n/i18n';
18
+ import { useRootStore } from '@n8n/stores/useRootStore';
19
+
20
+ import { getClientBrand, scopeLabel } from '../clients.utils';
21
+
22
+ const props = defineProps<{
23
+ client: OAuthClientResponseDto | null;
24
+ open: boolean;
25
+ }>();
26
+
27
+ const emit = defineEmits<{
28
+ 'update:open': [value: boolean];
29
+ revoke: [client: OAuthClientResponseDto];
30
+ }>();
31
+
32
+ const i18n = useI18n();
33
+ const rootStore = useRootStore();
34
+
35
+ const brand = computed(() => (props.client ? getClientBrand(props.client.name) : null));
36
+
37
+ const ownerLabel = computed(() => {
38
+ const owner = props.client?.owner;
39
+ if (!owner) return null;
40
+ const name = [owner.firstName, owner.lastName].filter(Boolean).join(' ');
41
+ return name ? `${name} (${owner.email})` : owner.email;
42
+ });
43
+
44
+ const subtitle = computed(() => {
45
+ const type = brand.value?.type;
46
+ if (!type) return i18n.baseText('settings.mcp.oAuthClients.details.subtitle');
47
+ return i18n.baseText('settings.mcp.oAuthClients.details.subtitleWithType', {
48
+ interpolate: {
49
+ type: i18n.baseText(`settings.mcp.oAuthClients.clientType.${type}` as BaseTextKey),
50
+ },
51
+ });
52
+ });
53
+
54
+ /** Granted scopes as human labels, listed plainly in grant order. */
55
+ const grantedScopes = computed(() => props.client?.scopes ?? []);
56
+
57
+ function onRevoke() {
58
+ if (!props.client) return;
59
+ emit('revoke', props.client);
60
+ emit('update:open', false);
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <N8nDialog :open="open" size="medium" @update:open="emit('update:open', $event)">
66
+ <div v-if="client" :class="$style.container" data-test-id="mcp-client-details-modal">
67
+ <N8nDialogHeader>
68
+ <N8nDialogTitle>
69
+ <span :class="$style.title">
70
+ <span :class="$style['icon-chip']">
71
+ <component :is="brand.icon" v-if="brand?.icon" :class="$style.icon" />
72
+ <N8nIcon v-else icon="mcp" :class="$style.icon" />
73
+ </span>
74
+ {{ client.name }}
75
+ </span>
76
+ </N8nDialogTitle>
77
+ <N8nDialogDescription>{{ subtitle }}</N8nDialogDescription>
78
+ </N8nDialogHeader>
79
+
80
+ <div :class="$style.details">
81
+ <template v-if="ownerLabel">
82
+ <N8nText color="text-light" size="small">
83
+ {{ i18n.baseText('settings.mcp.oAuthClients.details.connectedBy') }}
84
+ </N8nText>
85
+ <N8nText color="text-dark" size="small" data-test-id="mcp-client-details-connected-by">
86
+ {{ ownerLabel }}
87
+ </N8nText>
88
+ </template>
89
+
90
+ <N8nText color="text-light" size="small">
91
+ {{ i18n.baseText('settings.mcp.oAuthClients.details.connectedOn') }}
92
+ </N8nText>
93
+ <N8nText color="text-dark" size="small" data-test-id="mcp-client-details-connected-on">
94
+ <N8nTimeAgo
95
+ :date="new Date(client.grantedAt).toISOString()"
96
+ capitalize
97
+ :locale="rootStore.defaultLocale"
98
+ />
99
+ </N8nText>
100
+
101
+ <N8nText color="text-light" size="small">
102
+ {{ i18n.baseText('settings.mcp.oAuthClients.details.access') }}
103
+ </N8nText>
104
+ <div :class="$style.access" data-test-id="mcp-client-details-access">
105
+ <N8nText
106
+ v-for="scope in grantedScopes"
107
+ :key="scope"
108
+ color="text-dark"
109
+ size="small"
110
+ :data-test-id="`mcp-client-details-scope-${scope}`"
111
+ >
112
+ {{ scopeLabel(i18n, scope) }}
113
+ </N8nText>
114
+ </div>
115
+ </div>
116
+
117
+ <N8nDialogFooter>
118
+ <N8nButton
119
+ variant="subtle"
120
+ data-test-id="mcp-client-details-close"
121
+ @click="emit('update:open', false)"
122
+ >
123
+ {{ i18n.baseText('generic.close') }}
124
+ </N8nButton>
125
+ <N8nButton variant="destructive" data-test-id="mcp-client-details-revoke" @click="onRevoke">
126
+ {{ i18n.baseText('settings.mcp.oAuthClients.table.action.revokeAccess') }}
127
+ </N8nButton>
128
+ </N8nDialogFooter>
129
+ </div>
130
+ </N8nDialog>
131
+ </template>
132
+
133
+ <style lang="scss" module>
134
+ .container {
135
+ display: flex;
136
+ flex-direction: column;
137
+ gap: var(--spacing--sm);
138
+ }
139
+
140
+ .title {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ gap: var(--spacing--2xs);
144
+ }
145
+
146
+ .icon-chip {
147
+ display: inline-flex;
148
+ align-items: center;
149
+ justify-content: center;
150
+ width: var(--spacing--xl);
151
+ height: var(--spacing--xl);
152
+ flex-shrink: 0;
153
+ /* fixed white tile so dark brand marks stay visible on the dark theme */
154
+ background-color: var(--color--neutral-white);
155
+ border: var(--border);
156
+ border-radius: var(--radius);
157
+ }
158
+
159
+ .icon {
160
+ width: var(--spacing--md);
161
+ height: var(--spacing--md);
162
+ /* the tile is always white, so the fallback MCP glyph must stay dark in both themes */
163
+ color: var(--color--neutral-black);
164
+ }
165
+
166
+ .details {
167
+ display: grid;
168
+ grid-template-columns: minmax(120px, auto) 1fr;
169
+ column-gap: var(--spacing--lg);
170
+ row-gap: var(--spacing--xs);
171
+ align-items: start;
172
+ max-height: 60vh;
173
+ overflow-y: auto;
174
+ }
175
+
176
+ .access {
177
+ display: flex;
178
+ flex-direction: column;
179
+ gap: var(--spacing--3xs);
180
+ min-width: 0;
181
+ }
182
+ </style>
@@ -0,0 +1,60 @@
1
+ <script lang="ts" setup>
2
+ import { computed } from 'vue';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import type { OAuthClientResponseDto } from '@n8n/api-types';
5
+ import { N8nAlertDialog } from '@n8n/design-system';
6
+
7
+ const props = defineProps<{
8
+ client: OAuthClientResponseDto | null;
9
+ open: boolean;
10
+ loading?: boolean;
11
+ /** When the caller is not the consent owner — admin revoking another user's client. */
12
+ revokingForOther?: boolean;
13
+ }>();
14
+
15
+ const emit = defineEmits<{
16
+ 'update:open': [value: boolean];
17
+ confirm: [];
18
+ cancel: [];
19
+ }>();
20
+
21
+ const i18n = useI18n();
22
+
23
+ const title = computed(() =>
24
+ props.client
25
+ ? i18n.baseText('settings.mcp.oAuthClients.revoke.title', {
26
+ interpolate: { name: props.client.name },
27
+ })
28
+ : '',
29
+ );
30
+
31
+ const description = computed(() => {
32
+ if (!props.client) return '';
33
+ if (props.revokingForOther) {
34
+ const owner = props.client.owner;
35
+ const ownerName =
36
+ [owner?.firstName, owner?.lastName].filter(Boolean).join(' ') || owner?.email || '';
37
+ return i18n.baseText('settings.mcp.oAuthClients.revoke.description.other', {
38
+ interpolate: { ownerName },
39
+ });
40
+ }
41
+ return i18n.baseText('settings.mcp.oAuthClients.revoke.description.own');
42
+ });
43
+ </script>
44
+
45
+ <template>
46
+ <N8nAlertDialog
47
+ :open="open"
48
+ :title="title"
49
+ :description="description"
50
+ :action-label="i18n.baseText('settings.mcp.oAuthClients.revoke.button')"
51
+ :cancel-label="i18n.baseText('generic.cancel')"
52
+ action-variant="destructive"
53
+ :loading="loading"
54
+ size="medium"
55
+ data-test-id="mcp-client-revoke-confirm"
56
+ @action="emit('confirm')"
57
+ @cancel="emit('cancel')"
58
+ @update:open="emit('update:open', $event)"
59
+ />
60
+ </template>