mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 09:17:08 +02:00
fix(editor): Address MCP feedback (no-changelog) (#20595)
This commit is contained in:
parent
fd3caae509
commit
f15fd87bab
|
|
@ -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 */;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
60
packages/cli/src/modules/mcp/mcp.event-relay.ts
Normal file
60
packages/cli/src/modules/mcp/mcp.event-relay.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1152,6 +1152,10 @@
|
|||
"mainSidebar.whatsNew": "What’s 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}",
|
||||
|
|
|
|||
|
|
@ -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']">
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user