mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-24 23:48:42 +08:00
fix(editor): Address MCP re-design feedback (no-changelog) (#23077)
This commit is contained in:
@@ -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"
|
||||
|
||||
+2
-7
@@ -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
|
||||
|
||||
+1
-1
@@ -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')
|
||||
|
||||
+10
-11
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
-6
@@ -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"
|
||||
|
||||
+10
-1
@@ -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',
|
||||
|
||||
+16
-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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user