feat(core): Auto-expose new workflows to MCP from settings via toggle (#35828)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
João Almeida 2026-08-13 09:29:07 +00:00 committed by GitHub
parent b9496b2fa6
commit a74fbab2bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 768 additions and 63 deletions

View File

@ -325,6 +325,8 @@ export type FrontendModuleSettings = {
mcpManagedByEnv: boolean;
/** Public URL of the instance MCP server endpoint. */
serverUrl?: string;
/** Whether newly created workflows are auto-exposed to MCP. */
autoExposeNewWorkflows: boolean;
};
/**

View File

@ -0,0 +1,34 @@
import { z } from 'zod/v4';
import { defineTelemetryEvents } from '../define';
export const MCP_TELEMETRY = defineTelemetryEvents({
USER_GAVE_MCP_ACCESS_TO_WORKFLOW: {
name: 'User gave MCP access to workflow',
description: 'A workflow was exposed over MCP.',
properties: z.object({
workflow_id: z.string(),
}),
},
USER_TOGGLED_MCP_ACCESS: {
name: 'User toggled MCP access',
description:
'An admin turned instance-level MCP access on or off. Reports the resulting state.',
properties: z.object({
state: z.boolean().describe('Resulting state of MCP access, not the prior one'),
}),
},
AUTO_EXPOSE_NEW_WORKFLOWS_TOGGLED: {
name: 'User toggled auto-expose new workflows to MCP',
description:
'An admin turned the "Auto-expose new workflows" MCP setting on or off. Reports the resulting state so enabling and disabling are distinguishable.',
properties: z.object({
enabled: z.boolean().describe('Resulting state of the setting, not the prior one'),
source: z
.enum(['settings', 'expose_all'])
.describe(
'Where the toggle came from: "settings" for the manual MCP settings switch, "expose_all" when enabled automatically as part of exposing all workflows',
),
}),
},
});

View File

@ -3,6 +3,7 @@ import { AGENTS_TELEMETRY } from './events/agents';
import { CREDENTIALS_TELEMETRY } from './events/credentials';
import { INSTANCE_TELEMETRY } from './events/instance';
import { INSTANCE_AI_TELEMETRY } from './events/instance-ai';
import { MCP_TELEMETRY } from './events/mcp';
import { PLATFORM_TELEMETRY } from './events/platform';
export const TELEMETRY_EVENT = {
@ -11,4 +12,5 @@ export const TELEMETRY_EVENT = {
CREDENTIALS: CREDENTIALS_TELEMETRY,
INSTANCE: INSTANCE_TELEMETRY,
INSTANCE_AI: INSTANCE_AI_TELEMETRY,
MCP: MCP_TELEMETRY,
} satisfies TelemetryEventRegistry;

View File

@ -203,7 +203,7 @@ describe('PATCH /mcp/settings', () => {
.send({ mcpAccessEnabled: true });
expect(response.statusCode).toBe(200);
expect(response.body.data).toEqual({ mcpAccessEnabled: true });
expect(response.body.data).toEqual({ mcpAccessEnabled: true, autoExposeNewWorkflows: false });
const stored = await Container.get(SettingsRepository).findByKey(settingsKey);
expect(stored?.value).toBe('true');
@ -218,7 +218,7 @@ describe('PATCH /mcp/settings', () => {
.send({ mcpAccessEnabled: false });
expect(response.statusCode).toBe(200);
expect(response.body.data).toEqual({ mcpAccessEnabled: false });
expect(response.body.data).toEqual({ mcpAccessEnabled: false, autoExposeNewWorkflows: false });
const stored = await Container.get(SettingsRepository).findByKey(settingsKey);
expect(stored?.value).toBe('false');
@ -242,6 +242,12 @@ describe('PATCH /mcp/settings', () => {
expect(response.statusCode).toBe(400);
});
test('rejects an empty body with 400', async () => {
const response = await testServer.authAgentFor(owner).patch('/mcp/settings').send({});
expect(response.statusCode).toBe(400);
});
test('requires authentication', async () => {
const response = await testServer.authlessAgent
.patch('/mcp/settings')

View File

@ -111,6 +111,9 @@ describe('McpSettingsController', () => {
const req = createReq({ mcpAccessEnabled: false }, { user });
const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: false });
mcpSettingsService.setEnabled.mockResolvedValue(undefined);
mcpSettingsService.setAutoExposeNewWorkflows.mockResolvedValue(undefined);
mcpSettingsService.getEnabled.mockResolvedValue(false);
mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(false);
moduleRegistry.refreshModuleSettings.mockResolvedValue(null);
const res = createRes();
@ -122,7 +125,8 @@ describe('McpSettingsController', () => {
user,
enabled: false,
});
expect(result).toEqual({ mcpAccessEnabled: false });
expect(mcpSettingsService.setAutoExposeNewWorkflows).not.toHaveBeenCalled();
expect(result).toEqual({ mcpAccessEnabled: false, autoExposeNewWorkflows: false });
});
test('enables MCP access correctly', async () => {
@ -130,6 +134,8 @@ describe('McpSettingsController', () => {
const req = createReq({ mcpAccessEnabled: true }, { user });
const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: true });
mcpSettingsService.setEnabled.mockResolvedValue(undefined);
mcpSettingsService.getEnabled.mockResolvedValue(true);
mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(true);
moduleRegistry.refreshModuleSettings.mockResolvedValue(null);
const res = createRes();
@ -141,7 +147,9 @@ describe('McpSettingsController', () => {
user,
enabled: true,
});
expect(result).toEqual({ mcpAccessEnabled: true });
// The response reflects the persisted state read back via the getters,
// surfacing the real stored auto-expose value on enable.
expect(result).toEqual({ mcpAccessEnabled: true, autoExposeNewWorkflows: true });
});
test('handles module registry refresh failure gracefully', async () => {
@ -150,6 +158,8 @@ describe('McpSettingsController', () => {
const error = new Error('Registry sync failed');
mcpSettingsService.setEnabled.mockResolvedValue(undefined);
mcpSettingsService.getEnabled.mockResolvedValue(true);
mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(false);
moduleRegistry.refreshModuleSettings.mockRejectedValue(error);
const res = createRes();
@ -160,7 +170,22 @@ describe('McpSettingsController', () => {
expect(logger.warn).toHaveBeenCalledWith('Failed to sync MCP settings to module registry', {
cause: 'Registry sync failed',
});
expect(result).toEqual({ mcpAccessEnabled: true });
expect(result).toEqual({ mcpAccessEnabled: true, autoExposeNewWorkflows: false });
});
test('handles a non-Error rejection from the module registry refresh', async () => {
const req = createReq({ mcpAccessEnabled: true }, { user: createUser() });
const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: true });
mcpSettingsService.setEnabled.mockResolvedValue(undefined);
moduleRegistry.refreshModuleSettings.mockRejectedValue('registry unavailable');
const res = createRes();
await controller.updateSettings(req, res, dto);
expect(logger.warn).toHaveBeenCalledWith('Failed to sync MCP settings to module registry', {
cause: 'registry unavailable',
});
});
test('rejects updates when MCP settings are managed by env', async () => {
@ -177,9 +202,59 @@ describe('McpSettingsController', () => {
});
test('requires boolean mcpAccessEnabled value', () => {
expect(() => new UpdateMcpSettingsDto({} as never)).toThrow();
expect(() => new UpdateMcpSettingsDto({ mcpAccessEnabled: 'yes' } as never)).toThrow();
});
test('updates both fields when both are patched', async () => {
const user = createUser();
const req = createReq({ mcpAccessEnabled: true, autoExposeNewWorkflows: false }, { user });
const dto = UpdateMcpSettingsDto.parse({
mcpAccessEnabled: true,
autoExposeNewWorkflows: false,
});
mcpSettingsService.setEnabled.mockResolvedValue(undefined);
mcpSettingsService.getEnabled.mockResolvedValue(true);
mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(false);
moduleRegistry.refreshModuleSettings.mockResolvedValue(null);
const res = createRes();
const result = await controller.updateSettings(req, res, dto);
expect(mcpSettingsService.setEnabled).toHaveBeenCalledWith(true);
expect(mcpSettingsService.setAutoExposeNewWorkflows).toHaveBeenCalledWith(false);
expect(result).toEqual({ mcpAccessEnabled: true, autoExposeNewWorkflows: false });
});
test('touches only autoExposeNewWorkflows when mcpAccessEnabled is not patched', async () => {
const user = createUser();
const req = createReq({ autoExposeNewWorkflows: true }, { user });
const dto = UpdateMcpSettingsDto.parse({ autoExposeNewWorkflows: true });
mcpSettingsService.setAutoExposeNewWorkflows.mockResolvedValue(undefined);
mcpSettingsService.getEnabled.mockResolvedValue(true);
mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(true);
moduleRegistry.refreshModuleSettings.mockResolvedValue(null);
const res = createRes();
const result = await controller.updateSettings(req, res, dto);
expect(mcpSettingsService.setEnabled).not.toHaveBeenCalled();
expect(mcpSettingsService.setAutoExposeNewWorkflows).toHaveBeenCalledWith(true);
expect(eventService.emit).not.toHaveBeenCalled();
expect(result).toEqual({ mcpAccessEnabled: true, autoExposeNewWorkflows: true });
});
test('rejects an empty body', () => {
const result = UpdateMcpSettingsDto.safeParse({});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors[0].message).toBe(
'Provide at least one of mcpAccessEnabled or autoExposeNewWorkflows',
);
}
expect(mcpSettingsService.setEnabled).not.toHaveBeenCalled();
expect(mcpSettingsService.setAutoExposeNewWorkflows).not.toHaveBeenCalled();
});
});
describe('getApiKeyForMcpServer', () => {
@ -379,6 +454,7 @@ describe('McpSettingsController', () => {
skippedCount: 0,
failedCount: 0,
});
expect(moduleRegistry.refreshModuleSettings).not.toHaveBeenCalled();
});
});

View File

@ -57,7 +57,7 @@ describe('McpSettingsService', () => {
findByKey.mockResolvedValue(null);
await expect(service.getEnabled()).resolves.toBe(false);
expect(findByKey).toHaveBeenCalledWith('mcp.access.enabled');
expect(findByKey).toHaveBeenCalledWith('mcp.access.enabled', undefined);
});
test('returns true when setting value is "true"', async () => {
@ -97,6 +97,74 @@ describe('McpSettingsService', () => {
});
});
describe('getAutoExposeNewWorkflows', () => {
beforeEach(() => {
vi.spyOn(service, 'getEnabled').mockResolvedValue(true);
});
test('returns false when MCP access is disabled, without reading the setting', async () => {
vi.spyOn(service, 'getEnabled').mockResolvedValue(false);
await expect(service.getAutoExposeNewWorkflows()).resolves.toBe(false);
expect(cacheService.get).not.toHaveBeenCalledWith('mcp.autoExposeNewWorkflows');
expect(findByKey).not.toHaveBeenCalled();
});
test('returns false by default when no setting exists', async () => {
cacheService.get.mockResolvedValue(undefined);
findByKey.mockResolvedValue(null);
await expect(service.getAutoExposeNewWorkflows()).resolves.toBe(false);
expect(findByKey).toHaveBeenCalledWith('mcp.autoExposeNewWorkflows', undefined);
expect(cacheService.set).toHaveBeenCalledWith('mcp.autoExposeNewWorkflows', 'false');
});
test('returns the cached value without hitting the database', async () => {
cacheService.get.mockResolvedValue('true');
await expect(service.getAutoExposeNewWorkflows()).resolves.toBe(true);
expect(findByKey).not.toHaveBeenCalled();
});
test('reads through to the database on a cache miss', async () => {
cacheService.get.mockResolvedValue(undefined);
findByKey.mockResolvedValue(
mock<Settings>({
key: 'mcp.autoExposeNewWorkflows',
value: 'true',
loadOnStartup: true,
}),
);
await expect(service.getAutoExposeNewWorkflows()).resolves.toBe(true);
expect(findByKey).toHaveBeenCalledWith('mcp.autoExposeNewWorkflows', undefined);
});
test('forwards the entity manager to both settings reads on a cache miss', async () => {
vi.spyOn(service, 'getEnabled').mockRestore();
cacheService.get.mockResolvedValue(undefined);
findByKey.mockResolvedValue(mock<Settings>({ key: 'x', value: 'true', loadOnStartup: true }));
const em = mock<EntityManager>();
await service.getAutoExposeNewWorkflows(em);
expect(findByKey).toHaveBeenCalledWith('mcp.access.enabled', em);
expect(findByKey).toHaveBeenCalledWith('mcp.autoExposeNewWorkflows', em);
});
});
describe('setAutoExposeNewWorkflows', () => {
test('persists with loadOnStartup and primes the cache', async () => {
await service.setAutoExposeNewWorkflows(true);
expect(upsert).toHaveBeenCalledWith(
{ key: 'mcp.autoExposeNewWorkflows', value: 'true', loadOnStartup: true },
['key'],
);
expect(cacheService.set).toHaveBeenCalledWith('mcp.autoExposeNewWorkflows', 'true');
});
});
describe('getAllowedRedirectUris', () => {
test('returns empty array by default when no setting exists', async () => {
findByKey.mockResolvedValue(null);
@ -684,6 +752,7 @@ describe('McpSettingsService', () => {
});
expect(result).not.toHaveProperty('updatedIds');
expect(result).not.toHaveProperty('unchangedIds');
expect(result).not.toHaveProperty('autoExposeNewWorkflows');
});
test('does not resolve folder-scoped workflows when folder project cannot be scoped', async () => {

View File

@ -1,6 +1,28 @@
import { Z } from '@n8n/api-types';
import { z } from 'zod';
export class UpdateMcpSettingsDto extends Z.class({
mcpAccessEnabled: z.boolean(),
}) {}
const updateMcpSettingsShape = {
mcpAccessEnabled: z.boolean().optional(),
autoExposeNewWorkflows: z.boolean().optional(),
};
const updateMcpSettingsSchema = z
.object(updateMcpSettingsShape)
.refine(
(value) => value.mcpAccessEnabled !== undefined || value.autoExposeNewWorkflows !== undefined,
{ message: 'Provide at least one of mcpAccessEnabled or autoExposeNewWorkflows' },
);
export class UpdateMcpSettingsDto extends Z.class(updateMcpSettingsShape) {
constructor(data: z.infer<typeof updateMcpSettingsSchema>) {
super(updateMcpSettingsSchema.parse(data));
}
static override safeParse(data: unknown) {
return updateMcpSettingsSchema.safeParse(data);
}
static override parse(data: unknown) {
return updateMcpSettingsSchema.parse(data);
}
}

View File

@ -27,17 +27,19 @@ export class McpModule implements ModuleInterface {
* Settings exposed to the frontend under `/rest/module-settings`.
*
* The response shape will be
* `{ mcp: { mcpAccessEnabled: boolean, mcpManagedByEnv: boolean, serverUrl: string } }`.
* `{ mcp: { mcpAccessEnabled: boolean, mcpManagedByEnv: boolean, serverUrl: string, autoExposeNewWorkflows: boolean } }`.
*/
async settings() {
const { McpSettingsService } = await import('./mcp.settings.service.js');
const { McpProtectedResource } = await import('./mcp-protected-resource.js');
const mcpAccessEnabled = await Container.get(McpSettingsService).getEnabled();
const mcpSettingsService = Container.get(McpSettingsService);
const mcpAccessEnabled = await mcpSettingsService.getEnabled();
const autoExposeNewWorkflows = await mcpSettingsService.getAutoExposeNewWorkflows();
const { mcpManagedByEnv } = Container.get(InstanceSettingsLoaderConfig);
// Canonical resource URL, so the UI shows the URL clients must actually
// use when a dedicated MCP base URL is configured.
const serverUrl = Container.get(McpProtectedResource).getResourceUrl();
return { mcpAccessEnabled, mcpManagedByEnv, serverUrl };
return { mcpAccessEnabled, mcpManagedByEnv, serverUrl, autoExposeNewWorkflows };
}
@OnShutdown()

View File

@ -34,17 +34,29 @@ export class McpSettingsController {
if (this.instanceSettingsLoaderConfig.mcpManagedByEnv) {
throw new ForbiddenError('MCP settings are managed via environment variables');
}
const enabled = dto.mcpAccessEnabled;
await this.mcpSettingsService.setEnabled(enabled);
this.eventService.emit('mcp-access-updated', { user: req.user, enabled });
try {
await this.moduleRegistry.refreshModuleSettings('mcp');
} catch (error) {
this.logger.warn('Failed to sync MCP settings to module registry', {
cause: error instanceof Error ? error.message : String(error),
if (dto.mcpAccessEnabled !== undefined) {
await this.mcpSettingsService.setEnabled(dto.mcpAccessEnabled);
this.eventService.emit('mcp-access-updated', {
user: req.user,
enabled: dto.mcpAccessEnabled,
});
}
return { mcpAccessEnabled: enabled };
if (dto.autoExposeNewWorkflows !== undefined) {
await this.mcpSettingsService.setAutoExposeNewWorkflows(dto.autoExposeNewWorkflows);
}
await this.refreshMcpModuleSettings();
// Always return both values (read back from the source of truth) so the
// client can sync local state without a follow-up module-settings fetch.
const [mcpAccessEnabled, autoExposeNewWorkflows] = await Promise.all([
this.mcpSettingsService.getEnabled(),
this.mcpSettingsService.getAutoExposeNewWorkflows(),
]);
return { mcpAccessEnabled, autoExposeNewWorkflows };
}
@GlobalScope('mcpApiKey:create')
@ -116,4 +128,14 @@ export class McpSettingsController {
return result;
}
private async refreshMcpModuleSettings() {
try {
await this.moduleRegistry.refreshModuleSettings('mcp');
} catch (error) {
this.logger.warn('Failed to sync MCP settings to module registry', {
cause: error instanceof Error ? error.message : String(error),
});
}
}
}

View File

@ -1,7 +1,12 @@
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { SettingsRepository, WorkflowEntity, WorkflowRepository } from '@n8n/db';
import {
SettingsRepository,
WorkflowEntity,
WorkflowRepository,
type User,
type EntityManager,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { In } from '@n8n/typeorm';
import {
@ -21,6 +26,7 @@ import type { UpdateWorkflowsAvailabilityDto } from './dto/update-workflows-avai
const KEY = 'mcp.access.enabled';
const REDIRECT_URIS_KEY = 'mcp.oauth.allowedRedirectUris';
const AUTO_EXPOSE_NEW_WORKFLOWS_KEY = 'mcp.autoExposeNewWorkflows';
const BULK_CHUNK_SIZE = 500;
@ -55,14 +61,14 @@ export class McpSettingsService {
private readonly collaborationService: CollaborationService,
) {}
async getEnabled(): Promise<boolean> {
async getEnabled(em?: EntityManager): Promise<boolean> {
const isMcpAccessEnabled = await this.cacheService.get<string>(KEY);
if (isMcpAccessEnabled !== undefined) {
return isMcpAccessEnabled === 'true';
}
const row = await this.settingsRepository.findByKey(KEY);
const row = await this.settingsRepository.findByKey(KEY, em);
const enabled = row?.value === 'true';
@ -105,6 +111,35 @@ export class McpSettingsService {
await this.cacheService.set(REDIRECT_URIS_KEY, JSON.stringify(uris));
}
async getAutoExposeNewWorkflows(em?: EntityManager): Promise<boolean> {
if (!(await this.getEnabled(em))) {
return false;
}
const cached = await this.cacheService.get<string>(AUTO_EXPOSE_NEW_WORKFLOWS_KEY);
if (cached !== undefined) {
return cached === 'true';
}
const row = await this.settingsRepository.findByKey(AUTO_EXPOSE_NEW_WORKFLOWS_KEY, em);
const enabled = row?.value === 'true';
await this.cacheService.set(AUTO_EXPOSE_NEW_WORKFLOWS_KEY, enabled.toString());
return enabled;
}
async setAutoExposeNewWorkflows(enabled: boolean): Promise<void> {
await this.settingsRepository.upsert(
{ key: AUTO_EXPOSE_NEW_WORKFLOWS_KEY, value: enabled.toString(), loadOnStartup: true },
['key'],
);
await this.cacheService.set(AUTO_EXPOSE_NEW_WORKFLOWS_KEY, enabled.toString());
}
async bulkSetAvailableInMCP(
user: User,
dto: UpdateWorkflowsAvailabilityDto,

View File

@ -1,4 +1,4 @@
import type { LicenseState } from '@n8n/backend-common';
import type { Logger, LicenseState } from '@n8n/backend-common';
import type { ProjectRepository, Role, User } from '@n8n/db';
import { WorkflowEntity } from '@n8n/db';
import type { MockProxy } from 'vitest-mock-extended';
@ -9,6 +9,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { ExternalHooks, WorkflowLifecycleHookActor } from '@/external-hooks';
import type { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
import type { InstanceRedactionEnforcementService } from '@/modules/redaction/instance-redaction-enforcement.service';
import type { NodeTypes } from '@/node-types';
import { userHasScopes } from '@/permissions.ee/check-access';
@ -40,10 +41,13 @@ describe('WorkflowCreationService', () => {
let externalHooksMock: MockProxy<ExternalHooks>;
let workflowFinderServiceMock: MockProxy<WorkflowFinderService>;
let workflowHookContextServiceMock: MockProxy<WorkflowHookContextService>;
let mcpSettingsService: MockProxy<McpSettingsService>;
let loggerMock: MockProxy<Logger>;
beforeEach(() => {
vi.clearAllMocks();
loggerMock = mock<Logger>();
credentialsServiceMock = mock<CredentialsService>();
enterpriseWorkflowServiceMock = mock<EnterpriseWorkflowService>();
licenseStateMock = mock<LicenseState>();
@ -62,8 +66,10 @@ describe('WorkflowCreationService', () => {
// Default: no active floor. Tests opt into a floor explicitly.
instanceRedactionEnforcementServiceMock.get.mockResolvedValue('off');
mcpSettingsService = mock<McpSettingsService>();
workflowCreationService = new WorkflowCreationService(
mock(), // logger
loggerMock,
mock(), // sharedWorkflowRepository
mock(), // tagService
workflowHistoryServiceMock,
@ -82,9 +88,19 @@ describe('WorkflowCreationService', () => {
workflowValidationServiceMock,
instanceRedactionEnforcementServiceMock,
workflowHookContextServiceMock,
mcpSettingsService,
);
});
function makeWorkflow(overrides: Partial<WorkflowEntity> = {}): WorkflowEntity {
const workflow = new WorkflowEntity();
workflow.name = 'Test';
workflow.nodes = [];
workflow.connections = {};
Object.assign(workflow, overrides);
return workflow;
}
function setupTransactionMocks(
options: {
personalProjectId?: string;
@ -698,4 +714,92 @@ describe('WorkflowCreationService', () => {
).rejects.toBeInstanceOf(ForbiddenError);
});
});
describe('auto-expose new workflows in MCP', () => {
const user = mock<User>();
beforeEach(() => {
projectServiceMock.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never);
licenseStateMock.isSharingLicensed.mockReturnValue(false);
licenseStateMock.isDataRedactionLicensed.mockReturnValue(false);
const { transactionManager } = setupTransactionMocks();
transactionManager.save.mockImplementation(async (entity: unknown) => entity);
workflowHistoryServiceMock.saveVersion.mockResolvedValue(undefined as never);
workflowFinderServiceMock.findWorkflowForUser.mockImplementation(
async () => new WorkflowEntity(),
);
});
it('seeds availableInMCP when unset and the setting is on', async () => {
mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(true);
const workflow = makeWorkflow({ settings: {} });
await workflowCreationService.createWorkflow(user, workflow, { projectId: 'project-1' });
expect(workflow.settings?.availableInMCP).toBe(true);
});
it('seeds availableInMCP when settings is entirely absent', async () => {
mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(true);
const workflow = makeWorkflow({ settings: undefined });
await workflowCreationService.createWorkflow(user, workflow, { projectId: 'project-1' });
expect(workflow.settings?.availableInMCP).toBe(true);
});
it.each([
{ explicitValue: false, settingValue: true },
{ explicitValue: true, settingValue: false },
])(
'respects an explicit $explicitValue from the caller over a setting of $settingValue',
async ({ explicitValue, settingValue }) => {
mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(settingValue);
const workflow = makeWorkflow({ settings: { availableInMCP: explicitValue } });
await workflowCreationService.createWorkflow(user, workflow, { projectId: 'project-1' });
expect(workflow.settings?.availableInMCP).toBe(explicitValue);
},
);
it('does not seed when the setting is off', async () => {
mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(false);
const workflow = makeWorkflow({ settings: {} });
await workflowCreationService.createWorkflow(user, workflow, { projectId: 'project-1' });
expect(workflow.settings?.availableInMCP).toBeUndefined();
});
it('still creates the workflow when reading the auto-expose setting throws', async () => {
mcpSettingsService.getAutoExposeNewWorkflows.mockRejectedValue(new Error('cache down'));
const workflow = makeWorkflow({ settings: {} });
await expect(
workflowCreationService.createWorkflow(user, workflow, { projectId: 'project-1' }),
).resolves.not.toThrow();
expect(workflow.settings?.availableInMCP).toBeUndefined();
expect(loggerMock.warn).toHaveBeenCalledWith(
'Failed to resolve auto-expose setting for new workflow',
{ cause: 'cache down' },
);
});
it('still creates the workflow when the auto-expose setting lookup rejects with a non-Error', async () => {
mcpSettingsService.getAutoExposeNewWorkflows.mockRejectedValue('cache down');
const workflow = makeWorkflow({ settings: {} });
await expect(
workflowCreationService.createWorkflow(user, workflow, { projectId: 'project-1' }),
).resolves.not.toThrow();
expect(workflow.settings?.availableInMCP).toBeUndefined();
expect(loggerMock.warn).toHaveBeenCalledWith(
'Failed to resolve auto-expose setting for new workflow',
{ cause: 'cache down' },
);
});
});
});

View File

@ -23,6 +23,7 @@ import { EventService } from '@/events/event.service';
import type { WorkflowActionSource } from '@/events/maps/relay.event-map';
import { ExternalHooks, toWorkflowLifecycleHookActor } from '@/external-hooks';
import { validateEntity } from '@/generic-helpers';
import { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
import { InstanceRedactionEnforcementService } from '@/modules/redaction/instance-redaction-enforcement.service';
import { policyForFloor, policyMeetsFloor } from '@/modules/redaction/redaction-policy';
import { NodeTypes } from '@/node-types';
@ -61,6 +62,7 @@ export class WorkflowCreationService {
private readonly workflowValidationService: WorkflowValidationService,
private readonly instanceRedactionEnforcementService: InstanceRedactionEnforcementService,
private readonly workflowHookContextService: WorkflowHookContextService,
private readonly mcpSettingsService: McpSettingsService,
) {}
async createWorkflow(
@ -208,6 +210,8 @@ export class WorkflowCreationService {
floor,
);
await this.resolveMcpExposureOnCreate(newWorkflow, transactionManager);
if (parentFolderId && parentFolderId !== PROJECT_ROOT) {
newWorkflow.parentFolder = await this.findParentFolderInProjectOrFail(
parentFolderId,
@ -339,4 +343,25 @@ export class WorkflowCreationService {
newWorkflow.settings = { ...(newWorkflow.settings ?? {}), redactionPolicy: seed };
}
private async resolveMcpExposureOnCreate(
newWorkflow: WorkflowEntity,
transactionManager: EntityManager,
): Promise<void> {
if (newWorkflow.settings?.availableInMCP !== undefined) return;
try {
// Read through the create transaction's connection: a settings read on a
// separate pool connection would deadlock small pools (the transaction
// holds one, the read waits for another that never frees).
if (!(await this.mcpSettingsService.getAutoExposeNewWorkflows(transactionManager))) return;
} catch (error) {
this.logger.warn('Failed to resolve auto-expose setting for new workflow', {
cause: error instanceof Error ? error.message : String(error),
});
return;
}
newWorkflow.settings = { ...(newWorkflow.settings ?? {}), availableInMCP: true };
}
}

View File

@ -3248,6 +3248,9 @@
"settings.mcp.workflowsExposed.count": "{count} workflow | {count} workflows",
"settings.mcp.workflowsExposed.page.title": "Workflows exposed",
"settings.mcp.workflowsExposed.page.description": "Choose which workflows connected clients can reach over MCP. Changes apply immediately.",
"settings.mcp.autoExpose.title": "Auto-expose new workflows",
"settings.mcp.autoExpose.description": "Automatically expose newly created workflows to connected clients",
"settings.mcp.autoExpose.error.title": "Could not update setting",
"settings.mcp.agentsExposed.title": "Agents exposed",
"settings.mcp.agentsExposed.description": "Choose which agents connected clients can access.",
"settings.mcp.agentsExposed.count": "{count} agent | {count} agents",

View File

@ -448,7 +448,11 @@ describe('WorkflowDescriptionModal', () => {
it('should show base tooltip when MCP is disabled', async () => {
// Ensure MCP is disabled
settingsStore.isModuleActive = vi.fn().mockReturnValue(false);
settingsStore.moduleSettings.mcp = { mcpAccessEnabled: false, mcpManagedByEnv: false };
settingsStore.moduleSettings.mcp = {
mcpAccessEnabled: false,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
};
const { getByTestId } = renderModal({
props: {
@ -475,7 +479,11 @@ describe('WorkflowDescriptionModal', () => {
it('should show MCP tooltip when MCP is enabled', async () => {
// Enable MCP module
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
settingsStore.moduleSettings.mcp = { mcpAccessEnabled: true, mcpManagedByEnv: false };
settingsStore.moduleSettings.mcp = {
mcpAccessEnabled: true,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
};
const { getByTestId } = renderModal({
props: {

View File

@ -732,7 +732,7 @@ describe('WorkflowProductionChecklist', () => {
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({
mcp: { mcpAccessEnabled: false, mcpManagedByEnv: false },
mcp: { mcpAccessEnabled: false, mcpManagedByEnv: false, autoExposeNewWorkflows: false },
});
vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(true);
@ -756,7 +756,7 @@ describe('WorkflowProductionChecklist', () => {
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({
mcp: { mcpAccessEnabled: false, mcpManagedByEnv: false },
mcp: { mcpAccessEnabled: false, mcpManagedByEnv: false, autoExposeNewWorkflows: false },
});
vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(false);
vi.spyOn(usersStore, 'isInstanceOwner', 'get').mockReturnValue(false);
@ -776,7 +776,7 @@ describe('WorkflowProductionChecklist', () => {
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({
mcp: { mcpAccessEnabled: true, mcpManagedByEnv: false },
mcp: { mcpAccessEnabled: true, mcpManagedByEnv: false, autoExposeNewWorkflows: false },
});
renderComponent({ pinia });
@ -798,7 +798,7 @@ describe('WorkflowProductionChecklist', () => {
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({
mcp: { mcpAccessEnabled: true, mcpManagedByEnv: false },
mcp: { mcpAccessEnabled: true, mcpManagedByEnv: false, autoExposeNewWorkflows: false },
});
workflowDocumentStoreRef.value?.setSettings({
@ -826,7 +826,7 @@ describe('WorkflowProductionChecklist', () => {
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({
mcp: { mcpAccessEnabled: false, mcpManagedByEnv: false },
mcp: { mcpAccessEnabled: false, mcpManagedByEnv: false, autoExposeNewWorkflows: false },
});
vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(true);
@ -851,7 +851,7 @@ describe('WorkflowProductionChecklist', () => {
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({
mcp: { mcpAccessEnabled: true, mcpManagedByEnv: false },
mcp: { mcpAccessEnabled: true, mcpManagedByEnv: false, autoExposeNewWorkflows: false },
});
renderComponent({ pinia });

View File

@ -384,6 +384,23 @@ describe('useWorkflowSaving', () => {
});
describe('saveAsNewWorkflow', () => {
it('syncs backend-seeded settings (e.g. availableInMCP) into the document after create', async () => {
const workflow = getDuplicateTestWorkflow();
const created = createTestWorkflow({
id: 'new-wf-id',
settings: { executionOrder: 'v1', availableInMCP: true },
});
vi.spyOn(workflowsStore, 'createNewWorkflow').mockResolvedValue(created);
const { saveAsNewWorkflow } = useWorkflowSaving({ router });
await saveAsNewWorkflow({ name: workflow.name, data: workflow });
const snapshot = useWorkflowDocumentStore(
createWorkflowDocumentId(created.id),
).getSettingsSnapshot();
expect(snapshot.availableInMCP).toBe(true);
});
it('should respect `resetWebhookUrls: false` when duplicating workflows', async () => {
const workflow = getDuplicateTestWorkflow();
if (!workflow.nodes) {

View File

@ -537,6 +537,10 @@ export function useWorkflowSaving({
});
workflowDocumentStore.setUpdatedAt(workflowData.updatedAt);
if (workflowData.settings) {
workflowDocumentStore.setSettings(workflowData.settings);
}
// Only update webhook IDs if we explicitly reset them
if (resetWebhookUrls) {
Object.keys(changedNodes).forEach((nodeName) => {

View File

@ -295,12 +295,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// make sure that the new ones are not active
sendData.active = false;
// When activation is false, ensure MCP is disabled
if (!sendData.settings) {
sendData.settings ??= {};
}
sendData.settings.availableInMCP = false;
const projectStore = useProjectsStore();
if (!sendData.projectId && projectStore.currentProjectId) {

View File

@ -8,6 +8,14 @@ import userEvent from '@testing-library/user-event';
import { defineComponent } from 'vue';
import ExposeAllWorkflowsToMcpModal from './ExposeAllWorkflowsToMcpModal.vue';
const { trackAutoExposeToggledSpy } = vi.hoisted(() => ({
trackAutoExposeToggledSpy: vi.fn(),
}));
vi.mock('@/features/ai/mcpAccess/composables/useMcp', () => ({
useMcp: () => ({ trackAutoExposeToggled: trackAutoExposeToggledSpy }),
}));
vi.mock('@n8n/composables/useToast', () => {
const showMessage = vi.fn();
const showError = vi.fn();
@ -60,6 +68,8 @@ describe('ExposeAllWorkflowsToMcpModal', () => {
skippedCount: 0,
failedCount: 0,
});
mcpStore.setAutoExposeNewWorkflows.mockResolvedValue(true);
});
it('renders the copy and both actions', () => {
@ -120,4 +130,15 @@ describe('ExposeAllWorkflowsToMcpModal', () => {
expect(useToast().showError).toHaveBeenCalled();
expect(experimentStore.trackConfirmed).not.toHaveBeenCalled();
});
it('enables auto-expose only after exposing all workflows succeeds', async () => {
const user = userEvent.setup();
const { getByTestId } = renderComponent({ pinia, props: defaultProps });
await user.click(getByTestId('expose-all-workflows-mcp-confirm-button'));
expect(mcpStore.toggleWorkflowsMcpAccess).toHaveBeenCalledWith({ allWorkflows: true }, true);
expect(mcpStore.setAutoExposeNewWorkflows).toHaveBeenCalledWith(true);
expect(trackAutoExposeToggledSpy).toHaveBeenCalledWith({ enabled: true, source: 'expose_all' });
});
});

View File

@ -5,6 +5,7 @@ import { useSettingsStore } from '@n8n/stores/settings.store';
import { EXPOSE_ALL_WORKFLOWS_TO_MCP_MODAL_KEY } from '@/experiments/exposeAllWorkflowsToMcp/constants';
import { useExposeAllWorkflowsToMcpStore } from '@/experiments/exposeAllWorkflowsToMcp/stores/exposeAllWorkflowsToMcp.store';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import { N8nButton, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { createEventBus } from '@n8n/utils/event-bus';
@ -18,6 +19,7 @@ const props = defineProps<{
const i18n = useI18n();
const toast = useToast();
const mcp = useMcp();
const mcpStore = useMCPStore();
const settingsStore = useSettingsStore();
const experimentStore = useExposeAllWorkflowsToMcpStore();
@ -82,6 +84,9 @@ async function onExposeAll(close: () => void) {
? mcpStore.toggleAgentsMcpAccess({ allAgents: true }, true)
: Promise.resolve(undefined),
]);
await mcpStore.setAutoExposeNewWorkflows(true);
mcp.trackAutoExposeToggled({ enabled: true, source: 'expose_all' });
closedByAction.value = true;
experimentStore.trackConfirmed();
toast.showMessage({

View File

@ -95,6 +95,7 @@ describe('SettingsMCPAgentsView', () => {
mcp: {
mcpAccessEnabled: true,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
},
};
settingsStore.isModuleActive.mockReturnValue(true);
@ -111,6 +112,7 @@ describe('SettingsMCPAgentsView', () => {
mcp: {
mcpAccessEnabled: false,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
},
};

View File

@ -62,6 +62,7 @@ describe('SettingsMCPClientsView', () => {
mcp: {
mcpAccessEnabled: true,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
},
};
@ -77,6 +78,7 @@ describe('SettingsMCPClientsView', () => {
mcp: {
mcpAccessEnabled: false,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
},
};

View File

@ -3,7 +3,7 @@ import { createTestingPinia } from '@pinia/testing';
import { waitFor, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import { mockedStore, type MockedStore, waitAllPromises } from '@/__tests__/utils';
import SettingsMCPView from '@/features/ai/mcpAccess/SettingsMCPView.vue';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
@ -16,16 +16,33 @@ import { useExposeAllWorkflowsToMcpStore } from '@/experiments/exposeAllWorkflow
import type { Agent } from '@/features/agents/agent.types';
import { UNKNOWN_COUNT_VALUE } from '@/features/ai/mcpAccess/mcp.constants';
import { useToast } from '@n8n/composables/useToast';
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }));
const { hasPermissionMock } = vi.hoisted(() => ({
hasPermissionMock: vi.fn().mockReturnValue(true),
}));
const { trackSpy, trackAutoExposeToggledSpy } = vi.hoisted(() => ({
trackSpy: vi.fn(),
trackAutoExposeToggledSpy: vi.fn(),
}));
vi.mock('@/app/utils/rbac/permissions', () => ({
hasPermission: hasPermissionMock,
}));
vi.mock('@n8n/composables/useTelemetry', () => ({
useTelemetry: () => ({ track: trackSpy }),
}));
vi.mock('@n8n/composables/useToast', () => {
const showMessage = vi.fn();
const showError = vi.fn();
return {
useToast: () => ({ showMessage, showError }),
};
});
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
useRouter: () => ({ push: routerPush, replace: vi.fn() }),
@ -46,6 +63,7 @@ vi.mock('@/app/composables/useDocumentTitle', () => ({
vi.mock('@/features/ai/mcpAccess/composables/useMcp', () => ({
useMcp: () => ({
trackUserToggledMcpAccess: vi.fn(),
trackAutoExposeToggled: trackAutoExposeToggledSpy,
}),
}));
@ -87,6 +105,7 @@ const enableMcpSettings = () => {
mcp: {
mcpAccessEnabled: true,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
},
};
};
@ -109,6 +128,7 @@ describe('SettingsMCPView', () => {
mcp: {
mcpAccessEnabled: false,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
},
};
@ -479,6 +499,7 @@ describe('SettingsMCPView', () => {
mcp: {
mcpAccessEnabled: false,
mcpManagedByEnv: true,
autoExposeNewWorkflows: false,
},
};
@ -602,4 +623,72 @@ describe('SettingsMCPView', () => {
expect(mcpStore.getInstanceClientStats).not.toHaveBeenCalled();
});
});
describe('auto-expose toggle', () => {
beforeEach(() => {
enableMcpSettings();
});
it('renders for a user with mcp:manage when the experiment is on', async () => {
hasPermissionMock.mockReturnValue(true);
exposeAllWorkflowsToMcpStore.isEnabled = true;
const { getByTestId } = createComponent({ pinia });
await waitAllPromises();
expect(getByTestId('mcp-auto-expose-toggle')).toBeTruthy();
});
it('is hidden without the experiment flag', async () => {
hasPermissionMock.mockReturnValue(true);
exposeAllWorkflowsToMcpStore.isEnabled = false;
const { queryByTestId } = createComponent({ pinia });
await waitAllPromises();
expect(queryByTestId('mcp-auto-expose-toggle')).toBeNull();
});
it('is hidden for a user without mcp:manage', async () => {
hasPermissionMock.mockReturnValue(false);
exposeAllWorkflowsToMcpStore.isEnabled = true;
const { queryByTestId } = createComponent({ pinia });
await waitAllPromises();
expect(queryByTestId('mcp-auto-expose-toggle')).toBeNull();
});
it('persists the new state and tracks the resulting value', async () => {
hasPermissionMock.mockReturnValue(true);
exposeAllWorkflowsToMcpStore.isEnabled = true;
mcpStore.setAutoExposeNewWorkflows.mockResolvedValue(true);
const { getByTestId } = createComponent({ pinia });
await waitAllPromises();
await userEvent.click(getByTestId('mcp-auto-expose-toggle').querySelector('input')!);
expect(mcpStore.setAutoExposeNewWorkflows).toHaveBeenCalledWith(true);
expect(trackAutoExposeToggledSpy).toHaveBeenCalledWith({ enabled: true, source: 'settings' });
});
it('shows a toast error and does not track when persisting fails', async () => {
hasPermissionMock.mockReturnValue(true);
exposeAllWorkflowsToMcpStore.isEnabled = true;
mcpStore.setAutoExposeNewWorkflows.mockRejectedValueOnce(new Error('nope'));
const { getByTestId } = createComponent({ pinia });
await waitAllPromises();
await userEvent.click(getByTestId('mcp-auto-expose-toggle').querySelector('input')!);
await waitAllPromises();
expect(trackAutoExposeToggledSpy).not.toHaveBeenCalled();
expect(useToast().showError).toHaveBeenCalledWith(
expect.anything(),
'Could not update setting',
);
});
});
});

View File

@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { ElSwitch } from 'element-plus';
import {
N8nButton,
N8nDialog,
@ -19,6 +20,7 @@ import {
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useToast } from '@n8n/composables/useToast';
import { useExposeAllWorkflowsToMcpOffer } from '@/experiments/exposeAllWorkflowsToMcp/composables/useExposeAllWorkflowsToMcpOffer';
import { useExposeAllWorkflowsToMcpStore } from '@/experiments/exposeAllWorkflowsToMcp/stores/exposeAllWorkflowsToMcp.store';
import MCPEmptyState from '@/features/ai/mcpAccess/components/MCPEmptyState.vue';
import McpAllowedCallbackUrlsDialog from '@/features/ai/mcpAccess/components/McpAllowedCallbackUrlsDialog.vue';
import McpConnectClientDialog from '@/features/ai/mcpAccess/components/McpConnectClientDialog.vue';
@ -45,6 +47,7 @@ const router = useRouter();
const mcpStore = useMCPStore();
const settingsStore = useSettingsStore();
const { offerToExposeAllWorkflows } = useExposeAllWorkflowsToMcpOffer();
const exposeAllWorkflowsToMcpStore = useExposeAllWorkflowsToMcpStore();
const agentsModuleActive = computed(() => settingsStore.isModuleActive('agents'));
@ -58,10 +61,26 @@ const canManageMcpInstance = computed(() =>
const canToggleMCP = computed(() => canManageMcpInstance.value && !mcpStore.mcpManagedByEnv);
const exposedWorkflowsCount = ref<number | null>(null);
const autoExposeSaving = ref(false);
const showCallbackUrlsDialog = ref(false);
const savingCallbackUrls = ref(false);
const onAutoExposeSwitchUpdate = (value: string | number | boolean) => {
void onToggleAutoExpose(value === true);
};
const onToggleAutoExpose = async (value: boolean) => {
autoExposeSaving.value = true;
try {
const updated = await mcpStore.setAutoExposeNewWorkflows(value);
mcp.trackAutoExposeToggled({ enabled: updated, source: 'settings' });
} catch (error) {
toast.showError(error, i18n.baseText('settings.mcp.autoExpose.error.title'));
} finally {
autoExposeSaving.value = false;
}
};
const showInstanceCapacityNotice = computed(
() => canManageMcpInstance.value && mcpStore.instanceClientStats?.atCapacity === true,
);
@ -310,6 +329,22 @@ onMounted(async () => {
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
<N8nSettingsRowGroup v-if="canManageMcpInstance && exposeAllWorkflowsToMcpStore.isEnabled">
<N8nSettingsRow
:title="i18n.baseText('settings.mcp.autoExpose.title')"
:description="i18n.baseText('settings.mcp.autoExpose.description')"
>
<template #action>
<ElSwitch
data-test-id="mcp-auto-expose-toggle"
:model-value="mcpStore.autoExposeNewWorkflows"
:disabled="mcpStore.mcpManagedByEnv"
:loading="autoExposeSaving"
@update:model-value="onAutoExposeSwitchUpdate"
/>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
<N8nSettingsRowGroup v-if="canManageMcpInstance">
<N8nSettingsRow
:title="i18n.baseText('settings.mcp.callbackUrls.title')"

View File

@ -72,6 +72,7 @@ describe('SettingsMCPWorkflowsView', () => {
mcp: {
mcpAccessEnabled: true,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
},
};
@ -90,6 +91,7 @@ describe('SettingsMCPWorkflowsView', () => {
mcp: {
mcpAccessEnabled: false,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
},
};

View File

@ -5,7 +5,9 @@ export function useMcp() {
const telemetry = useTelemetry();
const trackMcpAccessEnabledForWorkflow = (workflowId: string) => {
telemetry.track('User gave MCP access to workflow', { workflow_id: workflowId });
telemetry.track(TELEMETRY_EVENT.MCP.USER_GAVE_MCP_ACCESS_TO_WORKFLOW, {
workflow_id: workflowId,
});
};
const trackMcpAccessEnabledForAgent = (agentId: string) => {
@ -13,12 +15,20 @@ export function useMcp() {
};
const trackUserToggledMcpAccess = (enabled: boolean) => {
telemetry.track('User toggled MCP access', { state: enabled });
telemetry.track(TELEMETRY_EVENT.MCP.USER_TOGGLED_MCP_ACCESS, { state: enabled });
};
const trackAutoExposeToggled = ({
enabled,
source,
}: { enabled: boolean; source: 'settings' | 'expose_all' }) => {
telemetry.track(TELEMETRY_EVENT.MCP.AUTO_EXPOSE_NEW_WORKFLOWS_TOGGLED, { enabled, source });
};
return {
trackMcpAccessEnabledForWorkflow,
trackMcpAccessEnabledForAgent,
trackUserToggledMcpAccess,
trackAutoExposeToggled,
};
}

View File

@ -1,7 +1,7 @@
import type { IRestApiContext } from '@n8n/rest-api-client';
import { getFullApiResponse } from '@n8n/rest-api-client';
import { getFullApiResponse, makeRestApiRequest } from '@n8n/rest-api-client';
import { fetchMcpAgents } from './mcp.api';
import { fetchMcpAgents, updateMcpSettings } from './mcp.api';
vi.mock('@n8n/rest-api-client', () => ({
getFullApiResponse: vi.fn(),
@ -40,3 +40,21 @@ describe('fetchMcpAgents', () => {
});
});
});
describe('updateMcpSettings', () => {
const context = {} as IRestApiContext;
beforeEach(() => {
vi.clearAllMocks();
});
it('sends the given settings as the PATCH body', async () => {
vi.mocked(makeRestApiRequest).mockResolvedValue({ mcpAccessEnabled: true });
await updateMcpSettings(context, { mcpAccessEnabled: true });
expect(makeRestApiRequest).toHaveBeenCalledWith(context, 'PATCH', '/mcp/settings', {
mcpAccessEnabled: true,
});
});
});

View File

@ -13,6 +13,7 @@ import { makeRestApiRequest, getFullApiResponse } from '@n8n/rest-api-client';
export type McpSettingsResponse = {
mcpAccessEnabled: boolean;
autoExposeNewWorkflows: boolean;
};
export type ToggleWorkflowsMcpAccessTarget =
@ -28,6 +29,7 @@ export type ToggleWorkflowsMcpAccessResponse = {
failedCount: number;
updatedIds?: string[];
unchangedIds?: string[];
autoExposeNewWorkflows?: boolean;
};
export type ToggleAgentsMcpAccessTarget =
@ -41,17 +43,11 @@ export type ToggleAgentsMcpAccessResponse = {
unchangedIds?: string[];
};
export async function getMcpSettings(context: IRestApiContext): Promise<McpSettingsResponse> {
return await makeRestApiRequest(context, 'GET', '/mcp/settings');
}
export async function updateMcpSettings(
context: IRestApiContext,
enabled: boolean,
settings: { mcpAccessEnabled?: boolean; autoExposeNewWorkflows?: boolean },
): Promise<McpSettingsResponse> {
return await makeRestApiRequest(context, 'PATCH', '/mcp/settings', {
mcpAccessEnabled: enabled,
});
return await makeRestApiRequest(context, 'PATCH', '/mcp/settings', settings);
}
export async function fetchApiKey(context: IRestApiContext): Promise<ApiKey> {

View File

@ -4,6 +4,7 @@ import { setActivePinia, createPinia } from 'pinia';
import * as mcpApi from './mcp.api';
import { useMCPStore } from './mcp.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { createOAuthClient, createWorkflow } from './mcp.test.utils';
const { mockWorkflowDocumentStore } = vi.hoisted(() => ({
@ -516,4 +517,83 @@ describe('mcp.store', () => {
});
});
});
describe('setMcpAccessEnabled', () => {
it.each([
{
desc: 'syncs both values from the response when enabling (auto-expose surfaced)',
response: { mcpAccessEnabled: true, autoExposeNewWorkflows: true },
},
{
desc: 'syncs both values from the response when disabling (auto-expose gated to false)',
response: { mcpAccessEnabled: false, autoExposeNewWorkflows: false },
},
])('$desc', async ({ response }) => {
const updateSpy = vi.spyOn(mcpApi, 'updateMcpSettings').mockResolvedValue(response);
const settingsStore = useSettingsStore();
const getModuleSettings = vi.spyOn(settingsStore, 'getModuleSettings').mockResolvedValue();
settingsStore.moduleSettings.mcp = {
mcpAccessEnabled: !response.mcpAccessEnabled,
mcpManagedByEnv: false,
autoExposeNewWorkflows: !response.autoExposeNewWorkflows,
serverUrl: 'https://example.com/mcp',
};
const result = await useMCPStore().setMcpAccessEnabled(response.mcpAccessEnabled);
expect(updateSpy).toHaveBeenCalledWith(expect.anything(), {
mcpAccessEnabled: response.mcpAccessEnabled,
});
expect(result).toBe(response.mcpAccessEnabled);
expect(settingsStore.moduleSettings.mcp).toEqual({
mcpAccessEnabled: response.mcpAccessEnabled,
mcpManagedByEnv: false,
autoExposeNewWorkflows: response.autoExposeNewWorkflows,
serverUrl: 'https://example.com/mcp',
});
// The response carries both values, so no follow-up module-settings fetch.
expect(getModuleSettings).not.toHaveBeenCalled();
});
it('defaults sibling fields when no local settings exist yet', async () => {
vi.spyOn(mcpApi, 'updateMcpSettings').mockResolvedValue({
mcpAccessEnabled: true,
autoExposeNewWorkflows: false,
});
const settingsStore = useSettingsStore();
settingsStore.moduleSettings.mcp = undefined;
const result = await useMCPStore().setMcpAccessEnabled(true);
expect(result).toBe(true);
expect(settingsStore.moduleSettings.mcp).toEqual({
mcpAccessEnabled: true,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
});
});
});
describe('autoExposeNewWorkflows', () => {
it('patches only autoExposeNewWorkflows and syncs both values from the response', async () => {
const updateSpy = vi
.spyOn(mcpApi, 'updateMcpSettings')
.mockResolvedValue({ mcpAccessEnabled: true, autoExposeNewWorkflows: true });
const settingsStore = useSettingsStore();
settingsStore.moduleSettings.mcp = {
mcpAccessEnabled: true,
mcpManagedByEnv: false,
autoExposeNewWorkflows: false,
};
const result = await useMCPStore().setAutoExposeNewWorkflows(true);
expect(updateSpy).toHaveBeenCalledWith(expect.anything(), {
autoExposeNewWorkflows: true,
});
expect(result).toBe(true);
expect(settingsStore.moduleSettings.mcp?.autoExposeNewWorkflows).toBe(true);
expect(settingsStore.moduleSettings.mcp?.mcpAccessEnabled).toBe(true);
});
});
});

View File

@ -20,6 +20,7 @@ import {
fetchMcpAgents,
getAllowedRedirectUris,
updateAllowedRedirectUris,
type McpSettingsResponse,
type ToggleWorkflowsMcpAccessResponse,
type ToggleWorkflowsMcpAccessTarget,
type ToggleAgentsMcpAccessResponse,
@ -66,6 +67,9 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
const mcpAccessEnabled = computed(() => !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled);
const mcpManagedByEnv = computed(() => !!settingsStore.moduleSettings.mcp?.mcpManagedByEnv);
const autoExposeNewWorkflows = computed(
() => !!settingsStore.moduleSettings.mcp?.autoExposeNewWorkflows,
);
// Backend-provided canonical URL, so a configured dedicated MCP base URL is
// reflected; the editor-base fallback covers settings not yet loaded.
@ -135,17 +139,31 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
return await clampToLastPage(fetchAgentsAvailableForMCP, page, pageSize);
}
async function setMcpAccessEnabled(enabled: boolean): Promise<boolean> {
const { mcpAccessEnabled: updated } = await updateMcpSettings(
rootStore.restApiContext,
enabled,
);
// The PATCH endpoint always returns both current values, so local state can
// be synced from the response without a follow-up module-settings fetch.
function applyMcpSettingsResponse(response: McpSettingsResponse) {
settingsStore.moduleSettings.mcp = {
mcpManagedByEnv: false,
...(settingsStore.moduleSettings.mcp ?? {}),
mcpAccessEnabled: updated,
mcpAccessEnabled: response.mcpAccessEnabled,
autoExposeNewWorkflows: response.autoExposeNewWorkflows,
};
return updated;
}
async function setMcpAccessEnabled(enabled: boolean): Promise<boolean> {
const response = await updateMcpSettings(rootStore.restApiContext, {
mcpAccessEnabled: enabled,
});
applyMcpSettingsResponse(response);
return response.mcpAccessEnabled;
}
async function setAutoExposeNewWorkflows(enabled: boolean): Promise<boolean> {
const response = await updateMcpSettings(rootStore.restApiContext, {
autoExposeNewWorkflows: enabled,
});
applyMcpSettingsResponse(response);
return response.autoExposeNewWorkflows;
}
function applyAvailableInMCPToLocalStores(workflowId: string, availableInMCP: boolean) {
@ -390,12 +408,14 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
return {
mcpAccessEnabled,
mcpManagedByEnv,
autoExposeNewWorkflows,
serverUrl,
fetchWorkflowsAvailableForMCP,
fetchWorkflowsAvailableForMCPPage,
fetchAgentsAvailableForMCP,
fetchAgentsAvailableForMCPPage,
setMcpAccessEnabled,
setAutoExposeNewWorkflows,
toggleWorkflowMcpAccess,
toggleWorkflowsMcpAccess,
toggleAgentMcpAccess,