From ceb561b87bcdaaa943aa410f03c6b70d7e9156c2 Mon Sep 17 00:00:00 2001 From: Romeo Balta <7095569+romeobalta@users.noreply.github.com> Date: Wed, 13 May 2026 19:53:51 +0100 Subject: [PATCH] fix(editor): Prioritize MCP tile in empty state (no-changelog) (#30418) Co-authored-by: Codex Co-authored-by: OpenCode --- .../frontend/@n8n/i18n/src/locales/en.json | 9 ++--- .../layouts/EmptyStateLayout.test.ts | 35 +++++++++++++++++-- .../components/layouts/EmptyStateLayout.vue | 7 +--- .../MCPOnboardingClientSetup.test.ts | 4 +-- .../onboarding/MCPOnboardingModal.test.ts | 31 ++++++++++------ .../onboarding/MCPOnboardingModal.vue | 27 ++++++++++++++ 6 files changed, 88 insertions(+), 25 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 1c0bf82ca48..1d8f99cfcc5 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2946,14 +2946,15 @@ "experiments.surfaceMcpToNewCloudUsers.onboarding.intro.settingsLink": "Settings > Instance-level MCP", "experiments.surfaceMcpToNewCloudUsers.onboarding.intro.tryIt": "Try MCP", "experiments.surfaceMcpToNewCloudUsers.onboarding.intro.skip": "Skip for now", - "experiments.surfaceMcpToNewCloudUsers.onboarding.prompt.claude": "Find the official n8n connector and show it in this chat.", - "experiments.surfaceMcpToNewCloudUsers.onboarding.prompt.claudeCode": "Set up the n8n MCP server for Claude Code globally (available in all projects).\n\n1. Register the MCP server at user scope by running:\n\n claude mcp add --scope user --transport http n8n \"{serverUrl}\"\n\n2. When Claude Code asks you to authenticate, complete the n8n OAuth flow.\n\n3. When you finish, tell me to restart Claude Code. Don't try to verify the server in this session.", - "experiments.surfaceMcpToNewCloudUsers.onboarding.prompt.cursor": "Set up the n8n MCP server for Cursor globally.\n\n1. Add the following to ~/.cursor/mcp.json (create the file and directory if they do not exist; if the file already exists, merge the n8n server entry without removing existing config):\n\n {'{'}\n \"mcpServers\": {'{'}\n \"n8n\": {'{'}\n \"url\": \"{serverUrl}\"\n {'}'}\n {'}'}\n {'}'}\n\n2. When Cursor asks you to authenticate, complete the n8n OAuth flow.\n\n3. When you finish, tell me to restart Cursor. Don't try to verify the server in this session.", - "experiments.surfaceMcpToNewCloudUsers.onboarding.prompt.codex": "Set up the n8n MCP server for Codex globally.\n\n1. Add the following to ~/.codex/config.toml (create the file and directory if they do not exist; if the file already exists, append the section without removing existing config):\n\n [mcp_servers.n8n]\n url = \"{serverUrl}\"\n\n2. When Codex asks you to authenticate, complete the n8n OAuth flow.\n\n3. When you finish, tell me to restart Codex. Don't try to verify the server in this session.", + "experiments.surfaceMcpToNewCloudUsers.onboarding.prompt.claude": "Show me the n8n MCP connector and walk me through enabling it if it isn't already on. I'll paste my server URL when you tell me where it goes.", + "experiments.surfaceMcpToNewCloudUsers.onboarding.prompt.claudeCode": "Set up the n8n MCP server for Claude Code so it's available across all projects.\n\n1. Register the server at user scope by running:\n\n claude mcp add --scope user --transport http n8n \"{serverUrl}\"\n\n2. Once it's registered, remind me to restart Claude Code — I'll complete the n8n OAuth flow when prompted in the new session, so don't try to list or call its tools yet.", + "experiments.surfaceMcpToNewCloudUsers.onboarding.prompt.cursor": "Set up the n8n MCP server for Cursor across all projects.\n\n1. Open ~/.cursor/mcp.json (create the file and directory if they don't exist). Add the entry below, merging with any existing config so other servers stay intact:\n\n {'{'}\n \"mcpServers\": {'{'}\n \"n8n\": {'{'}\n \"url\": \"{serverUrl}\"\n {'}'}\n {'}'}\n {'}'}\n\n2. Once it's saved, remind me to restart Cursor and complete the n8n OAuth flow when it prompts me — the server's tools won't be available until then.", + "experiments.surfaceMcpToNewCloudUsers.onboarding.prompt.codex": "Set up the n8n MCP server for Codex across all projects.\n\n1. Open ~/.codex/config.toml (create the file and directory if they don't exist). Append the section below, leaving any existing config in place:\n\n [mcp_servers.n8n]\n url = \"{serverUrl}\"\n\n2. Once it's saved, remind me to restart Codex and complete the n8n OAuth flow in my browser when it prompts me — the server's tools won't be available until then.", "experiments.surfaceMcpToNewCloudUsers.onboarding.section.client.title": "Choose your assistant", "experiments.surfaceMcpToNewCloudUsers.onboarding.section.access.title": "Enable MCP access", "experiments.surfaceMcpToNewCloudUsers.onboarding.section.prompt.title": "Paste the prompt in {assistant}", "experiments.surfaceMcpToNewCloudUsers.onboarding.section.serverUrl.title": "Paste Server URL", + "experiments.surfaceMcpToNewCloudUsers.onboarding.section.restart.title": "Restart {assistant} and connect to n8n", "experiments.surfaceMcpToNewCloudUsers.onboarding.section.chatgptDeveloperMode.title": "Enable developer mode in ChatGPT", "experiments.surfaceMcpToNewCloudUsers.onboarding.section.chatgptDeveloperMode.description": "In ChatGPT, go to Settings > Apps & Connectors > Advanced settings. Turn on developer mode.", "experiments.surfaceMcpToNewCloudUsers.onboarding.section.chatgptCustomApp.title": "Create a custom app", diff --git a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts index 495c1eb8408..2f78b2f5c59 100644 --- a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts +++ b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts @@ -6,6 +6,7 @@ import { useUsersStore } from '@/features/settings/users/users.store'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; import { useRecommendedTemplatesStore } from '@/features/workflows/templates/recommendations/recommendedTemplates.store'; +import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store'; import { useBannersStore } from '@/features/shared/banners/banners.store'; import userEvent from '@testing-library/user-event'; import type { IUser } from '@n8n/rest-api-client/api/users'; @@ -28,9 +29,15 @@ vi.mock('vue-router', () => ({ }, })); -vi.mock('@/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState', () => ({ - useSurfaceMcpEmptyState: vi.fn(() => surfaceMcpEmptyState), -})); +vi.mock('@/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState', async () => { + const { computed } = await import('vue'); + return { + useSurfaceMcpEmptyState: vi.fn(() => ({ + showTile: computed(() => surfaceMcpEmptyState.showTile), + showReminder: computed(() => surfaceMcpEmptyState.showReminder), + })), + }; +}); const renderComponent = createComponentRenderer(EmptyStateLayout, { pinia: createTestingPinia(), @@ -59,6 +66,7 @@ describe('EmptyStateLayout', () => { let recommendedTemplatesStore: ReturnType< typeof mockedStore >; + let readyToRunStore: ReturnType>; let bannersStore: ReturnType>; beforeEach(() => { @@ -66,6 +74,7 @@ describe('EmptyStateLayout', () => { projectsStore = mockedStore(useProjectsStore); sourceControlStore = mockedStore(useSourceControlStore); recommendedTemplatesStore = mockedStore(useRecommendedTemplatesStore); + readyToRunStore = mockedStore(useReadyToRunStore); bannersStore = mockedStore(useBannersStore); usersStore.currentUser = { @@ -91,6 +100,7 @@ describe('EmptyStateLayout', () => { } as unknown as ReturnType['preferences']; bannersStore.bannersHeight = 0; + readyToRunStore.userCanClaimOpenAiCredits = false; surfaceMcpEmptyState.showTile = false; surfaceMcpEmptyState.showReminder = false; @@ -193,6 +203,25 @@ describe('EmptyStateLayout', () => { expect(getByTestId('mcp-onboarding-reminder')).toBeInTheDocument(); }); + it('should render ready-to-run card when user can claim OpenAI credits and MCP tile is hidden', () => { + readyToRunStore.userCanClaimOpenAiCredits = true; + + const { getByTestId } = renderComponent(); + + expect(getByTestId('ready-to-run-card')).toBeInTheDocument(); + }); + + it('should hide ready-to-run card when Surface MCP tile is shown', () => { + readyToRunStore.userCanClaimOpenAiCredits = true; + surfaceMcpEmptyState.showTile = true; + + const { queryByTestId, getByTestId } = renderComponent(); + + expect(queryByTestId('ready-to-run-card')).not.toBeInTheDocument(); + expect(getByTestId('mcp-onboarding-card')).toBeInTheDocument(); + expect(getByTestId('new-workflow-card')).toBeInTheDocument(); + }); + it('should emit click:add event when workflow card is clicked', async () => { const { getByTestId, emitted } = renderComponent(); diff --git a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue index e07587d0716..f6b82332746 100644 --- a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue +++ b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue @@ -54,7 +54,7 @@ const addWorkflow = () => { // Check if user can claim credits for ready-to-run const showReadyToRunCard = computed(() => { - return readyToRunStore.userCanClaimOpenAiCredits && canCreateWorkflow.value; + return readyToRunStore.userCanClaimOpenAiCredits && canCreateWorkflow.value && !showMcpTile.value; }); const handleReadyToRunClick = async () => { @@ -176,7 +176,6 @@ const handleAppSelectionContinue = () => { $style.actionCardsContainer, { [$style.singleCard]: !showReadyToRunCard && !showMcpTile, - [$style.threeCards]: showReadyToRunCard && showMcpTile, }, ]" > @@ -297,10 +296,6 @@ const handleAppSelectionContinue = () => { grid-template-columns: 192px; } - &.threeCards { - grid-template-columns: repeat(3, 192px); - } - @media (max-width: vars.$breakpoint-xs) { grid-template-columns: 1fr; gap: var(--spacing--md); diff --git a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingClientSetup.test.ts b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingClientSetup.test.ts index de8871ec73c..e7e35c82eda 100644 --- a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingClientSetup.test.ts +++ b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingClientSetup.test.ts @@ -40,8 +40,8 @@ describe('MCPOnboardingClientSetup', () => { }); const text = container.textContent ?? ''; - expect(text).toContain('Find the official n8n connector and show it in this chat.'); - expect(text).not.toContain("When it's ready, ask me for the server URL."); + expect(text).toContain('Show me the n8n MCP connector'); + expect(text).toContain("I'll paste my server URL when you tell me where it goes."); expect(text).not.toContain('claude mcp add --scope user --transport http n8n'); expect(queryByTestId('mcp-onboarding-claude-server-url')).not.toBeInTheDocument(); }); diff --git a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingModal.test.ts b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingModal.test.ts index 98e51e9781d..52b115290c9 100644 --- a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingModal.test.ts +++ b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingModal.test.ts @@ -110,7 +110,7 @@ describe('MCPOnboardingModal', () => { expect(mockMcpStore.setMcpAccessEnabled).toHaveBeenCalledWith(true); expect(mockExperimentStore.trackEnableClicked).toHaveBeenCalledWith('first_open_modal'); - expect(await findByText(/Find the official n8n connector/)).toBeInTheDocument(); + expect(await findByText(/Show me the n8n MCP connector/)).toBeInTheDocument(); expect(mockExperimentStore.trackEnabled).toHaveBeenCalledWith('first_open_modal'); }); @@ -124,7 +124,7 @@ describe('MCPOnboardingModal', () => { const { getByRole, findByText, queryByTestId } = renderComponent(); - await findByText(/Find the official n8n connector/); + await findByText(/Show me the n8n MCP connector/); await user.click(getByRole('switch')); expect(mockMcpStore.setMcpAccessEnabled).toHaveBeenCalledWith(false); @@ -152,7 +152,7 @@ describe('MCPOnboardingModal', () => { const { findByTestId, findByText, getByTestId } = renderComponent(); - expect(await findByText(/Find the official n8n connector/)).toBeInTheDocument(); + expect(await findByText(/Show me the n8n MCP connector/)).toBeInTheDocument(); expect(await findByText('https://example.n8n.cloud/mcp-server/http')).toBeInTheDocument(); expect(await findByText('Paste the prompt in Claude')).toBeInTheDocument(); expect(await findByText('Paste Server URL')).toBeInTheDocument(); @@ -189,13 +189,13 @@ describe('MCPOnboardingModal', () => { const { getByRole, findByText, getByTestId } = renderComponent(); - await findByText(/Find the official n8n connector/); + await findByText(/Show me the n8n MCP connector/); await user.click(getByRole('switch')); await waitFor(() => { expect(mockShowError).toHaveBeenCalledWith(error, 'Error updating MCP access'); }); - expect(await findByText(/Find the official n8n connector/)).toBeInTheDocument(); + expect(await findByText(/Show me the n8n MCP connector/)).toBeInTheDocument(); expect(getByTestId('mcp-onboarding-copy-prompt-button')).toBeEnabled(); }); @@ -203,7 +203,7 @@ describe('MCPOnboardingModal', () => { const user = userEvent.setup(); mockMcpStore.mcpAccessEnabled = true; - const { getByText, container } = renderComponent(); + const { getByText, getByTestId, container } = renderComponent(); await user.click(getByText('Claude Code')); @@ -217,13 +217,16 @@ describe('MCPOnboardingModal', () => { 'prompt', ); expect(container.textContent).toContain('claude mcp add --scope user --transport http n8n'); + expect(getByTestId('mcp-onboarding-restart-step')).toHaveTextContent( + 'Restart Claude Code and connect to n8n', + ); }); it('switches between Claude Code and Codex setup instructions', async () => { const user = userEvent.setup(); mockMcpStore.mcpAccessEnabled = true; - const { getByText, queryByTestId, container } = renderComponent(); + const { getByText, getByTestId, queryByTestId, container } = renderComponent(); await user.click(getByText('Codex')); @@ -234,21 +237,25 @@ describe('MCPOnboardingModal', () => { expect(container.textContent).toContain('Paste the prompt in Codex'); expect(container.textContent).toContain('[mcp_servers.n8n]'); expect(queryByTestId('mcp-onboarding-claude-server-url')).not.toBeInTheDocument(); + expect(getByTestId('mcp-onboarding-restart-step')).toHaveTextContent( + 'Restart Codex and connect to n8n', + ); }); it('switches to Claude setup instructions', async () => { const user = userEvent.setup(); mockMcpStore.mcpAccessEnabled = true; - const { getByText, container } = renderComponent(); + const { getByText, queryByTestId, container } = renderComponent(); await user.click(getByText('Claude')); expect(mockExperimentStore.trackClientSelected).not.toHaveBeenCalled(); - expect(container.textContent).toContain('Find the official n8n connector'); + expect(container.textContent).toContain('Show me the n8n MCP connector'); expect(container.textContent).toContain('Paste the prompt in Claude'); expect(container.textContent).toContain('Paste Server URL'); expect(container.textContent).not.toContain('claude mcp add --scope user --transport http n8n'); + expect(queryByTestId('mcp-onboarding-restart-step')).not.toBeInTheDocument(); }); it('switches to ChatGPT setup instructions', async () => { @@ -286,6 +293,7 @@ describe('MCPOnboardingModal', () => { expect(queryByTestId('mcp-onboarding-client-setup')).not.toBeInTheDocument(); expect(queryByTestId('mcp-onboarding-claude-server-url')).not.toBeInTheDocument(); expect(queryByTestId('mcp-onboarding-copy-prompt-button')).not.toBeInTheDocument(); + expect(queryByTestId('mcp-onboarding-restart-step')).not.toBeInTheDocument(); await user.click(getByTestId('mcp-onboarding-copy-chatgpt-server-url-button')); @@ -310,7 +318,7 @@ describe('MCPOnboardingModal', () => { const user = userEvent.setup(); mockMcpStore.mcpAccessEnabled = true; - const { getByText, container } = renderComponent(); + const { getByText, getByTestId, container } = renderComponent(); await user.click(getByText('Cursor')); @@ -321,6 +329,9 @@ describe('MCPOnboardingModal', () => { expect(container.textContent).toContain('~/.cursor/mcp.json'); expect(container.textContent).toContain('complete the n8n OAuth flow'); expect(container.textContent).not.toContain('Authorization'); + expect(getByTestId('mcp-onboarding-restart-step')).toHaveTextContent( + 'Restart Cursor and connect to n8n', + ); }); it('forwards prompt copy telemetry with the selected client', async () => { diff --git a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingModal.vue b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingModal.vue index 5fe8010ae02..6e3a1d89cd6 100644 --- a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingModal.vue +++ b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/components/onboarding/MCPOnboardingModal.vue @@ -83,6 +83,12 @@ const clientOptions = computed(() => [ const serverUrl = computed(() => `${rootStore.urlBaseEditor}${MCP_ENDPOINT}`); const isChatGptClient = computed(() => activeClient.value === 'chatgpt'); const showServerUrlStep = computed(() => activeClient.value === 'claude'); +const showRestartStep = computed( + () => + activeClient.value === 'claude_code' || + activeClient.value === 'cursor' || + activeClient.value === 'codex', +); const activePromptClient = computed(() => activeClient.value === 'chatgpt' ? 'claude' : activeClient.value, ); @@ -96,6 +102,14 @@ const promptSectionTitle = computed(() => interpolate: { assistant: activeClientLabel.value }, }), ); +const restartSectionTitle = computed(() => + i18n.baseText( + 'experiments.surfaceMcpToNewCloudUsers.onboarding.section.restart.title' as BaseTextKey, + { + interpolate: { assistant: activeClientLabel.value }, + }, + ), +); const chatGptCustomAppFields = computed(() => [ { label: i18n.baseText( @@ -400,6 +414,19 @@ onBeforeUnmount(() => { /> + +
+
+ 4 +

+ {{ restartSectionTitle }} +

+
+