fix(editor): Prioritize MCP tile in empty state (no-changelog) (#30418)
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: OpenCode <noreply@opencode.ai>
This commit is contained in:
Romeo Balta 2026-05-13 19:53:51 +01:00 committed by GitHub
parent c3e39f8504
commit ceb561b87b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 88 additions and 25 deletions

View File

@ -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",

View File

@ -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<typeof useRecommendedTemplatesStore>
>;
let readyToRunStore: ReturnType<typeof mockedStore<typeof useReadyToRunStore>>;
let bannersStore: ReturnType<typeof mockedStore<typeof useBannersStore>>;
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<typeof useSourceControlStore>['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();

View File

@ -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);

View File

@ -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();
});

View File

@ -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 () => {

View File

@ -83,6 +83,12 @@ const clientOptions = computed<MCPOnboardingClientOption[]>(() => [
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<MCPOnboardingPromptClient>(() =>
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(() => {
/>
</div>
</section>
<section
v-if="showRestartStep"
:class="[$style.section, $style.revealSection]"
data-test-id="mcp-onboarding-restart-step"
>
<header :class="$style.sectionHeader">
<span :class="$style.sectionStep">4</span>
<h2 :class="$style.sectionTitle">
{{ restartSectionTitle }}
</h2>
</header>
</section>
</template>
</template>
</div>