@n8n/frontend-module-mcp 0.2.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@n8n/frontend-module-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
@@ -8,20 +8,20 @@
8
8
  "./mcp.module": "./src/mcp.module.ts"
9
9
  },
10
10
  "dependencies": {
11
- "@n8n/api-types": "1.44.1",
12
- "@n8n/composables": "1.34.1",
13
- "@n8n/design-system": "2.43.1",
14
- "@n8n/frontend-constants": "0.10.0",
15
- "@n8n/frontend-module-sdk": "0.14.1",
16
- "@n8n/i18n": "2.44.1",
17
- "@n8n/permissions": "0.81.0",
18
- "@n8n/rest-api-client": "2.44.1",
19
- "@n8n/stores": "2.44.1",
20
- "@n8n/telemetry": "1.12.1",
21
- "@n8n/utils": "1.51.0",
11
+ "@n8n/api-types": "1.45.0",
12
+ "@n8n/composables": "1.35.0",
13
+ "@n8n/design-system": "2.44.0",
14
+ "@n8n/frontend-constants": "0.11.0",
15
+ "@n8n/frontend-module-sdk": "0.15.0",
16
+ "@n8n/i18n": "2.45.0",
17
+ "@n8n/permissions": "0.82.0",
18
+ "@n8n/rest-api-client": "2.45.0",
19
+ "@n8n/stores": "2.45.0",
20
+ "@n8n/telemetry": "1.13.0",
21
+ "@n8n/utils": "1.52.0",
22
22
  "element-plus": "2.4.3",
23
23
  "lodash": "4.18.1",
24
- "n8n-workflow": "2.44.1",
24
+ "n8n-workflow": "2.45.0",
25
25
  "pinia": "^2.2.4",
26
26
  "reka-ui": "^2.5.0",
27
27
  "vue": "^3.5.13",
@@ -172,7 +172,7 @@ onMounted(async () => {
172
172
  />
173
173
  <N8nTooltip :content="i18n.baseText('settings.mcp.refresh.tooltip')">
174
174
  <N8nButton
175
- variant="subtle"
175
+ variant="outline"
176
176
  icon-only
177
177
  data-test-id="mcp-agents-refresh-button"
178
178
  size="small"
@@ -192,7 +192,7 @@ onMounted(async () => {
192
192
  />
193
193
  <N8nTooltip :content="i18n.baseText('settings.mcp.refresh.tooltip')">
194
194
  <N8nButton
195
- variant="subtle"
195
+ variant="outline"
196
196
  icon-only
197
197
  data-test-id="mcp-workflows-refresh-button"
198
198
  size="small"
@@ -72,7 +72,7 @@ const handleCopy = async (value: string) => {
72
72
  >
73
73
  <N8nButton
74
74
  v-if="props.allowCopy && isSupported"
75
- variant="subtle"
75
+ variant="outline"
76
76
  icon-only
77
77
  :icon="copied ? 'check' : 'copy'"
78
78
  :class="$style['copy-button']"
@@ -171,18 +171,7 @@ const handleCopy = async (value: string) => {
171
171
 
172
172
  button {
173
173
  height: auto;
174
- border: none;
175
174
  border-radius: 0;
176
- box-shadow: none;
177
-
178
- &:hover {
179
- border-color: inherit;
180
- box-shadow: none;
181
- }
182
-
183
- &:active {
184
- box-shadow: none;
185
- }
186
175
  }
187
176
 
188
177
  button + button {
@@ -207,7 +196,6 @@ const handleCopy = async (value: string) => {
207
196
  }
208
197
 
209
198
  .copy-button {
210
- border: none;
211
199
  border-radius: 0;
212
200
  }
213
201
  </style>
@@ -0,0 +1,36 @@
1
+ import userEvent from '@testing-library/user-event';
2
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
3
+ import MCPEmptyState from './MCPEmptyState.vue';
4
+
5
+ const renderComponent = createComponentRenderer(MCPEmptyState);
6
+
7
+ describe('MCPEmptyState', () => {
8
+ it('should render the design-system empty state with the MCP icon cards and both actions', () => {
9
+ const { getByTestId, container } = renderComponent();
10
+
11
+ const emptyState = getByTestId('mcp-empty-state-container');
12
+ expect(emptyState).toHaveClass('n8n-empty-state');
13
+ expect(emptyState).toHaveTextContent('Connect AI assistants to build and run workflows');
14
+ // The centre card carries the MCP mark; the side cards render the client brand marks.
15
+ expect(container.querySelector('[data-icon="mcp"]')).toBeInTheDocument();
16
+ expect(getByTestId('mcp-empty-state-learn-more')).toHaveAttribute('target', '_blank');
17
+ expect(getByTestId('enable-mcp-access-button')).toBeEnabled();
18
+ });
19
+
20
+ it('should emit turnOnMcp when the enable button is clicked', async () => {
21
+ const { getByTestId, emitted } = renderComponent();
22
+
23
+ await userEvent.click(getByTestId('enable-mcp-access-button'));
24
+
25
+ expect(emitted('turnOnMcp')).toHaveLength(1);
26
+ });
27
+
28
+ it.each([
29
+ ['disabled', { disabled: true }],
30
+ ['loading', { loading: true }],
31
+ ])('should disable the enable button when %s', (_, props) => {
32
+ const { getByTestId } = renderComponent({ props });
33
+
34
+ expect(getByTestId('enable-mcp-access-button')).toBeDisabled();
35
+ });
36
+ });
@@ -1,9 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
- import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
4
- import { MCP_DOCS_PAGE_URL } from '../mcp.constants';
3
+ import { N8nButton, N8nEmptyState, N8nIcon, N8nTooltip } from '@n8n/design-system';
4
+ import type { EmptyStateIconCards } from '@n8n/design-system';
5
5
  import { useI18n } from '@n8n/i18n';
6
- import McpEmptyStateCard from './McpEmptyStateCard.vue';
6
+
7
+ import { MCP_CLIENT_LOGO_CYCLE } from '../mcp.clients.catalog';
8
+ import { MCP_DOCS_PAGE_URL } from '../mcp.constants';
7
9
 
8
10
  type Props = {
9
11
  disabled?: boolean;
@@ -24,52 +26,78 @@ const emit = defineEmits<{
24
26
  const i18n = useI18n();
25
27
 
26
28
  const buttonDisabled = computed(() => props.disabled || props.loading);
29
+
30
+ // The MCP mark, flanked by side cards cycling through the client brand marks.
31
+ const emptyStateIcon: EmptyStateIconCards = {
32
+ type: 'cards',
33
+ center: 'mcp',
34
+ sides: MCP_CLIENT_LOGO_CYCLE,
35
+ };
27
36
  </script>
28
37
 
29
38
  <template>
30
- <McpEmptyStateCard
31
- surface
32
- :class="$style.card"
39
+ <N8nEmptyState
40
+ :class="$style.emptyState"
33
41
  data-test-id="mcp-empty-state-container"
34
- :title="i18n.baseText('settings.mcp.actionBox.heading')"
42
+ :icon="emptyStateIcon"
43
+ :heading="i18n.baseText('settings.mcp.actionBox.heading')"
35
44
  :description="i18n.baseText('settings.mcp.emptyState.description')"
36
45
  >
37
- <template #actions>
38
- <N8nButton
39
- variant="ghost"
40
- size="medium"
41
- :href="MCP_DOCS_PAGE_URL"
42
- target="_blank"
43
- data-test-id="mcp-empty-state-learn-more"
44
- >
45
- {{ i18n.baseText('generic.learnMore') }} <N8nIcon icon="arrow-up-right" size="small" />
46
- </N8nButton>
47
- <N8nTooltip :disabled="!buttonDisabled">
48
- <template #content>
49
- <span v-if="props.loading">{{ i18n.baseText('generic.loading') }}...</span>
50
- <span v-else-if="props.managedByEnv">
51
- {{ i18n.baseText('settings.mcp.managedByEnv.tooltip') }}
52
- </span>
53
- <span v-else>
54
- {{ i18n.baseText('settings.mcp.toggle.disabled.tooltip') }}
55
- </span>
56
- </template>
46
+ <template #additionalContent>
47
+ <div :class="$style.actions">
57
48
  <N8nButton
58
- variant="solid"
49
+ variant="ghost"
59
50
  size="medium"
60
- :disabled="buttonDisabled"
61
- data-test-id="enable-mcp-access-button"
62
- @click="emit('turnOnMcp')"
51
+ :href="MCP_DOCS_PAGE_URL"
52
+ target="_blank"
53
+ data-test-id="mcp-empty-state-learn-more"
63
54
  >
64
- {{ i18n.baseText('settings.mcp.actionBox.button.label') }}
55
+ {{ i18n.baseText('generic.learnMore') }}
56
+ <N8nIcon icon="arrow-up-right" size="small" />
65
57
  </N8nButton>
66
- </N8nTooltip>
58
+ <N8nTooltip :disabled="!buttonDisabled">
59
+ <template #content>
60
+ <span v-if="props.loading">{{ i18n.baseText('generic.loading') }}...</span>
61
+ <span v-else-if="props.managedByEnv">
62
+ {{ i18n.baseText('settings.mcp.managedByEnv.tooltip') }}
63
+ </span>
64
+ <span v-else>
65
+ {{ i18n.baseText('settings.mcp.toggle.disabled.tooltip') }}
66
+ </span>
67
+ </template>
68
+ <N8nButton
69
+ variant="solid"
70
+ size="medium"
71
+ :disabled="buttonDisabled"
72
+ data-test-id="enable-mcp-access-button"
73
+ @click="emit('turnOnMcp')"
74
+ >
75
+ {{ i18n.baseText('settings.mcp.actionBox.button.label') }}
76
+ </N8nButton>
77
+ </N8nTooltip>
78
+ </div>
67
79
  </template>
68
- </McpEmptyStateCard>
80
+ </N8nEmptyState>
69
81
  </template>
70
82
 
71
83
  <style lang="scss" module>
72
- .card {
84
+ @use '@n8n/design-system/css/mixins/motion';
85
+
86
+ .emptyState {
73
87
  margin-top: var(--spacing--xl);
88
+ /* The DS card is transparent by default; keep the page's surface so it reads as a panel. */
89
+ background: var(--background--surface);
90
+ /* Fixed white tiles so the dark brand marks stay visible on the dark theme, as in the client rows. */
91
+ --empty-state-icon-cards--tile-background: var(--color--neutral-white);
92
+ --empty-state-icon-cards--tile-color: var(--color--neutral-black);
93
+ /* Gentle entrance for the enable/disable swap (empty state ⇄ full page). */
94
+ @include motion.fade-in-up;
95
+ }
96
+
97
+ .actions {
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ gap: var(--spacing--2xs);
74
102
  }
75
103
  </style>
@@ -130,7 +130,7 @@ onMounted(async () => {
130
130
  :show-after="MCP_TOOLTIP_DELAY"
131
131
  >
132
132
  <N8nButton
133
- variant="subtle"
133
+ variant="outline"
134
134
  icon-only
135
135
  icon="refresh-cw"
136
136
  :disabled="keyRotating"
@@ -1,134 +1,21 @@
1
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';
2
+ import { N8nEmptyStateIconCards } from '@n8n/design-system';
5
3
 
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
- });
4
+ import { MCP_CLIENT_LOGO_CYCLE } from '../mcp.clients.catalog';
51
5
  </script>
52
6
 
53
7
  <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>
8
+ <N8nEmptyStateIconCards
9
+ :class="$style.cards"
10
+ center-icon="mcp"
11
+ :side-icons="MCP_CLIENT_LOGO_CYCLE"
12
+ />
71
13
  </template>
72
14
 
73
15
  <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
- }
16
+ .cards {
17
+ /* Fixed white tiles so the dark brand marks stay visible on the dark theme, as in the client rows. */
18
+ --empty-state-icon-cards--tile-background: var(--color--neutral-white);
19
+ --empty-state-icon-cards--tile-color: var(--color--neutral-black);
133
20
  }
134
21
  </style>
@@ -424,7 +424,7 @@ const serverUrlDescription = computed(() =>
424
424
  display: block;
425
425
  padding-block: var(--spacing--5xs);
426
426
  font-size: var(--font-size--3xs);
427
- font-weight: var(--font-weight--bold);
427
+ font-weight: var(--font-weight--medium);
428
428
  letter-spacing: 0.06em;
429
429
  text-transform: uppercase;
430
430
  color: var(--color--text--tint-1);
@@ -6,16 +6,14 @@ withDefaults(
6
6
  defineProps<{
7
7
  title: string;
8
8
  description: string;
9
- /** White surface instead of the page background (for the standalone disabled state). */
10
- surface?: boolean;
11
9
  dataTestId?: string;
12
10
  }>(),
13
- { surface: false, dataTestId: undefined },
11
+ { dataTestId: undefined },
14
12
  );
15
13
  </script>
16
14
 
17
15
  <template>
18
- <div :class="[$style.card, surface && $style.surface]" :data-test-id="dataTestId">
16
+ <div :class="$style.card" :data-test-id="dataTestId">
19
17
  <McpClientLogoCards :class="$style.cards" />
20
18
  <div :class="$style.copy">
21
19
  <N8nText bold size="large" color="text-dark">{{ title }}</N8nText>
@@ -28,6 +26,8 @@ withDefaults(
28
26
  </template>
29
27
 
30
28
  <style lang="scss" module>
29
+ @use '@n8n/design-system/css/mixins/motion';
30
+
31
31
  .card {
32
32
  display: flex;
33
33
  flex-direction: column;
@@ -38,29 +38,7 @@ withDefaults(
38
38
  border: var(--border-width) dashed var(--border-color);
39
39
  border-radius: var(--radius--lg);
40
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
- }
41
+ @include motion.fade-in-up;
64
42
  }
65
43
 
66
44
  .cards {
@@ -116,7 +116,7 @@ function onRevoke() {
116
116
 
117
117
  <N8nDialogFooter>
118
118
  <N8nButton
119
- variant="subtle"
119
+ variant="outline"
120
120
  data-test-id="mcp-client-details-close"
121
121
  @click="emit('update:open', false)"
122
122
  >
@@ -237,7 +237,7 @@ const projectLink = (agent: McpAgent) =>
237
237
  >
238
238
  <template #actions>
239
239
  <N8nButton
240
- variant="subtle"
240
+ variant="outline"
241
241
  data-test-id="mcp-bulk-remove-agent-access-button"
242
242
  :label="i18n.baseText('settings.mcp.agents.table.action.removeMCPAccess')"
243
243
  @click="onBulkRemoveMcpAccess"
@@ -291,7 +291,7 @@ const onConnectClick = () => {
291
291
  >
292
292
  <template #actions>
293
293
  <N8nButton
294
- variant="subtle"
294
+ variant="outline"
295
295
  data-test-id="mcp-bulk-remove-access-button"
296
296
  :label="i18n.baseText('settings.mcp.workflows.table.action.removeMCPAccess')"
297
297
  @click="onBulkRemoveMcpAccess"
@@ -1,4 +1,4 @@
1
- import type { Component } from 'vue';
1
+ import { markRaw, type Component } from 'vue';
2
2
 
3
3
  import AntigravityIcon from './assets/client-icons/antigravity.svg?component';
4
4
  import ClaudeIcon from './assets/client-icons/claude.svg?component';
@@ -10,6 +10,20 @@ import MistralIcon from './assets/client-icons/mistral.svg?component';
10
10
  import OpenAiIcon from './assets/client-icons/openai.svg?component';
11
11
  import VsCodeIcon from './assets/client-icons/vscode.svg?component';
12
12
 
13
+ /**
14
+ * Brand marks the decorative empty-state cards cycle through, interleaved so consecutive
15
+ * marks read clearly different (coral wordmark, blue ribbon, purple blossom, near-black
16
+ * cube, gradient spark, black blossom).
17
+ */
18
+ export const MCP_CLIENT_LOGO_CYCLE: Component[] = [
19
+ ClaudeIcon,
20
+ VsCodeIcon,
21
+ CodexIcon,
22
+ CursorIcon,
23
+ GeminiIcon,
24
+ OpenAiIcon,
25
+ ].map((icon) => markRaw(icon));
26
+
13
27
  export type McpSetupCategory = 'cli' | 'web' | 'ide';
14
28
 
15
29
  export interface McpClientSetup {
@@ -9,6 +9,7 @@ export const MCP_SCOPE_GROUPS: Array<{ key: string; resources: readonly string[]
9
9
  { key: 'workflows', resources: ['workflow', 'tag'] },
10
10
  { key: 'executions', resources: ['execution'] },
11
11
  { key: 'agents', resources: ['agent'] },
12
+ { key: 'skills', resources: ['skill'] },
12
13
  { key: 'credentials', resources: ['credential'] },
13
14
  { key: 'dataTables', resources: ['dataTable'] },
14
15
  { key: 'projectsAndFolders', resources: ['project'] },
@@ -21,6 +22,7 @@ export const MCP_SCOPE_RESOURCE_ICONS: Record<string, IconName> = {
21
22
  workflow: 'workflow',
22
23
  execution: 'history',
23
24
  agent: 'robot',
25
+ skill: 'graduation-cap',
24
26
  credential: 'key-round',
25
27
  dataTable: 'table',
26
28
  project: 'folder',
@@ -91,7 +91,7 @@ function preventOutsideClose(event: Event) {
91
91
  </div>
92
92
  <N8nDialogFooter>
93
93
  <N8nButton
94
- variant="subtle"
94
+ variant="outline"
95
95
  :label="i18n.baseText('generic.cancel')"
96
96
  :disabled="isSaving"
97
97
  data-test-id="mcp-connect-agents-cancel-button"
@@ -82,7 +82,7 @@ function preventOutsideClose(event: Event) {
82
82
  />
83
83
  <N8nDialogFooter>
84
84
  <N8nButton
85
- variant="subtle"
85
+ variant="outline"
86
86
  :label="i18n.baseText('generic.cancel')"
87
87
  :disabled="isSaving"
88
88
  data-test-id="mcp-connect-workflows-cancel-button"