fix(editor): Address MCP re-design feedback (no-changelog) (#23077)

This commit is contained in:
Milorad FIlipović
2025-12-12 16:51:48 +01:00
committed by GitHub
parent 6ac5ee72b5
commit 11a68a8b18
10 changed files with 107 additions and 35 deletions
@@ -2372,13 +2372,13 @@
"settings.communityNodes.confirmModal.update.buttonLabel": "Confirm update",
"settings.communityNodes.confirmModal.update.buttonLoadingLabel": "Updating...",
"settings.communityNodes.confirmModal.cancel": "Cancel",
"settings.mcp": "MCP Server",
"settings.mcp": "Instance-level MCP",
"settings.mcp.description": "Let MCP clients like Claude, Lovable, and other AI tools discover and execute your n8n workflows",
"settings.mcp.header.toggle.enabled": "Enabled",
"settings.mcp.header.toggle.disabled": "Disabled",
"settings.mcp.actionBox.heading": "Connect AI assistants to your workflows",
"settings.mcp.actionBox.button.label": "Enable MCP server",
"settings.mcp.actionBox.button.label": "Enable MCP access",
"settings.mcp.emptyState.docs.part1": "Read our docs to",
"settings.mcp.toggle.disabled.notice": "Let MCP clients like Claude, Lovable, and other AI tools discover and execute your n8n workflows.",
"settings.mcp.tabs.workflows": "Workflows",
"settings.mcp.tabs.oauth": "Connected clients",
"settings.mcp.access.token.notice": "Make sure to copy your access token, you won't be able to see it again",
@@ -2393,6 +2393,7 @@
"settings.mcp.workflows.table.empty.button.label": "Find workflows",
"settings.mcp.oauth.table.empty.title": "No OAuth clients connected",
"settings.mcp.oauth.table.empty.description": "Clients that connect via OAuth will show up here",
"settings.mcp.oauth.table.empty.button": "See connection instructions",
"settings.mcp.toggle.disabled.tooltip": "Only instance admins can change this",
"settings.mcp.toggle.error": "Error updating MCP access",
"settings.mcp.instructions.rotateKey.tooltip": "Generate new token.<br/>This one will be automatically revoked",
@@ -8,12 +8,15 @@ import { computed, onMounted, ref } from 'vue';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useUIStore } from '@/app/stores/ui.store';
import { LOADING_INDICATOR_TIMEOUT } from '@/features/ai/mcpAccess/mcp.constants';
import {
LOADING_INDICATOR_TIMEOUT,
MCP_DOCS_PAGE_URL,
} from '@/features/ai/mcpAccess/mcp.constants';
import MCPEmptyState from '@/features/ai/mcpAccess/components/MCPEmptyState.vue';
import MCpHeaderActions from '@/features/ai/mcpAccess/components/header/MCPHeaderActions.vue';
import WorkflowsTable from '@/features/ai/mcpAccess/components/tabs/WorkflowsTable.vue';
import OAuthClientsTable from '@/features/ai/mcpAccess/components/tabs/OAuthClientsTable.vue';
import { N8nHeading, N8nTabs, N8nTooltip, N8nButton } from '@n8n/design-system';
import { N8nHeading, N8nTabs, N8nTooltip, N8nButton, N8nText, N8nLink } from '@n8n/design-system';
import type { TabOptions } from '@n8n/design-system';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import type { OAuthClientResponseDto } from '@n8n/api-types';
@@ -179,7 +182,27 @@ const revokeClientAccess = async (client: OAuthClientResponseDto) => {
<template>
<div :class="$style.container">
<header :class="$style['main-header']" data-test-id="mcp-settings-header">
<N8nHeading size="2xlarge" class="mb-2xs">{{ i18n.baseText('settings.mcp') }}</N8nHeading>
<div :class="$style.headings">
<N8nHeading size="2xlarge" class="mb-2xs">{{ i18n.baseText('settings.mcp') }}</N8nHeading>
<div
v-show="mcpStore.mcpAccessEnabled"
:class="$style.description"
data-test-id="mcp-settings-description"
>
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.mcp.description') }}.
</N8nText>
<N8nLink
:href="MCP_DOCS_PAGE_URL"
target="_blank"
rel="noopener noreferrer"
size="small"
data-test-id="mcp-docs-link"
>
{{ i18n.baseText('generic.learnMore') }}
</N8nLink>
</div>
</div>
<MCpHeaderActions
:access-enabled="mcpStore.mcpAccessEnabled"
:toggle-disabled="!canToggleMCP"
@@ -243,7 +266,25 @@ const revokeClientAccess = async (client: OAuthClientResponseDto) => {
.main-header {
display: flex;
justify-content: space-between;
margin-bottom: var(--spacing--lg);
margin-bottom: var(--spacing--xl);
@media (max-width: 820px) {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--2xs);
}
}
.headings {
display: flex;
flex-direction: column;
min-height: 60px;
.description {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
}
.tabs-header {
@@ -26,7 +26,7 @@ const i18n = useI18n();
<N8nActionBox
:icon="{ type: 'icon', value: 'mcp' }"
:heading="i18n.baseText('settings.mcp.actionBox.heading')"
:description="i18n.baseText('settings.mcp.toggle.disabled.notice')"
:description="i18n.baseText('settings.mcp.description')"
:button-text="i18n.baseText('settings.mcp.actionBox.button.label')"
:button-disabled="props.disabled || props.loading"
button-variant="primary"
@@ -1,9 +1,6 @@
<script setup lang="ts">
import { MCP_DOCS_PAGE_URL } from '@/features/ai/mcpAccess/mcp.constants';
import ExternalLink from '@n8n/design-system/components/N8nExternalLink/ExternalLink.vue';
import MCPAccessToggle from '@/features/ai/mcpAccess/components/header/McpAccessToggle.vue';
import McpConnectPopover from '@/features/ai/mcpAccess/components/header/connectPopover/McpConnectPopover.vue';
import { useI18n } from '@n8n/i18n';
type Props = {
toggleDisabled?: boolean;
@@ -19,13 +16,10 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
disableMcpAccess: [];
}>();
const i18n = useI18n();
</script>
<template>
<div :class="$style.container" data-test-id="mcp-header-actions">
<ExternalLink :href="MCP_DOCS_PAGE_URL">{{ i18n.baseText('generic.docs') }}</ExternalLink>
<MCPAccessToggle
:model-value="accessEnabled"
:disabled="props.toggleDisabled"
@@ -41,7 +35,8 @@ const i18n = useI18n();
.container {
display: flex;
gap: var(--spacing--md);
align-items: center;
align-items: baseline;
padding-top: var(--spacing--5xs);
// Docs link doesn't have outline when not hovered
// so we pull it closer to the rest of the elements
@@ -28,7 +28,7 @@ const onUpdateMCPEnabled = () => {
<template>
<div :class="$style['main-toggle-container']">
<div :class="$style['main-toggle-info']">
<N8nText :bold="true" :color="modelValue ? `success` : `text-light`">
<N8nText :bold="true" :color="modelValue ? `success` : `text-light`" size="small">
{{
modelValue
? i18n.baseText('settings.mcp.header.toggle.enabled')
@@ -2,8 +2,9 @@ import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import McpConnectPopover from './McpConnectPopover.vue';
vi.mock('@/app/composables/useTelemetry', () => ({
@@ -12,14 +13,6 @@ vi.mock('@/app/composables/useTelemetry', () => ({
}),
}));
const mockResetCurrentUserMCPKey = vi.fn();
vi.mock('@/features/ai/mcpAccess/mcp.store', () => ({
useMCPStore: () => ({
resetCurrentUserMCPKey: mockResetCurrentUserMCPKey,
}),
}));
vi.mock('./MCPOAuthPopoverTab.vue', () => ({
default: {
name: 'MCPOAuthPopoverTab',
@@ -36,17 +29,23 @@ vi.mock('./MCPAccessTokenPopoverTab.vue', () => ({
},
}));
let pinia: ReturnType<typeof createTestingPinia>;
let rootStore: ReturnType<typeof mockedStore<typeof useRootStore>>;
let mcpStore: MockedStore<typeof useMCPStore>;
let renderComponent: ReturnType<typeof createComponentRenderer>;
describe('McpConnectPopover', () => {
beforeEach(() => {
pinia = createTestingPinia({ stubActions: false });
renderComponent = createComponentRenderer(McpConnectPopover, {
pinia: createTestingPinia(),
pinia,
});
rootStore = mockedStore(useRootStore);
rootStore.urlBaseEditor = 'http://localhost:5678/';
mcpStore = mockedStore(useMCPStore);
});
afterEach(() => {
@@ -106,7 +105,7 @@ describe('McpConnectPopover', () => {
await user.keyboard('{Escape}');
await waitFor(() => {
expect(mockResetCurrentUserMCPKey).toHaveBeenCalled();
expect(mcpStore.resetCurrentUserMCPKey).toHaveBeenCalled();
});
});
});
@@ -18,8 +18,6 @@ const props = defineProps<{
disabled?: boolean;
}>();
const popoverOpen = ref(false);
const TABS = {
ACCESS_TOKEN: 'accessToken',
OAUTH: 'oauth',
@@ -35,8 +33,10 @@ const serverUrl = ref(`${rootStore.urlBaseEditor}${MCP_ENDPOINT}`);
const activeTab = ref(tabItems.value[0].value);
const handlePopoverOpenChange = (isOpen: boolean) => {
popoverOpen.value = isOpen;
if (!isOpen) {
if (isOpen) {
mcpStore.openConnectPopover();
} else {
mcpStore.closeConnectPopover();
mcpStore.resetCurrentUserMCPKey();
}
};
@@ -69,7 +69,7 @@ watch(
() => props.disabled,
(newValue) => {
if (!newValue) {
popoverOpen.value = true;
mcpStore.openConnectPopover();
}
},
);
@@ -79,7 +79,7 @@ watch(
<div>
<N8nPopoverReka
:id="'mcp-connect-popover'"
:open="popoverOpen"
:open="mcpStore.connectPopoverOpen"
:popper-options="{ strategy: 'fixed' }"
:content-class="$style.popper"
:show-arrow="false"
@@ -1,5 +1,6 @@
import { within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import OAuthClientsTable from '@/features/ai/mcpAccess/components/tabs/OAuthClientsTable.vue';
import type { OAuthClientResponseDto } from '@n8n/api-types';
@@ -12,7 +13,15 @@ vi.mock('@/app/components/TimeAgo.vue', () => ({
},
}));
const createComponent = createComponentRenderer(OAuthClientsTable);
vi.mock('@/features/ai/mcpAccess/mcp.store', () => ({
useMCPStore: () => ({
openConnectPopover: vi.fn(),
}),
}));
const createComponent = createComponentRenderer(OAuthClientsTable, {
pinia: createTestingPinia(),
});
const createClient = (overrides: Partial<OAuthClientResponseDto> = {}): OAuthClientResponseDto => ({
id: 'client-1',
@@ -2,12 +2,20 @@
import { useI18n } from '@n8n/i18n';
import type { OAuthClientResponseDto } from '@n8n/api-types';
import type { UserAction } from '@/Interface';
import { N8nActionToggle, N8nDataTableServer, N8nLoading, N8nText } from '@n8n/design-system';
import {
N8nActionToggle,
N8nButton,
N8nDataTableServer,
N8nLoading,
N8nText,
} from '@n8n/design-system';
import { ref } from 'vue';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServer';
import TimeAgo from '@/app/components/TimeAgo.vue';
const i18n = useI18n();
const mcpStore = useMCPStore();
type Props = {
clients: OAuthClientResponseDto[];
@@ -90,6 +98,13 @@ const onTableAction = (action: string, item: OAuthClientResponseDto) => {
>
{{ i18n.baseText('settings.mcp.oauth.table.empty.description') }}
</N8nText>
<N8nButton
data-test-id="mcp-oauth-create-client-button"
variant="primary"
@click="mcpStore.openConnectPopover()"
>
{{ i18n.baseText('settings.mcp.oauth.table.empty.button') }}
</N8nButton>
</div>
</template>
<template #[`item.name`]="{ item }">
@@ -23,6 +23,7 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
const currentUserMCPKey = ref<ApiKey | null>(null);
const oauthClients = ref<OAuthClientResponseDto[]>([]);
const connectPopoverOpen = ref(false);
const mcpAccessEnabled = computed(() => !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled);
@@ -117,6 +118,14 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
return response;
}
function openConnectPopover(): void {
connectPopoverOpen.value = true;
}
function closeConnectPopover(): void {
connectPopoverOpen.value = false;
}
return {
mcpAccessEnabled,
fetchWorkflowsAvailableForMCP,
@@ -129,5 +138,8 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
oauthClients,
getAllOAuthClients,
removeOAuthClient,
connectPopoverOpen,
openConnectPopover,
closeConnectPopover,
};
});