@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,124 @@
1
+ import type { Component } from 'vue';
2
+
3
+ import { MCP_CLIENT_BRAND_MATCHERS, MCP_INSTANCE_SCOPES } from '@n8n/api-types';
4
+ import type {
5
+ McpClientBrandName,
6
+ McpClientConnectedPeriod,
7
+ McpClientType,
8
+ McpClientTypeFilter,
9
+ OAuthClientResponseDto,
10
+ } from '@n8n/api-types';
11
+ import type { BaseTextKey, I18nClass } from '@n8n/i18n';
12
+
13
+ import ClaudeIcon from './assets/client-icons/claude.svg?component';
14
+ import CursorIcon from './assets/client-icons/cursor.svg?component';
15
+ import MistralIcon from './assets/client-icons/mistral.svg?component';
16
+ import OpenAiIcon from './assets/client-icons/openai.svg?component';
17
+ import VsCodeIcon from './assets/client-icons/vscode.svg?component';
18
+
19
+ export interface McpClientBrand {
20
+ icon: Component | null;
21
+ type: McpClientType | null;
22
+ }
23
+
24
+ /** Logos for the brands recognized by the shared name-pattern matchers. */
25
+ const BRAND_ICONS: Record<McpClientBrandName, Component> = {
26
+ claude: ClaudeIcon,
27
+ cursor: CursorIcon,
28
+ vscode: VsCodeIcon,
29
+ openai: OpenAiIcon,
30
+ mistral: MistralIcon,
31
+ };
32
+
33
+ // Client names are bounded (a user's own registered clients), so memoizing the
34
+ // regex scan by name is safe; the map would need eviction only if that set ever
35
+ // became unbounded.
36
+ const brandCache = new Map<string, McpClientBrand>();
37
+
38
+ export function getClientBrand(clientName: string): McpClientBrand {
39
+ let brand = brandCache.get(clientName);
40
+ if (!brand) {
41
+ const match = MCP_CLIENT_BRAND_MATCHERS.find(({ pattern }) => pattern.test(clientName));
42
+ brand = match
43
+ ? { icon: BRAND_ICONS[match.brand], type: match.type }
44
+ : { icon: null, type: null };
45
+ brandCache.set(clientName, brand);
46
+ }
47
+ return brand;
48
+ }
49
+
50
+ /**
51
+ * i18n key suffix for a granted scope's human label, e.g. `workflow:read` →
52
+ * `workflow.read`. Unknown scopes have no label and are rendered verbatim.
53
+ */
54
+ export function scopeLabelKeySuffix(scope: string): string {
55
+ return scope.replace(':', '.');
56
+ }
57
+
58
+ /**
59
+ * Human label for a granted scope. Unknown scopes have no i18n entry and render
60
+ * verbatim.
61
+ */
62
+ export function scopeLabel(
63
+ i18n: { baseText: (key: BaseTextKey) => string },
64
+ scope: string,
65
+ ): string {
66
+ const key = `settings.mcp.oAuthClients.scope.${scopeLabelKeySuffix(scope)}` as BaseTextKey;
67
+ const label = i18n.baseText(key);
68
+ return label === key ? scope : label;
69
+ }
70
+
71
+ /**
72
+ * Whether a grant covers every scope the instance currently offers.
73
+ * Older grants need fresh consent for scopes introduced after they were made.
74
+ *
75
+ * `offeredScopes` comes from the backend, which drops scopes this instance
76
+ * cannot serve (agent scopes when the agents module is off). Without it a
77
+ * full grant on such an instance would never count as full access.
78
+ */
79
+ export function isFullAccessGrant(scopes: string[], offeredScopes?: string[]): boolean {
80
+ const required = offeredScopes?.length ? offeredScopes : MCP_INSTANCE_SCOPES;
81
+ return scopes.length > 0 && required.every((scope) => scopes.includes(scope));
82
+ }
83
+
84
+ /** Scopes spelled out in a one-line grant summary before the rest collapse into "+N". */
85
+ const ACCESS_SUMMARY_VISIBLE_SCOPES = 2;
86
+
87
+ /**
88
+ * One-line summary of a client's grant for list rows: "Full access", "No access",
89
+ * or the first two scope labels followed by a "+N" overflow.
90
+ */
91
+ export function getAccessSummary(
92
+ i18n: Pick<I18nClass, 'baseText'>,
93
+ client: Pick<OAuthClientResponseDto, 'scopes'>,
94
+ offeredScopes?: string[],
95
+ ): string {
96
+ if (client.scopes.length === 0) return i18n.baseText('settings.mcp.oAuthClients.access.none');
97
+ if (isFullAccessGrant(client.scopes, offeredScopes)) {
98
+ return i18n.baseText('settings.mcp.oAuthClients.access.full');
99
+ }
100
+ const visible = client.scopes
101
+ .slice(0, ACCESS_SUMMARY_VISIBLE_SCOPES)
102
+ .map((scope) => scopeLabel(i18n, scope))
103
+ .join(', ');
104
+ const remaining = client.scopes.length - ACCESS_SUMMARY_VISIBLE_SCOPES;
105
+ if (remaining <= 0) return visible;
106
+ return `${visible} ${i18n.baseText('settings.mcp.oAuthClients.scope.more', {
107
+ interpolate: { count: remaining },
108
+ })}`;
109
+ }
110
+
111
+ /** UI state of the connected-clients search + filter popover; applied server-side. */
112
+ export interface OAuthClientFilters {
113
+ search: string;
114
+ type: McpClientTypeFilter | null;
115
+ ownerId: string | null;
116
+ connected: McpClientConnectedPeriod | null;
117
+ }
118
+
119
+ export const EMPTY_OAUTH_CLIENT_FILTERS: OAuthClientFilters = {
120
+ search: '',
121
+ type: null,
122
+ ownerId: null,
123
+ connected: null,
124
+ };
@@ -0,0 +1,213 @@
1
+ <script setup lang="ts">
2
+ import { useI18n } from '@n8n/i18n';
3
+ import { useClipboard } from '@n8n/composables/useClipboard';
4
+ import { N8nButton, N8nTooltip, N8nInfoTip, N8nInput, N8nLoading } from '@n8n/design-system';
5
+ import { MCP_TOOLTIP_DELAY } from '../mcp.constants';
6
+
7
+ type Props = {
8
+ id: string;
9
+ label: string;
10
+ value: string;
11
+ valueLoading?: boolean;
12
+ infoTip?: string;
13
+ allowCopy?: boolean;
14
+ };
15
+
16
+ const { copy, copied, isSupported } = useClipboard();
17
+ const i18n = useI18n();
18
+
19
+ const props = withDefaults(defineProps<Props>(), {
20
+ allowCopy: true,
21
+ maxWidth: undefined,
22
+ infoTip: undefined,
23
+ valueLoading: false,
24
+ });
25
+
26
+ const emit = defineEmits<{
27
+ copy: [value: string];
28
+ }>();
29
+
30
+ const handleCopy = async (value: string) => {
31
+ await copy(value);
32
+ emit('copy', value);
33
+ };
34
+ </script>
35
+
36
+ <template>
37
+ <div :class="$style.container">
38
+ <div :class="$style['label-wrapper']">
39
+ <label :class="$style.label" :for="`connection-parameter-${props.id}`">
40
+ {{ props.label }}
41
+ </label>
42
+ <div v-if="props.infoTip" :class="$style['info-tip']">
43
+ <N8nInfoTip type="tooltip" size="small" :enterable="false">
44
+ {{ props.infoTip }}
45
+ </N8nInfoTip>
46
+ </div>
47
+ </div>
48
+ <div
49
+ :id="`connection-parameter-${props.id}`"
50
+ :class="{
51
+ [$style['parameter-value']]: true,
52
+ [$style['parameter-value--loading']]: props.valueLoading,
53
+ }"
54
+ data-test-id="connection-parameter-value"
55
+ >
56
+ <div :class="$style['input-wrapper']">
57
+ <N8nLoading
58
+ v-if="props.valueLoading"
59
+ :loading="props.valueLoading"
60
+ variant="h1"
61
+ :class="$style['parameter-skeleton']"
62
+ />
63
+ <N8nInput v-else v-model="props.value" type="text" :readonly="true" />
64
+ </div>
65
+ <div :class="$style['copy-button-wrapper']">
66
+ <slot name="customActions" />
67
+ <N8nTooltip
68
+ :disabled="!isSupported"
69
+ :content="copied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy')"
70
+ :show-after="MCP_TOOLTIP_DELAY"
71
+ placement="bottom"
72
+ >
73
+ <N8nButton
74
+ v-if="props.allowCopy && isSupported"
75
+ variant="subtle"
76
+ icon-only
77
+ :icon="copied ? 'check' : 'copy'"
78
+ :class="$style['copy-button']"
79
+ :disabled="props.valueLoading"
80
+ @click="handleCopy(props.value)"
81
+ />
82
+ </N8nTooltip>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </template>
87
+
88
+ <style module lang="scss">
89
+ .container {
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: var(--spacing--4xs);
93
+ }
94
+
95
+ .label-wrapper {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: var(--spacing--4xs);
99
+
100
+ .info-tip {
101
+ visibility: hidden;
102
+ cursor: pointer;
103
+ padding-top: 1px;
104
+
105
+ :global(.n8n-info-tip) {
106
+ display: flex;
107
+ }
108
+ }
109
+
110
+ &:hover .info-tip span {
111
+ visibility: visible;
112
+ }
113
+ }
114
+
115
+ .label {
116
+ font-size: var(--font-size--sm);
117
+ color: var(--color--text--shade-1);
118
+ }
119
+
120
+ .parameter-value {
121
+ display: flex;
122
+ align-items: stretch;
123
+ justify-content: space-between;
124
+ font-size: var(--font-size--2xs);
125
+ color: var(--color--text);
126
+ gap: var(--spacing--2xs);
127
+ border: var(--border);
128
+ border-radius: var(--radius);
129
+ overflow: hidden;
130
+
131
+ &--loading {
132
+ gap: 0;
133
+ }
134
+
135
+ .input-wrapper {
136
+ flex: 1;
137
+ min-width: 0;
138
+ display: flex;
139
+ align-items: center;
140
+
141
+ :global(.n8n-input__wrapper) {
142
+ --input--border--shadow: 0 0 0 0 transparent;
143
+ --input--border--shadow--hover: 0 0 0 0 transparent;
144
+ --input--border--shadow--focus: 0 0 0 0 transparent;
145
+
146
+ &:focus-within {
147
+ outline: none;
148
+ }
149
+ }
150
+ }
151
+
152
+ :global(.n8n-input) {
153
+ flex: 1;
154
+ align-items: center;
155
+
156
+ input {
157
+ font-family: monospace;
158
+ font-size: var(--font-size--2xs);
159
+ border: none;
160
+ color: var(--color--text);
161
+ min-height: var(--spacing--md);
162
+ height: var(--spacing--md);
163
+ }
164
+ }
165
+
166
+ .parameter-skeleton div {
167
+ margin: 0;
168
+ height: calc(var(--spacing--xl) - 2 * var(--border-width));
169
+ border-radius: 0 0 var(--radius) var(--radius);
170
+ }
171
+
172
+ button {
173
+ height: auto;
174
+ border: none;
175
+ 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
+ }
187
+
188
+ button + button {
189
+ border-left: var(--border);
190
+ }
191
+
192
+ @media screen and (max-width: 820px) {
193
+ word-wrap: break-word;
194
+ margin-top: var(--spacing--2xs);
195
+ }
196
+ }
197
+
198
+ .copy-button-wrapper {
199
+ display: flex;
200
+ align-items: stretch;
201
+ border-left: var(--border);
202
+
203
+ // Tooltip triggers wrap each button in a span, which must stretch it too.
204
+ > span {
205
+ display: flex;
206
+ }
207
+ }
208
+
209
+ .copy-button {
210
+ border: none;
211
+ border-radius: 0;
212
+ }
213
+ </style>
@@ -0,0 +1,176 @@
1
+ <script setup lang="ts">
2
+ import { useMCPStore } from '../mcp.store';
3
+ import { LOADING_INDICATOR_TIMEOUT } from '../mcp.constants';
4
+ import { N8nSelect, N8nOption, N8nText } from '@n8n/design-system';
5
+ import { computed, onMounted, ref, useCssModule } from 'vue';
6
+ import type { McpAgent } from '../mcp.types';
7
+ import { useI18n } from '@n8n/i18n';
8
+ import { useToast } from '@n8n/composables/useToast';
9
+ import { sleep } from '@n8n/utils/sleep';
10
+
11
+ defineProps<{
12
+ placeholder?: string;
13
+ disabled?: boolean;
14
+ }>();
15
+
16
+ const i18n = useI18n();
17
+ const toast = useToast();
18
+
19
+ const modelValue = defineModel<string[]>({ default: () => [] });
20
+
21
+ const emit = defineEmits<{
22
+ ready: [];
23
+ confirm: [];
24
+ }>();
25
+
26
+ const mcpStore = useMCPStore();
27
+
28
+ const isLoading = ref(false);
29
+ const hasFetched = ref(false);
30
+ const isDropdownVisible = ref(false);
31
+ const selectRef = ref<InstanceType<typeof N8nSelect>>();
32
+ const agentOptions = ref<McpAgent[]>([]);
33
+ let loadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
34
+
35
+ const showEmptyState = computed(() => {
36
+ return !isLoading.value && hasFetched.value && agentOptions.value.length === 0;
37
+ });
38
+
39
+ const $style = useCssModule();
40
+
41
+ // `popper-class` is a string prop, so the classes are joined here rather than
42
+ // passed as an object binding.
43
+ const popperClass = computed(() =>
44
+ [
45
+ isLoading.value ? $style['mcp-agents-select-loading'] : '',
46
+ showEmptyState.value ? $style['mcp-agents-select-empty'] : '',
47
+ ]
48
+ .filter(Boolean)
49
+ .join(' '),
50
+ );
51
+
52
+ const projectName = (agent: McpAgent) =>
53
+ agent.project?.type === 'personal'
54
+ ? i18n.baseText('projects.menu.personal')
55
+ : (agent.project?.name ?? '');
56
+
57
+ async function searchAgents(query?: string) {
58
+ if (loadingTimeoutId) {
59
+ clearTimeout(loadingTimeoutId);
60
+ loadingTimeoutId = null;
61
+ }
62
+ isLoading.value = true;
63
+ hasFetched.value = false;
64
+ try {
65
+ const response = await mcpStore.getMcpEligibleAgents({
66
+ take: 10,
67
+ query: query ?? undefined,
68
+ });
69
+ agentOptions.value = response?.data ?? [];
70
+ } catch (e) {
71
+ toast.showError(e, i18n.baseText('settings.mcp.connectAgents.error'));
72
+ } finally {
73
+ await sleep(LOADING_INDICATOR_TIMEOUT);
74
+ isLoading.value = false;
75
+ hasFetched.value = true;
76
+ }
77
+ }
78
+
79
+ function focusOnInput() {
80
+ selectRef.value?.focusOnInput();
81
+ }
82
+
83
+ function onVisibleChange(visible: boolean) {
84
+ isDropdownVisible.value = visible;
85
+ }
86
+
87
+ function onKeydownCapture(event: KeyboardEvent) {
88
+ if (event.key === 'Enter' && !isDropdownVisible.value && modelValue.value.length > 0) {
89
+ event.preventDefault();
90
+ event.stopPropagation();
91
+ emit('confirm');
92
+ }
93
+ }
94
+
95
+ onMounted(async () => {
96
+ await searchAgents();
97
+ emit('ready');
98
+ });
99
+
100
+ defineExpose({
101
+ focusOnInput,
102
+ });
103
+ </script>
104
+
105
+ <template>
106
+ <div @keydown.enter.capture="onKeydownCapture">
107
+ <N8nSelect
108
+ ref="selectRef"
109
+ v-model="modelValue"
110
+ data-test-id="mcp-agents-select"
111
+ :placeholder="placeholder"
112
+ :disabled="disabled"
113
+ :loading="isLoading"
114
+ :multiple="true"
115
+ :filterable="true"
116
+ :remote="true"
117
+ :remote-method="searchAgents"
118
+ size="medium"
119
+ :popper-class="popperClass"
120
+ :teleported="false"
121
+ @visible-change="onVisibleChange"
122
+ >
123
+ <N8nOption v-if="showEmptyState" value="" disabled :class="$style['empty-option']">
124
+ {{ i18n.baseText('settings.mcp.connectAgents.emptyState') }}
125
+ </N8nOption>
126
+ <N8nOption
127
+ v-for="agent in agentOptions"
128
+ :key="agent.id"
129
+ :value="agent.id"
130
+ :label="agent.name"
131
+ >
132
+ <div :class="$style.option">
133
+ <N8nText :class="$style.truncate">{{ projectName(agent) }}</N8nText>
134
+ <span :class="$style.separator">/</span>
135
+ <N8nText :class="$style.truncate" color="text-dark">{{ agent.name }}</N8nText>
136
+ </div>
137
+ </N8nOption>
138
+ </N8nSelect>
139
+ </div>
140
+ </template>
141
+
142
+ <style module lang="scss">
143
+ .mcp-agents-select-loading,
144
+ .mcp-agents-select-empty {
145
+ display: flex;
146
+ min-height: var(--spacing--5xl);
147
+ align-items: center;
148
+ justify-content: space-around;
149
+ }
150
+
151
+ .empty-option {
152
+ cursor: default !important;
153
+ color: var(--color--text) !important;
154
+ }
155
+
156
+ .option {
157
+ display: flex;
158
+ align-items: center;
159
+ gap: var(--spacing--4xs);
160
+ min-width: 0;
161
+ overflow: hidden;
162
+ }
163
+
164
+ .separator {
165
+ user-select: none;
166
+ color: var(--color--text--tint-1);
167
+ }
168
+
169
+ .truncate {
170
+ display: block;
171
+ overflow: hidden;
172
+ text-overflow: ellipsis;
173
+ white-space: nowrap;
174
+ min-width: 0;
175
+ }
176
+ </style>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
4
+ import { MCP_DOCS_PAGE_URL } from '../mcp.constants';
5
+ import { useI18n } from '@n8n/i18n';
6
+ import McpEmptyStateCard from './McpEmptyStateCard.vue';
7
+
8
+ type Props = {
9
+ disabled?: boolean;
10
+ loading?: boolean;
11
+ managedByEnv?: boolean;
12
+ };
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
15
+ disabled: false,
16
+ loading: false,
17
+ managedByEnv: false,
18
+ });
19
+
20
+ const emit = defineEmits<{
21
+ turnOnMcp: [];
22
+ }>();
23
+
24
+ const i18n = useI18n();
25
+
26
+ const buttonDisabled = computed(() => props.disabled || props.loading);
27
+ </script>
28
+
29
+ <template>
30
+ <McpEmptyStateCard
31
+ surface
32
+ :class="$style.card"
33
+ data-test-id="mcp-empty-state-container"
34
+ :title="i18n.baseText('settings.mcp.actionBox.heading')"
35
+ :description="i18n.baseText('settings.mcp.emptyState.description')"
36
+ >
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>
57
+ <N8nButton
58
+ variant="solid"
59
+ size="medium"
60
+ :disabled="buttonDisabled"
61
+ data-test-id="enable-mcp-access-button"
62
+ @click="emit('turnOnMcp')"
63
+ >
64
+ {{ i18n.baseText('settings.mcp.actionBox.button.label') }}
65
+ </N8nButton>
66
+ </N8nTooltip>
67
+ </template>
68
+ </McpEmptyStateCard>
69
+ </template>
70
+
71
+ <style lang="scss" module>
72
+ .card {
73
+ margin-top: var(--spacing--xl);
74
+ }
75
+ </style>