diff --git a/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts b/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts index e1c3b9ead67..ce6b4ef795f 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts @@ -74,7 +74,7 @@ export async function getMcpWorkflow( if (!workflow.settings?.availableInMCP) { throw new WorkflowAccessError( - 'Workflow is not available in MCP. Enable MCP access in workflow settings.', + 'Workflow is not available in MCP. Enable MCP access from the workflow card in the workflows list, or from the workflow settings.', 'not_available_in_mcp', ); } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 3a072946ab7..92e0d7f93da 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2989,7 +2989,7 @@ "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.access.title": "Enable MCP for my instance", "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", @@ -4101,6 +4101,7 @@ "resourceDependents.type.workflows": "Workflows", "workflows.item.enableMCPAccess": "Enable MCP access", "workflows.item.disableMCPAccess": "Remove MCP access", + "workflows.item.mcpDisabledByInstance": "Instance-level MCP is disabled. Ask an admin to enable it.", "workflows.itemSuggestion.try": "Try template", "workflows.templateRecoV2.starterTemplates": "Starter templates", "workflows.templateRecoV2.seeMoreStarterTemplates": "See more starter templates", diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts index 40cf2a2a2f0..ea619ca6f67 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts @@ -18,6 +18,9 @@ import { useWorkflowsListStore } from '@/app/stores/workflowsList.store'; import { createTestingPinia } from '@pinia/testing'; import { useSettingsStore } from '@/app/stores/settings.store'; import { useUsersStore } from '@/features/settings/users/users.store'; +import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store'; +import { useUIStore } from '@/app/stores/ui.store'; +import { SURFACE_MCP_ONBOARDING_MODAL_KEY } from '@/experiments/surfaceMcpToNewCloudUsers/constants'; vi.mock('vue-router', () => { const push = vi.fn(); @@ -103,6 +106,8 @@ describe('WorkflowCard', () => { let workflowsStore: MockedStore; let workflowsListStore: MockedStore; let usersStore: MockedStore; + let mcpStore: MockedStore; + let uiStore: MockedStore; let message: ReturnType; let toast: ReturnType; @@ -113,6 +118,8 @@ describe('WorkflowCard', () => { workflowsStore = mockedStore(useWorkflowsStore); workflowsListStore = mockedStore(useWorkflowsListStore); usersStore = mockedStore(useUsersStore); + mcpStore = mockedStore(useMCPStore); + uiStore = mockedStore(useUIStore); message = useMessage(); toast = useToast(); @@ -540,7 +547,7 @@ describe('WorkflowCard', () => { expect(heading).toHaveTextContent('Read only'); }); - it('should show Enable MCP action when module is enabled', async () => { + it('should show MCP toggle on the card when module is enabled and user can update', () => { const data = createWorkflow({ scopes: ['workflow:update'], settings: { @@ -553,25 +560,91 @@ describe('WorkflowCard', () => { props: { data, isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, }, }); - const actionsToggle = getByTestId('workflow-card-actions'); - const toggleButton = within(actionsToggle).getByRole('button'); - const controllingId = toggleButton.getAttribute('aria-controls'); - - await userEvent.click(toggleButton); - - const actions = document.querySelector(`#${controllingId}`); - if (!actions) { - throw new Error('Actions menu not found'); - } - - expect(within(actions).getByTestId('action-enableMCPAccess')).toBeInTheDocument(); - expect(within(actions).queryByTestId('action-removeMCPAccess')).not.toBeInTheDocument(); + const mcpToggle = getByTestId('workflow-card-mcp-toggle'); + expect(mcpToggle).toBeVisible(); + expect(mcpToggle).toHaveAttribute('aria-checked', 'false'); }); - it('should show Disable MCP action when workflow is available in MCP and module is enabled', async () => { + it('should mark MCP toggle as pressed when workflow is available in MCP', () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: true, + }, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, + }, + }); + + const mcpToggle = getByTestId('workflow-card-mcp-toggle'); + expect(mcpToggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('should render the MCP toggle as off when the instance module is disabled, even if the workflow is available in MCP', () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: true, + }, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: false, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, + }, + }); + + const mcpToggle = getByTestId('workflow-card-mcp-toggle'); + expect(mcpToggle).toHaveAttribute('aria-checked', 'false'); + }); + + it('should toggle MCP access when the MCP button is clicked', async () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: false, + }, + }); + + mcpStore.toggleWorkflowMcpAccess.mockResolvedValue({ + updatedCount: 1, + skippedCount: 0, + failedCount: 0, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, + }, + }); + + await userEvent.click(getByTestId('workflow-card-mcp-toggle')); + + expect(mcpStore.toggleWorkflowMcpAccess).toHaveBeenCalledWith(data.id, true); + }); + + it('should not include MCP actions in the dropdown menu', async () => { const data = createWorkflow({ scopes: ['workflow:update'], settings: { @@ -584,6 +657,9 @@ describe('WorkflowCard', () => { props: { data, isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, }, }); @@ -598,37 +674,137 @@ describe('WorkflowCard', () => { throw new Error('Actions menu not found'); } - expect(within(actions).getByTestId('action-removeMCPAccess')).toBeInTheDocument(); - expect(within(actions).queryByTestId('action-enableMCPAccess')).not.toBeInTheDocument(); - }); - - it('should hide MCP actions when module is disabled', async () => { - const data = createWorkflow({ - scopes: ['workflow:update'], - settings: { - availableInMCP: true, - }, - isArchived: false, - }); - - const { getByTestId } = renderComponent({ props: { data } }); - - const actionsToggle = getByTestId('workflow-card-actions'); - const toggleButton = within(actionsToggle).getByRole('button'); - const controllingId = toggleButton.getAttribute('aria-controls'); - - await userEvent.click(toggleButton); - - const actions = document.querySelector(`#${controllingId}`); - if (!actions) { - throw new Error('Actions menu not found'); - } - expect(within(actions).queryByTestId('action-enableMCPAccess')).not.toBeInTheDocument(); expect(within(actions).queryByTestId('action-removeMCPAccess')).not.toBeInTheDocument(); }); - it('should show MCP indicator when module is enabled and workflow is available', () => { + it('should open the MCP onboarding modal when the switch is clicked while the instance module is off', async () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: false, + }, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: false, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, + }, + }); + + const mcpToggle = getByTestId('workflow-card-mcp-toggle'); + expect(mcpToggle).toBeVisible(); + expect(mcpToggle).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(mcpToggle); + + expect(mcpStore.toggleWorkflowMcpAccess).not.toHaveBeenCalled(); + expect(uiStore.openModalWithData).toHaveBeenCalledWith( + expect.objectContaining({ + name: SURFACE_MCP_ONBOARDING_MODAL_KEY, + data: expect.objectContaining({ + surface: 'workflow_card', + onMcpAccessEnabled: expect.any(Function), + }), + }), + ); + }); + + it('should enable workflow MCP access when the modal callback fires', async () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: false, + }, + }); + + mcpStore.toggleWorkflowMcpAccess.mockResolvedValue({ + updatedCount: 1, + skippedCount: 0, + failedCount: 0, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: false, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, + }, + }); + + await userEvent.click(getByTestId('workflow-card-mcp-toggle')); + + const openCall = vi.mocked(uiStore.openModalWithData).mock.calls.at(-1)?.[0]; + const callback = (openCall?.data as { onMcpAccessEnabled?: () => void } | undefined) + ?.onMcpAccessEnabled; + callback?.(); + + await waitFor(() => { + expect(mcpStore.toggleWorkflowMcpAccess).toHaveBeenCalledWith(data.id, true); + }); + }); + + it('should not re-toggle when the modal callback fires and the workflow is already available', async () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: true, + }, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: false, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, + }, + }); + + await userEvent.click(getByTestId('workflow-card-mcp-toggle')); + + const openCall = vi.mocked(uiStore.openModalWithData).mock.calls.at(-1)?.[0]; + const callback = (openCall?.data as { onMcpAccessEnabled?: () => void } | undefined) + ?.onMcpAccessEnabled; + callback?.(); + + expect(mcpStore.toggleWorkflowMcpAccess).not.toHaveBeenCalled(); + }); + + it('should leave workflow MCP off if the modal callback never fires', async () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: false, + }, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: false, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, + }, + }); + + await userEvent.click(getByTestId('workflow-card-mcp-toggle')); + await waitFor(() => { + expect(uiStore.openModalWithData).toHaveBeenCalled(); + }); + + expect(mcpStore.toggleWorkflowMcpAccess).not.toHaveBeenCalled(); + }); + + it('should show MCP toggle as disabled when user cannot update but workflow is available', () => { const data = createWorkflow({ settings: { availableInMCP: true, @@ -639,27 +815,18 @@ describe('WorkflowCard', () => { props: { data, isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, }, }); - const indicator = getByTestId('workflow-card-mcp'); - expect(indicator).toBeVisible(); + const mcpToggle = getByTestId('workflow-card-mcp-toggle'); + expect(mcpToggle).toBeVisible(); + expect(mcpToggle).toBeDisabled(); }); - it('should hide MCP indicator when module is disabled', () => { - const data = createWorkflow({ - settings: { - availableInMCP: true, - }, - }); - - const { queryByTestId } = renderComponent({ props: { data } }); - - const indicator = queryByTestId('workflow-card-mcp'); - expect(indicator).not.toBeVisible(); - }); - - it('should hide MCP indicator when workflow is not available in MCP', () => { + it('should hide MCP toggle when user cannot update and workflow is not available', () => { const data = createWorkflow({ settings: { availableInMCP: false, @@ -670,11 +837,258 @@ describe('WorkflowCard', () => { props: { data, isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, }, }); - const indicator = queryByTestId('workflow-card-mcp'); - expect(indicator).not.toBeVisible(); + expect(queryByTestId('workflow-card-mcp-toggle')).not.toBeInTheDocument(); + }); + + it('should disable the MCP toggle for non-admins when instance MCP is off', async () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: false, + }, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: false, + isMcpModuleActive: true, + canManageInstanceMcp: false, + isWorkflowCardMcpToggleEnabled: true, + }, + }); + + const mcpToggle = getByTestId('workflow-card-mcp-toggle'); + expect(mcpToggle).toBeVisible(); + expect(mcpToggle).toBeDisabled(); + + await userEvent.click(mcpToggle); + expect(uiStore.openModalWithData).not.toHaveBeenCalled(); + expect(mcpStore.toggleWorkflowMcpAccess).not.toHaveBeenCalled(); + }); + + it('should hide MCP toggle when the MCP module is not loaded on the instance', () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: true, + }, + }); + + const { queryByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: false, + isMcpModuleActive: false, + }, + }); + + expect(queryByTestId('workflow-card-mcp-toggle')).not.toBeInTheDocument(); + }); + + it('should hide the inline MCP switch when the workflow-card MCP toggle experiment is off', async () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: false, + }, + }); + + const { getByTestId, queryByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: false, + }, + }); + + expect(queryByTestId('workflow-card-mcp-toggle')).not.toBeInTheDocument(); + + const actionsToggle = getByTestId('workflow-card-actions'); + const toggleButton = within(actionsToggle).getByRole('button'); + const controllingId = toggleButton.getAttribute('aria-controls'); + + await userEvent.click(toggleButton); + + const actions = document.querySelector(`#${controllingId}`); + if (!actions) { + throw new Error('Actions menu not found'); + } + expect(within(actions).getByTestId('action-enableMCPAccess')).toBeInTheDocument(); + }); + + it('should show the legacy MCP indicator in the card description when the experiment is off and the workflow is available', () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: true, + }, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: false, + }, + }); + + expect(getByTestId('workflow-card-mcp')).toBeVisible(); + }); + + it('should hide the legacy MCP indicator when the experiment is on (the inline switch replaces it)', () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: true, + }, + }); + + const { queryByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, + }, + }); + + expect(queryByTestId('workflow-card-mcp')).not.toBeVisible(); + }); + + it('should show Remove MCP access in the menu when the experiment is off and workflow is available', async () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: true, + }, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: false, + }, + }); + + const actionsToggle = getByTestId('workflow-card-actions'); + const toggleButton = within(actionsToggle).getByRole('button'); + const controllingId = toggleButton.getAttribute('aria-controls'); + + await userEvent.click(toggleButton); + + const actions = document.querySelector(`#${controllingId}`); + if (!actions) { + throw new Error('Actions menu not found'); + } + expect(within(actions).getByTestId('action-removeMCPAccess')).toBeInTheDocument(); + }); + + it('should call toggleWorkflowMcpAccess from the dropdown menu item when the experiment is off', async () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: false, + }, + }); + + mcpStore.toggleWorkflowMcpAccess.mockResolvedValue({ + updatedCount: 1, + skippedCount: 0, + failedCount: 0, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: false, + }, + }); + + const actionsToggle = getByTestId('workflow-card-actions'); + const toggleButton = within(actionsToggle).getByRole('button'); + const controllingId = toggleButton.getAttribute('aria-controls'); + + await userEvent.click(toggleButton); + + const actions = document.querySelector(`#${controllingId}`); + if (!actions) { + throw new Error('Actions menu not found'); + } + await userEvent.click(within(actions).getByTestId('action-enableMCPAccess')); + + expect(mcpStore.toggleWorkflowMcpAccess).toHaveBeenCalledWith(data.id, true); + }); + + it('should hide MCP menu items when the experiment is off and instance MCP is disabled', async () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + settings: { + availableInMCP: false, + }, + }); + + const { getByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: false, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: false, + }, + }); + + const actionsToggle = getByTestId('workflow-card-actions'); + const toggleButton = within(actionsToggle).getByRole('button'); + const controllingId = toggleButton.getAttribute('aria-controls'); + + await userEvent.click(toggleButton); + + const actions = document.querySelector(`#${controllingId}`); + if (!actions) { + throw new Error('Actions menu not found'); + } + expect(within(actions).queryByTestId('action-enableMCPAccess')).not.toBeInTheDocument(); + expect(within(actions).queryByTestId('action-removeMCPAccess')).not.toBeInTheDocument(); + }); + + it('should hide MCP toggle when workflow is archived', () => { + const data = createWorkflow({ + scopes: ['workflow:update'], + isArchived: true, + settings: { + availableInMCP: false, + }, + }); + + const { queryByTestId } = renderComponent({ + props: { + data, + isMcpEnabled: true, + isMcpModuleActive: true, + canManageInstanceMcp: true, + isWorkflowCardMcpToggleEnabled: true, + }, + }); + + expect(queryByTestId('workflow-card-mcp-toggle')).not.toBeInTheDocument(); }); it('should show dynamic credentials indicator when workflow has resolvable credentials', () => { diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue index 16a25aa47b4..cd35b442a37 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue @@ -44,6 +44,7 @@ import { N8nText, N8nTooltip, } from '@n8n/design-system'; +import WorkflowCardMcpToggle from '@/features/ai/mcpAccess/components/WorkflowCardMcpToggle.vue'; import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store'; import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp'; import { useWorkflowActivate } from '@/app/composables/useWorkflowActivate'; @@ -73,6 +74,9 @@ const props = withDefaults( showOwnershipBadge?: boolean; areTagsEnabled?: boolean; isMcpEnabled?: boolean; + isMcpModuleActive?: boolean; + canManageInstanceMcp?: boolean; + isWorkflowCardMcpToggleEnabled?: boolean; areFoldersEnabled?: boolean; }>(), { @@ -81,6 +85,9 @@ const props = withDefaults( showOwnershipBadge: false, areTagsEnabled: true, isMcpEnabled: false, + isMcpModuleActive: false, + canManageInstanceMcp: false, + isWorkflowCardMcpToggleEnabled: false, areFoldersEnabled: false, }, ); @@ -110,7 +117,6 @@ const locale = useI18n(); const router = useRouter(); const route = useRoute(); const telemetry = useTelemetry(); -const mcp = useMcp(); const { isEnabled: isDynamicCredentialsEnabled } = useDynamicCredentials(); const { hasDependencies } = useDependencies(); @@ -120,17 +126,13 @@ const workflowsStore = useWorkflowsStore(); const workflowsListStore = useWorkflowsListStore(); const projectsStore = useProjectsStore(); const foldersStore = useFoldersStore(); -const mcpStore = useMCPStore(); const favoritesStore = useFavoritesStore(); +const mcpStore = useMCPStore(); +const mcp = useMcp(); const workflowActivate = useWorkflowActivate(); const hiddenBreadcrumbsItemsAsync = ref>(new Promise(() => {})); const cachedHiddenBreadcrumbsItems = ref([]); -// We use this to optimistically update the MCP status in the UI -// without needing to modify the workflow prop directly. -// null means we haven't changed it yet -const mcpToggleStatus = ref(null); - const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase()); const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser)); const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow); @@ -259,6 +261,7 @@ const actions = computed(() => { } if ( + !props.isWorkflowCardMcpToggleEnabled && props.isMcpEnabled && workflowPermissions.value.update && !props.readOnly && @@ -288,12 +291,21 @@ const formattedCreatedAtDate = computed(() => { ); }); -const isAvailableInMCP = computed(() => { - if (mcpToggleStatus.value === null) { - return props.data.settings?.availableInMCP ?? false; - } - return mcpToggleStatus.value; -}); +const canEditMcp = computed( + () => Boolean(workflowPermissions.value.update) && !props.readOnly && !props.data.isArchived, +); + +// Optimistic state for the legacy 3-dot menu fallback (used when the +// 086_workflow_card_mcp_toggle experiment is off). +const mcpToggleStatus = ref(null); + +const isAvailableInMCP = computed( + () => mcpToggleStatus.value ?? props.data.settings?.availableInMCP ?? false, +); + +const showLegacyMcpIndicator = computed( + () => !props.isWorkflowCardMcpToggleEnabled && props.isMcpEnabled && isAvailableInMCP.value, +); const isSomeoneElsesWorkflow = computed( () => @@ -394,15 +406,15 @@ async function onAction(action: string) { homeProjectId: props.data.homeProject?.id, }); break; + case WORKFLOW_LIST_ITEM_ACTIONS.UNPUBLISH: + await unpublishWorkflow(); + break; case WORKFLOW_LIST_ITEM_ACTIONS.ENABLE_MCP_ACCESS: await toggleMCPAccess(true); break; case WORKFLOW_LIST_ITEM_ACTIONS.REMOVE_MCP_ACCESS: await toggleMCPAccess(false); break; - case WORKFLOW_LIST_ITEM_ACTIONS.UNPUBLISH: - await unpublishWorkflow(); - break; case WORKFLOW_LIST_ITEM_ACTIONS.TOGGLE_FAVORITE: await favoritesStore.toggleFavorite(props.data.id, 'workflow'); break; @@ -446,10 +458,11 @@ async function toggleMCPAccess(enabled: boolean) { try { await mcpStore.toggleWorkflowMcpAccess(props.data.id, enabled); mcpToggleStatus.value = enabled; - mcp.trackMcpAccessEnabledForWorkflow(props.data.id); + if (enabled) { + mcp.trackMcpAccessEnabledForWorkflow(props.data.id); + } } catch (error) { toast.showError(error, locale.baseText('workflowSettings.toggleMCP.error.title')); - return; } } @@ -650,19 +663,15 @@ const tags = computed( {{ locale.baseText('workflows.item.created') }} {{ formattedCreatedAtDate }} - | + | - - + + + { return settingsStore.isFoldersFeatureEnabled; }); +const mcpModuleActive = computed(() => settingsStore.isModuleActive('mcp')); + const mcpEnabled = computed(() => { - return settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled; + return mcpModuleActive.value && settingsStore.moduleSettings.mcp?.mcpAccessEnabled; }); +const canManageInstanceMcp = computed(() => + hasPermission(['rbac'], { rbac: { scope: ['mcp:manage'] } }), +); + +const isWorkflowCardMcpToggleEnabled = computed(() => + posthogStore.isVariantEnabled( + WORKFLOW_CARD_MCP_TOGGLE_EXPERIMENT.name, + WORKFLOW_CARD_MCP_TOGGLE_EXPERIMENT.variant, + ), +); + const showFolders = computed(() => { return foldersEnabled.value && !projectPages.isOverviewSubPage && !projectPages.isSharedSubPage; }); @@ -2017,6 +2034,9 @@ const onNameSubmit = async (name: string) => { :are-folders-enabled="settingsStore.isFoldersFeatureEnabled" :are-tags-enabled="settingsStore.areTagsEnabled" :is-mcp-enabled="mcpEnabled" + :is-mcp-module-active="mcpModuleActive" + :can-manage-instance-mcp="canManageInstanceMcp" + :is-workflow-card-mcp-toggle-enabled="isWorkflowCardMcpToggleEnabled" @click:tag="onClickTag" @workflow:deleted="refreshWorkflows" @workflow:archived="refreshWorkflows" 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 6e3a1d89cd6..33860d30474 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 @@ -19,7 +19,7 @@ import MCPOnboardingClientSetup from './MCPOnboardingClientSetup.vue'; import MCPOnboardingCopyBlock from './MCPOnboardingCopyBlock.vue'; import type { MCPOnboardingClient, MCPOnboardingClientOption } from './types'; -type MCPOnboardingSurface = 'tile' | 'first_open_modal'; +type MCPOnboardingSurface = 'tile' | 'first_open_modal' | 'workflow_card'; type MCPOnboardingPromptClient = Exclude; type MCPOnboardingCopiedParameter = 'agent-prompt' | 'server-url' | 'chatgpt-app-name'; type MCPOnboardingSetupType = 'prompt' | 'chatgpt_custom_app'; @@ -29,6 +29,7 @@ const MCP_ONBOARDING_DOCS_URL = 'https://docs.n8n.io/advanced-ai/mcp/accessing-n const props = defineProps<{ data?: { surface?: MCPOnboardingSurface; + onMcpAccessEnabled?: () => void | Promise; }; }>(); @@ -167,6 +168,7 @@ async function handleToggleMcpAccess() { enabledDuringThisOpen.value = true; experimentStore.trackEnabled(surface.value); trackCurrentSetupShown(); + void props.data?.onMcpAccessEnabled?.(); return; } diff --git a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/stores/surfaceMcpToNewCloudUsers.store.ts b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/stores/surfaceMcpToNewCloudUsers.store.ts index b32eccca8a9..59b4a7cc6ca 100644 --- a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/stores/surfaceMcpToNewCloudUsers.store.ts +++ b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/stores/surfaceMcpToNewCloudUsers.store.ts @@ -10,7 +10,7 @@ import type { MCPOnboardingClient as SurfaceMcpOnboardingClient } from '../compo const FIRST_OPEN_SEEN_STORAGE_KEY = 'N8N_SURFACE_MCP_TO_NEW_CLOUD_USERS_FIRST_OPEN_SEEN'; const FIRST_OPEN_DISMISSED_STORAGE_KEY = 'N8N_SURFACE_MCP_TO_NEW_CLOUD_USERS_FIRST_OPEN_DISMISSED'; -type SurfaceMcpOnboardingSurface = 'tile' | 'first_open_modal'; +type SurfaceMcpOnboardingSurface = 'tile' | 'first_open_modal' | 'workflow_card'; type SurfaceMcpOnboardingEntryPoint = 'empty_state_tile'; type SurfaceMcpOnboardingParameter = 'agent-prompt' | 'server-url' | 'chatgpt-app-name'; type SurfaceMcpOnboardingSetupType = 'prompt' | 'chatgpt_custom_app'; diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowCardMcpToggle.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowCardMcpToggle.vue new file mode 100644 index 00000000000..5aca0dac336 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowCardMcpToggle.vue @@ -0,0 +1,115 @@ + + + + +