From a74fbab2bf3f59d148bae4b63e76a7e454eae72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Almeida?= Date: Thu, 13 Aug 2026 09:29:07 +0000 Subject: [PATCH] feat(core): Auto-expose new workflows to MCP from settings via toggle (#35828) Co-authored-by: Claude Opus 5 --- .../@n8n/api-types/src/frontend-settings.ts | 2 + packages/@n8n/telemetry/src/events/mcp.ts | 34 ++++++ .../@n8n/telemetry/src/telemetry-events.ts | 2 + .../mcp.settings.controller.api.test.ts | 10 +- .../__tests__/mcp.settings.controller.test.ts | 84 +++++++++++++- .../__tests__/mcp.settings.service.test.ts | 71 +++++++++++- .../mcp/dto/update-mcp-settings.dto.ts | 28 ++++- packages/cli/src/modules/mcp/mcp.module.ts | 8 +- .../modules/mcp/mcp.settings.controller.ts | 40 +++++-- .../src/modules/mcp/mcp.settings.service.ts | 43 ++++++- .../workflow-creation.service.test.ts | 108 +++++++++++++++++- .../workflows/workflow-creation.service.ts | 25 ++++ .../frontend/@n8n/i18n/src/locales/en.json | 3 + .../WorkflowDescriptionModal.test.ts | 12 +- .../WorkflowProductionChecklist.test.ts | 12 +- .../app/composables/useWorkflowSaving.test.ts | 17 +++ .../src/app/composables/useWorkflowSaving.ts | 4 + .../src/app/stores/workflows.store.ts | 6 - .../ExposeAllWorkflowsToMcpModal.test.ts | 21 ++++ .../ExposeAllWorkflowsToMcpModal.vue | 5 + .../mcpAccess/SettingsMCPAgentsView.test.ts | 2 + .../mcpAccess/SettingsMCPClientsView.test.ts | 2 + .../ai/mcpAccess/SettingsMCPView.test.ts | 91 ++++++++++++++- .../features/ai/mcpAccess/SettingsMCPView.vue | 37 +++++- .../SettingsMCPWorkflowsView.test.ts | 2 + .../ai/mcpAccess/composables/useMcp.ts | 14 ++- .../src/features/ai/mcpAccess/mcp.api.test.ts | 22 +++- .../src/features/ai/mcpAccess/mcp.api.ts | 12 +- .../features/ai/mcpAccess/mcp.store.test.ts | 80 +++++++++++++ .../src/features/ai/mcpAccess/mcp.store.ts | 34 ++++-- 30 files changed, 768 insertions(+), 63 deletions(-) create mode 100644 packages/@n8n/telemetry/src/events/mcp.ts diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index b89a5d06def..2e372fdff0a 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -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; }; /** diff --git a/packages/@n8n/telemetry/src/events/mcp.ts b/packages/@n8n/telemetry/src/events/mcp.ts new file mode 100644 index 00000000000..f3ea7f572ba --- /dev/null +++ b/packages/@n8n/telemetry/src/events/mcp.ts @@ -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', + ), + }), + }, +}); diff --git a/packages/@n8n/telemetry/src/telemetry-events.ts b/packages/@n8n/telemetry/src/telemetry-events.ts index 267f4075f77..ecb32826fc6 100644 --- a/packages/@n8n/telemetry/src/telemetry-events.ts +++ b/packages/@n8n/telemetry/src/telemetry-events.ts @@ -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; diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.api.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.api.test.ts index 535a35cea61..c7833e2b5b4 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.api.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.api.test.ts @@ -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') diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts index 51b4b757cd0..2add0f66124 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts @@ -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(); }); }); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts index cd36fa54ebf..266a6d0b2d0 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts @@ -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({ + 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({ key: 'x', value: 'true', loadOnStartup: true })); + const em = mock(); + + 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 () => { diff --git a/packages/cli/src/modules/mcp/dto/update-mcp-settings.dto.ts b/packages/cli/src/modules/mcp/dto/update-mcp-settings.dto.ts index 6910c54a971..7a43250f716 100644 --- a/packages/cli/src/modules/mcp/dto/update-mcp-settings.dto.ts +++ b/packages/cli/src/modules/mcp/dto/update-mcp-settings.dto.ts @@ -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) { + super(updateMcpSettingsSchema.parse(data)); + } + + static override safeParse(data: unknown) { + return updateMcpSettingsSchema.safeParse(data); + } + + static override parse(data: unknown) { + return updateMcpSettingsSchema.parse(data); + } +} diff --git a/packages/cli/src/modules/mcp/mcp.module.ts b/packages/cli/src/modules/mcp/mcp.module.ts index c4694339232..35d5367bf59 100644 --- a/packages/cli/src/modules/mcp/mcp.module.ts +++ b/packages/cli/src/modules/mcp/mcp.module.ts @@ -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() diff --git a/packages/cli/src/modules/mcp/mcp.settings.controller.ts b/packages/cli/src/modules/mcp/mcp.settings.controller.ts index 6e225100c3c..0669c404b5d 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.controller.ts @@ -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), + }); + } + } } diff --git a/packages/cli/src/modules/mcp/mcp.settings.service.ts b/packages/cli/src/modules/mcp/mcp.settings.service.ts index c93ccda336d..c164d3b7e53 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.service.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.service.ts @@ -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 { + async getEnabled(em?: EntityManager): Promise { const isMcpAccessEnabled = await this.cacheService.get(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 { + if (!(await this.getEnabled(em))) { + return false; + } + + const cached = await this.cacheService.get(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 { + 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, diff --git a/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts index c0812533c97..ee9f356eb30 100644 --- a/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts @@ -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; let workflowFinderServiceMock: MockProxy; let workflowHookContextServiceMock: MockProxy; + let mcpSettingsService: MockProxy; + let loggerMock: MockProxy; beforeEach(() => { vi.clearAllMocks(); + loggerMock = mock(); credentialsServiceMock = mock(); enterpriseWorkflowServiceMock = mock(); licenseStateMock = mock(); @@ -62,8 +66,10 @@ describe('WorkflowCreationService', () => { // Default: no active floor. Tests opt into a floor explicitly. instanceRedactionEnforcementServiceMock.get.mockResolvedValue('off'); + mcpSettingsService = mock(); + 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 { + 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(); + + 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' }, + ); + }); + }); }); diff --git a/packages/cli/src/workflows/workflow-creation.service.ts b/packages/cli/src/workflows/workflow-creation.service.ts index 43f8b9a84a0..da46cc2c0f1 100644 --- a/packages/cli/src/workflows/workflow-creation.service.ts +++ b/packages/cli/src/workflows/workflow-creation.service.ts @@ -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 { + 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 }; + } } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 5ea65dc896d..191b199a6a4 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowDescriptionModal.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowDescriptionModal.test.ts index 34787a74a2e..ece52332fad 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowDescriptionModal.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowDescriptionModal.test.ts @@ -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: { diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts index 5d5bfde253c..0f781f0f345 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts @@ -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 }); diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts index 63e1c8ba8ed..5200de65fba 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts @@ -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) { diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts index c73c626eab8..0fe8a1ef43d 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts @@ -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) => { diff --git a/packages/frontend/editor-ui/src/app/stores/workflows.store.ts b/packages/frontend/editor-ui/src/app/stores/workflows.store.ts index 6cda3185b46..40df825f027 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflows.store.ts @@ -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) { diff --git a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.test.ts b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.test.ts index ca399b298cc..f2de31201f1 100644 --- a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.test.ts +++ b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.test.ts @@ -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' }); + }); }); diff --git a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue index 81eac80c2bd..1a2b0c45ff8 100644 --- a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue +++ b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue @@ -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({ diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.test.ts index 656442f4d6d..96419a7be97 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.test.ts @@ -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, }, }; diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPClientsView.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPClientsView.test.ts index 77e9a0c604d..13ef6ea1f07 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPClientsView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPClientsView.test.ts @@ -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, }, }; diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts index c816dc2089a..acaa0ce8ad1 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts @@ -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', + ); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue index cbc335002b1..9152fa5aed9 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue @@ -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(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 () => { + + + + + { mcp: { mcpAccessEnabled: true, mcpManagedByEnv: false, + autoExposeNewWorkflows: false, }, }; @@ -90,6 +91,7 @@ describe('SettingsMCPWorkflowsView', () => { mcp: { mcpAccessEnabled: false, mcpManagedByEnv: false, + autoExposeNewWorkflows: false, }, }; diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/composables/useMcp.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/composables/useMcp.ts index b61f29b7701..6b1ad46eae6 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/composables/useMcp.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/composables/useMcp.ts @@ -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, }; } diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.test.ts index a2cce227ee4..658630a7621 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.test.ts @@ -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, + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts index 198973cf7ad..89224d58a11 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.api.ts @@ -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 { - return await makeRestApiRequest(context, 'GET', '/mcp/settings'); -} - export async function updateMcpSettings( context: IRestApiContext, - enabled: boolean, + settings: { mcpAccessEnabled?: boolean; autoExposeNewWorkflows?: boolean }, ): Promise { - return await makeRestApiRequest(context, 'PATCH', '/mcp/settings', { - mcpAccessEnabled: enabled, - }); + return await makeRestApiRequest(context, 'PATCH', '/mcp/settings', settings); } export async function fetchApiKey(context: IRestApiContext): Promise { diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.test.ts index c4fa7e0a527..d724a50fd8f 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.test.ts @@ -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); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts index c3633e0e340..238270b329c 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/mcp.store.ts @@ -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 { - 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 { + const response = await updateMcpSettings(rootStore.restApiContext, { + mcpAccessEnabled: enabled, + }); + applyMcpSettingsResponse(response); + return response.mcpAccessEnabled; + } + + async function setAutoExposeNewWorkflows(enabled: boolean): Promise { + 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,