mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
feat(editor): Surface MCP access toggle on workflow cards (#30683)
This commit is contained in:
parent
319c9c24db
commit
bf2b205b6b
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user