mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
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:
parent
b9496b2fa6
commit
a74fbab2bf
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
34
packages/@n8n/telemetry/src/events/mcp.ts
Normal file
34
packages/@n8n/telemetry/src/events/mcp.ts
Normal 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',
|
||||
),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user