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 5ee9caa3c26..80b9b458910 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 @@ -1,5 +1,5 @@ import { Logger, ModuleRegistry } from '@n8n/backend-common'; -import { GLOBAL_OWNER_ROLE, type AuthenticatedRequest } from '@n8n/db'; +import { GLOBAL_ADMIN_ROLE, GLOBAL_OWNER_ROLE, type AuthenticatedRequest } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock, mockDeep } from 'jest-mock-extended'; @@ -34,14 +34,14 @@ describe('McpSettingsController', () => { }); describe('updateSettings', () => { - test('prevents non-owners from updating MCP access', async () => { + test('prevents non-admins from updating MCP access', async () => { const req = createReq({ mcpAccessEnabled: false }, 'member'); const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: false }); const res = new Response(); await expect(controller.updateSettings(req, res, dto)).rejects.toBeInstanceOf(ForbiddenError); }); - test('disables MCP access correctly', async () => { + test('disables MCP access correctly for instance owners', async () => { const req = createReq({ mcpAccessEnabled: false }, GLOBAL_OWNER_ROLE.slug); const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: false }); mcpSettingsService.setEnabled.mockResolvedValue(undefined); @@ -55,6 +55,20 @@ describe('McpSettingsController', () => { expect(result).toEqual({ mcpAccessEnabled: false }); }); + test('disables MCP access correctly for admins', async () => { + const req = createReq({ mcpAccessEnabled: false }, GLOBAL_ADMIN_ROLE.slug); + const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: false }); + mcpSettingsService.setEnabled.mockResolvedValue(undefined); + moduleRegistry.refreshModuleSettings.mockResolvedValue({ mcpAccessEnabled: false }); + + const res = new Response(); + const result = await controller.updateSettings(req, res, dto); + + expect(mcpSettingsService.setEnabled).toHaveBeenCalledWith(false); + expect(moduleRegistry.refreshModuleSettings).toHaveBeenCalledWith('mcp'); + expect(result).toEqual({ mcpAccessEnabled: false }); + }); + test('enables MCP access correctly', async () => { const req = createReq({ mcpAccessEnabled: true }, GLOBAL_OWNER_ROLE.slug); const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: true }); diff --git a/packages/cli/src/modules/mcp/mcp.controller.ts b/packages/cli/src/modules/mcp/mcp.controller.ts index 9329350ad84..de232e8b9a8 100644 --- a/packages/cli/src/modules/mcp/mcp.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.controller.ts @@ -9,7 +9,7 @@ import { McpSettingsService } from './mcp.settings.service'; export type FlushableResponse = Response & { flush: () => void }; -@RootLevelController('/mcp-access') +@RootLevelController('/mcp-server') export class McpController { constructor( private readonly errorReporter: ErrorReporter, diff --git a/packages/cli/src/modules/mcp/mcp.settings.controller.ts b/packages/cli/src/modules/mcp/mcp.settings.controller.ts index cd50e149fb1..ceec5bf4bfa 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.controller.ts @@ -1,5 +1,5 @@ import { ModuleRegistry, Logger } from '@n8n/backend-common'; -import { GLOBAL_OWNER_ROLE, type AuthenticatedRequest } from '@n8n/db'; +import { GLOBAL_ADMIN_ROLE, GLOBAL_OWNER_ROLE, type AuthenticatedRequest } from '@n8n/db'; import { Body, Get, Patch, RestController } from '@n8n/decorators'; import { UpdateMcpSettingsDto } from './dto/update-mcp-settings.dto'; @@ -22,8 +22,8 @@ export class McpSettingsController { @Patch('/settings') async updateSettings(req: AuthenticatedRequest, _res: Response, @Body dto: UpdateMcpSettingsDto) { - if (req.user.role?.slug !== GLOBAL_OWNER_ROLE.slug) { - throw new ForbiddenError('Only the instance owner can update MCP settings'); + if (![GLOBAL_OWNER_ROLE.slug, GLOBAL_ADMIN_ROLE.slug].includes(req.user.role?.slug)) { + throw new ForbiddenError('Only admin users can update MCP settings'); } const enabled = dto.mcpAccessEnabled; await this.mcpSettingsService.setEnabled(enabled); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 62e42b188d0..8c6e4c7f010 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -74,6 +74,8 @@ import IconLucideCirclePlay from '~icons/lucide/circle-play'; import IconLucideCirclePlus from '~icons/lucide/circle-plus'; import IconLucideCircleUserRound from '~icons/lucide/circle-user-round'; import IconLucideCircleX from '~icons/lucide/circle-x'; +import IconLucideClipboard from '~icons/lucide/clipboard'; +import IconLucideClipboardCheck from '~icons/lucide/clipboard-check'; import IconLucideClipboardList from '~icons/lucide/clipboard-list'; import IconLucideClock from '~icons/lucide/clock'; import IconLucideCloud from '~icons/lucide/cloud'; @@ -285,6 +287,8 @@ export const deprecatedIconSet = { cogs: IconLucideCog, comment: IconLucideMessageCircle, comments: IconLucideMessagesSquare, + clipboard: IconLucideClipboard, + 'clipboard-check': IconLucideClipboardCheck, 'clipboard-list': IconLucideClipboardList, clock: IconLucideClock, clone: IconLucideCopy, @@ -493,6 +497,8 @@ export const updatedIconSet = { 'circle-plus': IconLucideCirclePlus, 'circle-user-round': IconLucideCircleUserRound, 'circle-x': IconLucideCircleX, + clipboard: IconLucideClipboard, + 'clipboard-check': IconLucideClipboardCheck, 'clipboard-list': IconLucideClipboardList, clock: IconLucideClock, cloud: IconLucideCloud, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index fc563a1ce35..a8fa1c31b2a 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -62,6 +62,7 @@ "generic.communityNode.tooltip": "This is a node from our community. It's part of the {packageName} package. Learn more", "generic.officialNode.tooltip": "This is an official node maintained by {author}", "generic.copy": "Copy", + "generic.copied": "Copied", "generic.delete": "Delete", "generic.dontShowAgain": "Don't show again", "generic.enterprise": "Enterprise", @@ -2023,7 +2024,8 @@ "settings.mcp.workflows.table.action.removeMCPAccess": "Remove MCP Access", "settings.mcp.empty.title": "No workflows available for MCP", "settings.mcp.empty.description": "Enable MCP access in each workflow's settings to see them here.", - "settings.mcp.toggle.disabled.tooltip": "Only the instance owner can change this", + "settings.mcp.toggle.disabled.tooltip": "Only instance admins can change this", + "settings.mcp.toggle.error": "Error updating MCP access", "settings.mcp.instructions.enableAccess": "Enable workflow access in at least one workflow via its settings", "settings.mcp.instructions.serverUrl": "Server URL", "settings.mcp.instructions.apiKey.part1": "Create an", diff --git a/packages/frontend/editor-ui/src/features/mcpAccess/SettingsMCPView.vue b/packages/frontend/editor-ui/src/features/mcpAccess/SettingsMCPView.vue index ebed2786117..c696fcaeef2 100644 --- a/packages/frontend/editor-ui/src/features/mcpAccess/SettingsMCPView.vue +++ b/packages/frontend/editor-ui/src/features/mcpAccess/SettingsMCPView.vue @@ -21,10 +21,10 @@ const documentTitle = useDocumentTitle(); const workflowsStore = useWorkflowsStore(); const mcpStore = useMCPStore(); const usersStore = useUsersStore(); -const isOwner = computed(() => usersStore.isInstanceOwner); const rootStore = useRootStore(); const workflowsLoading = ref(false); +const mcpStatusLoading = ref(false); const availableWorkflows = ref([]); @@ -83,6 +83,11 @@ const tableActions = ref>>([ }, ]); +const isOwner = computed(() => usersStore.isInstanceOwner); +const isAdmin = computed(() => usersStore.isAdmin); + +const canToggleMCP = computed(() => isOwner.value || isAdmin.value); + const getProjectIcon = (workflow: WorkflowListItem): IconOrEmoji => { if (workflow.homeProject?.type === 'personal') { return { type: 'icon', value: 'user' }; @@ -115,10 +120,18 @@ const fetchAvailableWorkflows = async () => { }; const onUpdateMCPEnabled = async (value: boolean) => { - const updated = await mcpStore.setMcpAccessEnabled(value); - if (updated) { - await fetchAvailableWorkflows(); - } else { + try { + mcpStatusLoading.value = true; + const updated = await mcpStore.setMcpAccessEnabled(value); + if (updated) { + await fetchAvailableWorkflows(); + } else { + workflowsLoading.value = false; + } + } catch (error) { + toast.showError(error, i18n.baseText('settings.mcp.toggle.error')); + } finally { + mcpStatusLoading.value = false; workflowsLoading.value = false; } }; @@ -158,14 +171,15 @@ onMounted(async () => {
diff --git a/packages/frontend/editor-ui/src/features/mcpAccess/components/MCPConnectionInstructions.vue b/packages/frontend/editor-ui/src/features/mcpAccess/components/MCPConnectionInstructions.vue index 7a06798a2fd..83bf328682f 100644 --- a/packages/frontend/editor-ui/src/features/mcpAccess/components/MCPConnectionInstructions.vue +++ b/packages/frontend/editor-ui/src/features/mcpAccess/components/MCPConnectionInstructions.vue @@ -3,7 +3,7 @@ import { computed } from 'vue'; import { useClipboard } from '@/composables/useClipboard'; import { useI18n } from '@n8n/i18n'; -const MCP_ENDPOINT = 'mcp-access/http'; +const MCP_ENDPOINT = 'mcp-server/http'; // TODO: Update once docs page is ready const DOCS_URL = 'https://docs.n8n.io/'; @@ -64,14 +64,22 @@ const fullServerUrl = computed(() => { {{ fullServerUrl }} - + +
+ +
+
@@ -90,14 +98,19 @@ const fullServerUrl = computed(() => { @@ -135,7 +148,7 @@ const fullServerUrl = computed(() => { .url { display: flex; - align-items: center; + align-items: stretch; gap: var(--spacing-2xs); background: var(--color-background-xlight); border: var(--border-base); @@ -144,17 +157,24 @@ const fullServerUrl = computed(() => { overflow: hidden; code { + text-overflow: ellipsis; + overflow: hidden; + white-space: pre; padding: var(--spacing-2xs) var(--spacing-3xs); } + .copy-url-wrapper { + display: flex; + align-items: center; + border-left: var(--border-base); + } + .copy-url-button { border: none; border-radius: 0; - border-left: var(--border-base); } @media screen and (max-width: 820px) { - display: block; word-wrap: break-word; margin-top: var(--spacing-2xs); } @@ -183,7 +203,7 @@ const fullServerUrl = computed(() => { .copy-json-button { position: absolute; top: var(--spacing-xl); - right: var(--spacing-xl); + right: var(--spacing-2xl); display: none; } diff --git a/packages/frontend/editor-ui/src/stores/users.store.ts b/packages/frontend/editor-ui/src/stores/users.store.ts index 575e8b48ea8..c7198457fe2 100644 --- a/packages/frontend/editor-ui/src/stores/users.store.ts +++ b/packages/frontend/editor-ui/src/stores/users.store.ts @@ -36,6 +36,7 @@ const _isPendingUser = (user: IUserResponse | null) => !!user?.isPending; const _isInstanceOwner = (user: IUserResponse | null) => user?.role === ROLE.Owner; const _isDefaultUser = (user: IUserResponse | null) => _isInstanceOwner(user) && _isPendingUser(user); +const _isAdmin = (user: IUserResponse | null) => user?.role === ROLE.Admin; export type LoginHook = (user: CurrentUserResponse) => void; type LogoutHook = () => void; @@ -69,6 +70,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => { const isInstanceOwner = computed(() => _isInstanceOwner(currentUser.value)); + const isAdmin = computed(() => _isAdmin(currentUser.value)); + const mfaEnabled = computed(() => currentUser.value?.mfaEnabled ?? false); const globalRoleName = computed(() => currentUser.value?.role ?? 'default'); @@ -445,6 +448,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => { userActivated, isDefaultUser, isInstanceOwner, + isAdmin, mfaEnabled, globalRoleName, personalizedNodeTypes,