feat(editor): Surface MCP access toggle on workflow cards (#30683)

This commit is contained in:
Ricardo Espinoza 2026-05-27 02:59:49 -04:00 committed by GitHub
parent 319c9c24db
commit bf2b205b6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 669 additions and 101 deletions

View File

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

View File

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

View File

@ -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<typeof useWorkflowsStore>;
let workflowsListStore: MockedStore<typeof useWorkflowsListStore>;
let usersStore: MockedStore<typeof useUsersStore>;
let mcpStore: MockedStore<typeof useMCPStore>;
let uiStore: MockedStore<typeof useUIStore>;
let message: ReturnType<typeof useMessage>;
let toast: ReturnType<typeof useToast>;
@ -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<HTMLElement>(`#${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<HTMLElement>(`#${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<HTMLElement>(`#${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<HTMLElement>(`#${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<HTMLElement>(`#${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<HTMLElement>(`#${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', () => {

View File

@ -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<Promise<PathItem[]>>(new Promise(() => {}));
const cachedHiddenBreadcrumbsItems = ref<PathItem[]>([]);
// 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<boolean | null>(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<boolean | null>(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(
</span>
<span v-show="data">
{{ locale.baseText('workflows.item.created') }} {{ formattedCreatedAtDate }}
<span v-if="props.isMcpEnabled && isAvailableInMCP">|</span>
<span v-if="showLegacyMcpIndicator">|</span>
</span>
<span
v-show="props.isMcpEnabled && isAvailableInMCP"
:class="[$style['description-cell'], $style['description-cell--mcp']]"
v-show="showLegacyMcpIndicator"
:class="$style.legacyMcpIndicator"
data-test-id="workflow-card-mcp"
>
<N8nTooltip
placement="right"
:content="locale.baseText('workflows.item.availableInMCP')"
data-test-id="workflow-card-mcp-tooltip"
>
<N8nIcon icon="mcp" size="medium"></N8nIcon>
<N8nTooltip placement="right" :content="locale.baseText('workflows.item.availableInMCP')">
<N8nIcon icon="mcp" size="medium" />
</N8nTooltip>
</span>
<span
@ -736,6 +745,15 @@ const tags = computed(
locale.baseText('workflows.published')
}}</N8nText>
</div>
<WorkflowCardMcpToggle
v-if="props.isWorkflowCardMcpToggleEnabled"
:workflow-id="data.id"
:available-in-mcp="data.settings?.availableInMCP ?? false"
:can-edit="canEditMcp"
:is-mcp-enabled="props.isMcpEnabled"
:is-mcp-module-active="props.isMcpModuleActive"
:can-manage-instance-mcp="props.canManageInstanceMcp"
/>
<N8nActionToggle
:actions="actions"
placement="bottom-end"
@ -791,6 +809,11 @@ const tags = computed(
margin-top: var(--spacing--4xs);
}
.legacyMcpIndicator {
display: inline-flex;
align-items: center;
}
.cardActions {
display: flex;
gap: var(--spacing--2xs);
@ -821,15 +844,6 @@ const tags = computed(
color: var(--color--text);
}
.description-cell--mcp {
display: inline-flex;
align-items: center;
&:hover {
color: var(--color--text);
}
}
.dynamicBadgeText {
display: inline-flex;
align-items: center;

View File

@ -94,6 +94,7 @@ export const CODE_WORKFLOW_BUILDER_EXPERIMENT = createExperiment('071_coding_wor
});
export const AI_BUILDER_SETUP_WIZARD_EXPERIMENT = createExperiment('079_ai_builder_setup_wizard');
export const WORKFLOW_CARD_MCP_TOGGLE_EXPERIMENT = createExperiment('086_workflow_card_mcp_toggle');
export const INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT = createExperiment(
'082_instance_ai_proactive_agent',
);
@ -139,6 +140,7 @@ export const EXPERIMENTS_TO_TRACK = [
AI_BUILDER_REVIEW_CHANGES_EXPERIMENT.name,
MERGE_ASK_BUILD_EXPERIMENT.name,
AI_BUILDER_SETUP_WIZARD_EXPERIMENT.name,
WORKFLOW_CARD_MCP_TOGGLE_EXPERIMENT.name,
INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT.name,
INSTANCE_AI_PROMPT_SUGGESTIONS_V2_EXPERIMENT.name,
AA_EXPERIMENT_CHECK.name,

View File

@ -24,6 +24,7 @@ import { useMessage } from '@/app/composables/useMessage';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { hasPermission } from '@/app/utils/rbac/permissions';
import {
DEBOUNCE_TIME,
DEFAULT_WORKFLOW_PAGE_SIZE,
@ -60,6 +61,8 @@ import type {
} from '@/Interface';
import { useFoldersStore } from '@/features/core/folders/folders.store';
import { useFavoritesStore } from '@/app/stores/favorites.store';
import { usePostHog } from '@/app/stores/posthog.store';
import { WORKFLOW_CARD_MCP_TOGGLE_EXPERIMENT } from '@/app/constants/experiments';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
@ -143,6 +146,7 @@ const uiStore = useUIStore();
const tagsStore = useTagsStore();
const foldersStore = useFoldersStore();
const favoritesStore = useFavoritesStore();
const posthogStore = usePostHog();
const usageStore = useUsageStore();
const insightsStore = useInsightsStore();
const aiStarterTemplatesStore = useAITemplatesStarterCollectionStore();
@ -268,10 +272,23 @@ const foldersEnabled = computed(() => {
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"

View File

@ -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<MCPOnboardingClient, 'chatgpt'>;
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<void>;
};
}>();
@ -167,6 +168,7 @@ async function handleToggleMcpAccess() {
enabledDuringThisOpen.value = true;
experimentStore.trackEnabled(surface.value);
trackCurrentSetupShown();
void props.data?.onMcpAccessEnabled?.();
return;
}

View File

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

View File

@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { N8nIcon, N8nSwitch2, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/app/composables/useToast';
import { useUIStore } from '@/app/stores/ui.store';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import { SURFACE_MCP_ONBOARDING_MODAL_KEY } from '@/experiments/surfaceMcpToNewCloudUsers/constants';
const props = defineProps<{
workflowId: string;
availableInMcp: boolean;
canEdit: boolean;
isMcpEnabled: boolean;
isMcpModuleActive: boolean;
canManageInstanceMcp: boolean;
}>();
const locale = useI18n();
const toast = useToast();
const uiStore = useUIStore();
const mcpStore = useMCPStore();
const mcp = useMcp();
// Optimistically reflect the new state in the switch without mutating props.
// null means no local override yet.
const optimisticAvailability = ref<boolean | null>(null);
const isAvailableInMCP = computed(() => optimisticAvailability.value ?? props.availableInMcp);
const showToggle = computed(
() => props.isMcpModuleActive && (props.canEdit || isAvailableInMCP.value),
);
const switchModelValue = computed(() => props.isMcpEnabled && isAvailableInMCP.value);
// Non-admins can't flip instance-level MCP, so the modal would be a dead end.
const blockedByMissingAdminScope = computed(
() => !props.isMcpEnabled && !props.canManageInstanceMcp,
);
const switchDisabled = computed(() => !props.canEdit || blockedByMissingAdminScope.value);
const tooltipContent = computed(() => {
if (!props.canEdit) {
return locale.baseText('workflows.item.availableInMCP');
}
if (blockedByMissingAdminScope.value) {
return locale.baseText('workflows.item.mcpDisabledByInstance');
}
return switchModelValue.value
? locale.baseText('workflows.item.disableMCPAccess')
: locale.baseText('workflows.item.enableMCPAccess');
});
async function toggleMcpAccess(enabled: boolean) {
try {
await mcpStore.toggleWorkflowMcpAccess(props.workflowId, enabled);
optimisticAvailability.value = enabled;
if (enabled) {
mcp.trackMcpAccessEnabledForWorkflow(props.workflowId);
}
} catch (error) {
toast.showError(error, locale.baseText('workflowSettings.toggleMCP.error.title'));
}
}
async function onSwitchChange(nextValue: boolean) {
if (props.isMcpEnabled) {
await toggleMcpAccess(nextValue);
return;
}
uiStore.openModalWithData({
name: SURFACE_MCP_ONBOARDING_MODAL_KEY,
data: {
surface: 'workflow_card',
onMcpAccessEnabled: () => {
if (isAvailableInMCP.value) return;
void toggleMcpAccess(true);
},
},
});
}
</script>
<template>
<N8nTooltip v-if="showToggle" placement="top" :content="tooltipContent">
<span :class="$style.container">
<N8nIcon :class="$style.icon" icon="mcp" size="medium" />
<N8nSwitch2
:model-value="switchModelValue"
:disabled="switchDisabled"
size="small"
:aria-label="tooltipContent"
data-test-id="workflow-card-mcp-toggle"
@update:model-value="onSwitchChange"
@click.stop
/>
</span>
</N8nTooltip>
</template>
<style lang="scss" module>
.container {
display: inline-flex;
align-items: center;
gap: var(--spacing--3xs);
}
.icon {
color: var(--color--text);
}
</style>