fix(editor): Address MCP feedback (no-changelog) (#20595)

This commit is contained in:
Milorad FIlipović 2025-10-13 15:39:42 +02:00 committed by GitHub
parent fd3caae509
commit f15fd87bab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 293 additions and 73 deletions

View File

@ -88,6 +88,20 @@ export type RelayEventMap = {
publicApi: boolean;
};
'workflow-activated': {
user: UserLike;
workflowId: string;
workflow: IWorkflowDb;
publicApi: boolean;
};
'workflow-deactivated': {
user: UserLike;
workflowId: string;
workflow: IWorkflowDb;
publicApi: boolean;
};
'workflow-pre-execute': {
executionId: string;
data: IWorkflowExecutionDataProcess /* main process */ | IWorkflowBase /* worker */;

View File

@ -239,6 +239,25 @@ describe('McpSettingsController', () => {
expect(workflowService.update).not.toHaveBeenCalled();
});
test('allows disabling MCP for inactive workflows', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(
createWorkflow({ active: false }),
);
workflowService.update.mockResolvedValue({
id: workflowId,
settings: { saveManualExecutions: true, availableInMCP: false },
versionId: 'client-version',
} as unknown as WorkflowEntity);
const req = createReq({}, { user });
await controller.toggleWorkflowMCPAccess(req, mock<Response>(), workflowId, {
availableInMCP: false,
});
expect(workflowService.update).toHaveBeenCalledTimes(1);
});
test('rejects enabling MCP without active webhook nodes', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(
createWorkflow({
@ -296,26 +315,5 @@ describe('McpSettingsController', () => {
versionId: 'updated-version-id',
});
});
test('rejects disabling MCP for inactive workflows', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(
createWorkflow({ active: false }),
);
workflowService.update.mockResolvedValue({
id: workflowId,
settings: { saveManualExecutions: true, availableInMCP: false },
versionId: 'client-version',
} as unknown as WorkflowEntity);
const req = createReq({}, { user });
await expect(
controller.toggleWorkflowMCPAccess(req, mock<Response>(), workflowId, {
availableInMCP: false,
}),
).rejects.toThrow(new BadRequestError('MCP access can only be set for active workflows'));
expect(workflowService.update).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,60 @@
import { Logger } from '@n8n/backend-common';
import { WorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { EventService } from '@/events/event.service';
import { EventRelay } from '@/events/relays/event-relay';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
/**
* Event relay for MCP module to handle workflow events
*/
@Service()
export class McpEventRelay extends EventRelay {
constructor(
eventService: EventService,
private readonly workflowRepository: WorkflowRepository,
private readonly logger: Logger,
) {
super(eventService);
}
init() {
this.setupListeners({
'workflow-deactivated': async (event) => await this.onWorkflowDeactivated(event),
});
}
/**
* Handles workflow deactivated events.
* When a workflow is deactivated, automatically disables MCP access.
*/
private async onWorkflowDeactivated(event: RelayEventMap['workflow-deactivated']) {
const { workflow, workflowId } = event;
// Only process if workflow has MCP access enabled
if (workflow.settings?.availableInMCP === true) {
try {
// Update the workflow settings to disable MCP access
const updatedSettings = {
...workflow.settings,
availableInMCP: false,
};
await this.workflowRepository.update(workflowId, {
settings: updatedSettings,
});
this.logger.info('Disabled MCP access for deactivated workflow', {
workflowId,
workflowName: workflow.name,
});
} catch (error) {
this.logger.error('Failed to disable MCP access for deactivated workflow', {
workflowId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
}

View File

@ -12,6 +12,10 @@ export class McpModule implements ModuleInterface {
async init() {
await import('./mcp.controller');
await import('./mcp.settings.controller');
// Initialize event relay to handle workflow deactivation
const { McpEventRelay } = await import('./mcp.event-relay');
Container.get(McpEventRelay).init();
}
/**

View File

@ -78,7 +78,7 @@ export class McpSettingsController {
);
}
if (!workflow.active) {
if (!workflow.active && dto.availableInMCP) {
throw new BadRequestError('MCP access can only be set for active workflows');
}

View File

@ -368,6 +368,13 @@ export = {
workflow.active = true;
Container.get(EventService).emit('workflow-activated', {
user: req.user,
workflowId: workflow.id,
workflow,
publicApi: true,
});
return res.json(workflow);
}
@ -402,6 +409,13 @@ export = {
workflow.active = false;
Container.get(EventService).emit('workflow-deactivated', {
user: req.user,
workflowId: workflow.id,
workflow,
publicApi: true,
});
return res.json(workflow);
}

View File

@ -354,6 +354,28 @@ export class WorkflowService {
publicApi: false,
});
// Check if workflow activation status changed
const wasActive = workflow.active;
const isNowActive = updatedWorkflow.active;
if (isNowActive && !wasActive) {
// Workflow is being activated
this.eventService.emit('workflow-activated', {
user,
workflowId,
workflow: updatedWorkflow,
publicApi: false,
});
} else if (!isNowActive && wasActive) {
// Workflow is being deactivated
this.eventService.emit('workflow-deactivated', {
user,
workflowId,
workflow: updatedWorkflow,
publicApi: false,
});
}
if (updatedWorkflow.active) {
// When the workflow is supposed to be active add it again
try {
@ -370,6 +392,14 @@ export class WorkflowService {
// Also set it in the returned data
updatedWorkflow.active = false;
// Emit deactivation event since activation failed
this.eventService.emit('workflow-deactivated', {
user,
workflowId,
workflow: updatedWorkflow,
publicApi: false,
});
let message;
if (error instanceof NodeApiError) message = error.description;
message = message ?? (error as Error).message;

View File

@ -1152,6 +1152,10 @@
"mainSidebar.whatsNew": "Whats New",
"mainSidebar.whatsNew.fullChangelog": "Full changelog",
"mcp.workflowNotEligable.description": "Only active, webhook-triggered workflows can be accessible through MCP",
"mcp.workflowDeactivated.title": "MCP Access Disabled",
"mcp.productionCheklist.title": "Enable MCP access",
"mcp.productionCheklist.description": "Allow MCP clients to access this workflow",
"mcp.workflowDeactivated.message": "MCP Access has been disabled for this workflow because it is deactivated.",
"menuActions.duplicate": "Duplicate",
"menuActions.download": "Download",
"menuActions.push": "Push to Git",
@ -2694,6 +2698,7 @@
"workflowSettings.showError.saveSettings2.message": "The timeout is longer than allowed",
"workflowSettings.showError.saveSettings2.title": "Problem saving settings",
"workflowSettings.showError.saveSettings3.title": "Problem saving settings",
"workflowSettings.showError.fetchSettings.title": "Problem fetching settings",
"workflowSettings.showMessage.saveSettings.title": "Workflow settings saved",
"workflowSettings.timeoutAfter": "Timeout After",
"workflowSettings.timeoutWorkflow": "Timeout Workflow",
@ -2804,6 +2809,7 @@
"workflows.empty.shared-with-me.link": "<a href=\"#\">Back to Personal</a>",
"workflows.empty.readyToRunV2": "Try an AI workflow",
"workflows.list.easyAI": "Test the power of AI in n8n with this simple AI Agent Workflow",
"workflows.list.error.fetching.one": "Error fetching workflow",
"workflows.list.error.fetching": "Error fetching workflows",
"workflows.shareModal.title": "Share '{name}'",
"workflows.shareModal.title.static": "Shared with {projectName}",

View File

@ -26,10 +26,12 @@ import GithubButton from 'vue-github-button';
import type { FolderShortInfo } from '@/features/folders/folders.types';
import { N8nIcon } from '@n8n/design-system';
import { useToast } from '@/composables/useToast';
const router = useRouter();
const route = useRoute();
const locale = useI18n();
const pushConnection = usePushConnection({ router });
const toast = useToast();
const ndvStore = useNDVStore();
const uiStore = useUIStore();
const sourceControlStore = useSourceControlStore();
@ -244,6 +246,23 @@ async function navigateToEvaluationsView(openInNewTab: boolean) {
function hideGithubButton() {
githubButtonHidden.value = true;
}
async function onWorkflowDeactivated() {
if (settingsStore.isModuleActive('mcp') && workflow.value.settings?.availableInMCP) {
try {
// Fetch the updated workflow to get the latest settings after backend processing
const updatedWorkflow = await workflowsStore.fetchWorkflow(workflow.value.id);
workflowsStore.setWorkflow(updatedWorkflow);
toast.showToast({
title: locale.baseText('mcp.workflowDeactivated.title'),
message: locale.baseText('mcp.workflowDeactivated.message'),
type: 'info',
});
} catch (error) {
toast.showError(error, locale.baseText('workflowSettings.showError.fetchSettings.title'));
}
}
}
</script>
<template>
@ -263,6 +282,7 @@ function hideGithubButton() {
:read-only="readOnly"
:current-folder="parentFolderForBreadcrumbs"
:is-archived="workflow.isArchived"
@workflow:deactivated="onWorkflowDeactivated"
/>
<div v-if="showGitHubButton" :class="[$style['github-button'], 'hidden-sm-and-down']">
<div :class="$style['github-button-container']">

View File

@ -86,6 +86,10 @@ const props = defineProps<{
isArchived: IWorkflowDb['isArchived'];
}>();
const emit = defineEmits<{
'workflow:deactivated': [];
}>();
const $style = useCssModule();
const rootStore = useRootStore();
@ -713,6 +717,12 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
});
}
};
const onWorkflowActiveToggle = async (value: { id: string; active: boolean }) => {
if (!value.active) {
emit('workflow:deactivated');
}
};
</script>
<template>
@ -813,6 +823,7 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
:workflow-active="active"
:workflow-id="id"
:workflow-permissions="workflowPermissions"
@update:workflow-active="onWorkflowActiveToggle"
/>
</span>
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">

View File

@ -208,7 +208,7 @@ const actions = computed(() => {
!props.readOnly &&
!props.data.isArchived
) {
if (props.data.settings?.availableInMCP) {
if (isAvailableInMCP.value) {
items.push({
label: locale.baseText('workflows.item.disableMCPAccess'),
value: WORKFLOW_LIST_ITEM_ACTIONS.REMOVE_MCP_ACCESS,
@ -466,8 +466,19 @@ function moveResource() {
});
}
const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
const onWorkflowActiveToggle = async (value: { id: string; active: boolean }) => {
emit('workflow:active-toggle', value);
// Show notification if MCP access was removed due to deactivation
if (!value.active && props.isMcpEnabled && isAvailableInMCP.value) {
// Reset the local MCP toggle status to null to use props data
mcpToggleStatus.value = null;
toast.showToast({
title: locale.baseText('mcp.workflowDeactivated.title'),
message: locale.baseText('mcp.workflowDeactivated.message'),
type: 'info',
});
}
};
const onBreadcrumbItemClick = async (item: PathItem) => {
@ -592,7 +603,7 @@ const tags = computed(
:workflow-id="data.id"
:workflow-permissions="workflowPermissions"
data-test-id="workflow-card-activator"
@update:workflow-active="emitWorkflowActiveToggle"
@update:workflow-active="onWorkflowActiveToggle"
/>
<N8nActionToggle

View File

@ -20,8 +20,11 @@ import {
import { useMessage } from '@/composables/useMessage';
import { useTelemetry } from '@/composables/useTelemetry';
import { useSourceControlStore } from '@/features/sourceControl.ee/sourceControl.store';
import { MCP_DOCS_PAGE_URL } from '@/features/mcpAccess/mcp.constants';
import { useMcp } from '@/features/mcpAccess/composables/useMcp';
import { N8nSuggestedActions } from '@n8n/design-system';
import { useSettingsStore } from '@/stores/settings.store';
const props = defineProps<{
workflow: IWorkflowDb;
}>();
@ -35,6 +38,8 @@ const uiStore = useUIStore();
const message = useMessage();
const telemetry = useTelemetry();
const sourceControlStore = useSourceControlStore();
const settingsStore = useSettingsStore();
const { isEligibleForMcpAccess } = useMcp();
const isPopoverOpen = ref(false);
const cachedSettings = ref<WorkflowSettings | null>(null);
@ -67,6 +72,10 @@ const isProtectedEnvironment = computed(() => {
return sourceControlStore.preferences.branchReadOnly;
});
const isMcpAvailable = computed(() => {
return settingsStore.isModuleActive('mcp') && isEligibleForMcpAccess(props.workflow);
});
const availableActions = computed(() => {
if (!props.workflow.active || workflowsCache.isCacheLoading.value) {
return [];
@ -118,6 +127,16 @@ const availableActions = computed(() => {
});
}
if (isMcpAvailable.value && !suggestedActionSettings['mcp-access']?.ignored) {
actions.push({
id: 'mcp-access',
title: i18n.baseText('mcp.productionCheklist.title'),
description: i18n.baseText('mcp.productionCheklist.description'),
moreInfoLink: MCP_DOCS_PAGE_URL,
completed: props.workflow.settings?.availableInMCP ?? false,
});
}
return actions;
});
@ -135,7 +154,11 @@ async function handleActionClick(actionId: string) {
name: VIEWS.EVALUATION_EDIT,
params: { name: props.workflow.id },
});
} else if (actionId === 'errorWorkflow' || actionId === 'timeSaved') {
} else if (
actionId === 'errorWorkflow' ||
actionId === 'timeSaved' ||
actionId === 'mcp-access'
) {
// Open workflow settings modal
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
}
@ -143,7 +166,7 @@ async function handleActionClick(actionId: string) {
}
function isValidAction(action: string): action is ActionType {
return ['evaluations', 'errorWorkflow', 'timeSaved'].includes(action);
return ['evaluations', 'errorWorkflow', 'timeSaved', 'mcp-access'].includes(action);
}
async function handleIgnoreClick(actionId: string) {

View File

@ -8,7 +8,6 @@ import Modal from '@/components/Modal.vue';
import {
EnterpriseEditionFeature,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
WEBHOOK_NODE_TYPE,
WORKFLOW_SETTINGS_MODAL_KEY,
} from '@/constants';
import type { WorkflowSettings } from 'n8n-workflow';
@ -26,6 +25,7 @@ import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useDebounce } from '@/composables/useDebounce';
import { injectWorkflowState } from '@/composables/useWorkflowState';
import { useMcp } from '@/features/mcpAccess/composables/useMcp';
import { ElCol, ElRow, ElSwitch } from 'element-plus';
import { N8nButton, N8nIcon, N8nInput, N8nOption, N8nSelect, N8nTooltip } from '@n8n/design-system';
@ -35,6 +35,7 @@ const externalHooks = useExternalHooks();
const toast = useToast();
const modalBus = createEventBus();
const telemetry = useTelemetry();
const { isEligibleForMcpAccess } = useMcp();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
@ -98,14 +99,9 @@ const workflowOwnerName = computed(() => {
});
const workflowPermissions = computed(() => getResourcePermissions(workflow.value?.scopes).workflow);
const isEligibleForMCPAccess = computed(() => {
if (!workflow.value?.active) {
return false;
}
// If it's active, check if workflow has at least one enabled webhook trigger:
return workflow.value?.nodes.some(
(node) => node.type === WEBHOOK_NODE_TYPE && node.disabled !== true,
);
const isEligibleForMcp = computed(() => {
if (!workflow?.value) return false;
return isEligibleForMcpAccess(workflow.value);
});
const onCallerIdsInput = (str: string) => {
@ -858,7 +854,7 @@ onBeforeUnmount(() => {
<N8nTooltip placement="top">
<template #content>
{{
isEligibleForMCPAccess
isEligibleForMcp
? i18n.baseText('workflowSettings.availableInMCP.tooltip')
: i18n.baseText('mcp.workflowNotEligable.description')
}}
@ -869,13 +865,13 @@ onBeforeUnmount(() => {
</ElCol>
<ElCol :span="14">
<div>
<N8nTooltip placement="top" :disabled="isEligibleForMCPAccess">
<N8nTooltip placement="top" :disabled="isEligibleForMcp">
<template #content>
{{ i18n.baseText('mcp.workflowNotEligable.description') }}
</template>
<ElSwitch
ref="inputField"
:disabled="readOnlyEnv || !workflowPermissions.update || !isEligibleForMCPAccess"
:disabled="readOnlyEnv || !workflowPermissions.update || !isEligibleForMcp"
:model-value="workflowSettings.availableInMCP ?? false"
data-test-id="workflow-settings-available-in-mcp"
@update:model-value="toggleAvailableInMCP"

View File

@ -2,7 +2,7 @@ import { indexedDbCache } from '@/plugins/cache';
import { jsonParse } from 'n8n-workflow';
import { ref } from 'vue';
const actionTypes = ['evaluations', 'errorWorkflow', 'timeSaved'] as const;
const actionTypes = ['evaluations', 'errorWorkflow', 'timeSaved', 'mcp-access'] as const;
export type ActionType = (typeof actionTypes)[number];

View File

@ -71,14 +71,6 @@ const tableHeaders = ref<Array<TableHeader<WorkflowListItem>>>([
return;
},
},
{
title: i18n.baseText('workflowDetails.active'),
key: 'active',
disableSort: true,
value() {
return;
},
},
{
title: '',
key: 'actions',
@ -160,7 +152,7 @@ const onWorkflowAction = async (action: string, workflow: WorkflowListItem) => {
case 'removeFromMCP':
try {
await workflowsStore.updateWorkflowSetting(workflow.id, 'availableInMCP', false);
await fetchAvailableWorkflows();
availableWorkflows.value = availableWorkflows.value.filter((w) => w.id !== workflow.id);
} catch (error) {
toast.showError(error, i18n.baseText('workflowSettings.toggleMCP.error.title'));
}
@ -366,13 +358,6 @@ onMounted(async () => {
</span>
<N8nText v-else data-test-id="mcp-workflow-no-project">-</N8nText>
</template>
<template #[`item.active`]="{ item }">
<N8nIcon
:icon="item.active ? 'check' : 'x'"
:size="16"
:color="item.active ? 'success' : 'danger'"
/>
</template>
<template #[`item.actions`]="{ item }">
<N8nActionToggle
placement="bottom"

View File

@ -14,10 +14,9 @@ import {
} from '@n8n/design-system';
import type { ApiKey } from '@n8n/api-types';
import ConnectionParameter from './ConnectionParameter.vue';
import { MCP_DOCS_PAGE_URL } from '@/features/mcpAccess/mcp.constants';
const MCP_ENDPOINT = 'mcp-server/http';
// TODO: Update once docs page is ready
const DOCS_URL = 'https://docs.n8n.io/';
type Props = {
baseUrl: string;
@ -37,19 +36,17 @@ const i18n = useI18n();
// mcp.json value that's to be copied
const connectionString = computed(() => {
return `
{
"mcpServers": {
"n8n-mcp": {
"command": "npx",
"args": [
"-y",
"supergateway",
"--streamableHttp",
"${props.baseUrl}${MCP_ENDPOINT}",
"--header",
"authorization:Bearer ${apiKeyText.value}"
]
}
"mcpServers": {
"n8n-mcp": {
"command": "npx",
"args": [
"-y",
"supergateway",
"--streamableHttp",
"${props.baseUrl}${MCP_ENDPOINT}",
"--header",
"authorization:Bearer ${apiKeyText.value}"
]
}
}
`;
@ -157,7 +154,7 @@ const apiKeyText = computed(() => {
</div>
<N8nText size="small" class="mt-m">
{{ i18n.baseText('settings.mcp.instructions.docs.part1') }}
<a :href="DOCS_URL" target="_blank">
<a :href="MCP_DOCS_PAGE_URL" target="_blank">
{{ i18n.baseText('settings.mcp.instructions.docs.part2') }}
</a>
</N8nText>

View File

@ -0,0 +1,20 @@
import { WEBHOOK_NODE_TYPE } from '@/constants';
import type { IWorkflowDb } from '@/Interface';
export function useMcp() {
/**
* Checks if MCP access can be enabled for the given workflow.
* A workflow is eligible if it is active and has at least one enabled webhook trigger.
*/
const isEligibleForMcpAccess = (workflow: IWorkflowDb) => {
if (!workflow.active) {
return false;
}
// If it's active, check if workflow has at least one enabled webhook trigger:
return workflow.nodes.some((node) => node.type === WEBHOOK_NODE_TYPE && node.disabled !== true);
};
return {
isEligibleForMcpAccess,
};
}

View File

@ -3,3 +3,6 @@ export const MCP_SETTINGS_VIEW = 'McpSettings';
export const MCP_STORE = 'mcp';
export const LOADING_INDICATOR_TIMEOUT = 200;
// TODO: Update when we have the final docs page URL
export const MCP_DOCS_PAGE_URL = 'https://docs.n8n.io';

View File

@ -64,6 +64,24 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
workflowId,
availableInMCP,
);
const { id, settings, versionId } = response;
// Update local version of the workflow
if (id === workflowsStore.workflowId) {
workflowsStore.setWorkflowVersionId(versionId);
if (settings) {
workflowsStore.private.setWorkflowSettings(settings);
}
}
if (workflowsStore.workflowsById[id]) {
workflowsStore.workflowsById[id] = {
...workflowsStore.workflowsById[id],
settings,
versionId,
};
}
return response;
}

View File

@ -1055,12 +1055,22 @@ const handleDismissReadyToRunCallout = () => {
readyToRunWorkflowsStore.trackDismissCallout();
};
const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
const onWorkflowActiveToggle = async (data: { id: string; active: boolean }) => {
const workflow: WorkflowListItem | undefined = workflowsAndFolders.value.find(
(w): w is WorkflowListItem => w.id === data.id,
);
if (!workflow) return;
workflow.active = data.active;
// Fetch the updated workflow to get the latest settings
try {
const updatedWorkflow = await workflowsStore.fetchWorkflow(data.id);
if (updatedWorkflow.settings) {
workflow.settings = updatedWorkflow.settings;
}
} catch (error) {
toast.showError(error, i18n.baseText('workflows.list.error.fetching.one'));
}
};
const getFolderListItem = (folderId: string): FolderListItem | undefined => {