feat(editor): Improve MCP UX (no-changelog) (#20247)

This commit is contained in:
Milorad FIlipović 2025-10-01 13:23:17 +02:00 committed by GitHub
parent af1391853b
commit 071dcd836d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 97 additions and 37 deletions

View File

@ -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 });

View File

@ -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,

View File

@ -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);

View File

@ -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,

View File

@ -62,6 +62,7 @@
"generic.communityNode.tooltip": "This is a node from our community. It's part of the {packageName} package. <a href=\"{docURL}\" target=\"_blank\" title=\"Read the n8n docs\">Learn more</a>",
"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",

View File

@ -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<WorkflowListItem[]>([]);
@ -83,6 +83,11 @@ const tableActions = ref<Array<UserAction<WorkflowListItem>>>([
},
]);
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 () => {
<div :class="$style.mainTooggle" data-test-id="mcp-toggle-container">
<N8nTooltip
:content="i18n.baseText('settings.mcp.toggle.disabled.tooltip')"
:disabled="isOwner"
:disabled="canToggleMCP"
placement="top"
>
<ElSwitch
:model-value="mcpStore.mcpAccessEnabled"
size="large"
data-test-id="mcp-access-toggle"
:disabled="!isOwner"
:model-value="mcpStore.mcpAccessEnabled"
:disabled="!canToggleMCP"
:loading="mcpStatusLoading"
@update:model-value="onUpdateMCPEnabled"
/>
</N8nTooltip>

View File

@ -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(() => {
</span>
<span :class="$style.url">
<code>{{ fullServerUrl }}</code>
<N8nButton
v-if="isSupported"
type="tertiary"
:icon="copied ? 'check' : 'copy'"
:square="true"
:class="$style['copy-url-button']"
@click="copy(fullServerUrl)"
/>
<N8nTooltip
:disables="!isSupported"
:content="copied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy')"
placement="right"
>
<div :class="$style['copy-url-wrapper']">
<N8nButton
v-if="isSupported"
type="tertiary"
:icon="copied ? 'clipboard-check' : 'clipboard'"
:square="true"
:class="$style['copy-url-button']"
@click="copy(fullServerUrl)"
/>
</div>
</N8nTooltip>
</span>
</div>
</li>
@ -90,14 +98,19 @@ const fullServerUrl = computed(() => {
<N8nInfoAccordion :title="i18n.baseText('settings.mcp.instructions.json')">
<template #customContent>
<N8nMarkdown :content="connectionCode"></N8nMarkdown>
<N8nButton
v-if="isSupported"
type="tertiary"
:icon="copied ? 'check' : 'copy'"
:square="true"
:class="$style['copy-json-button']"
@click="copy(connectionString)"
/>
<N8nTooltip
:disables="!isSupported"
:content="copied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy')"
>
<N8nButton
v-if="isSupported"
type="tertiary"
:icon="copied ? 'clipboard-check' : 'clipboard'"
:square="true"
:class="$style['copy-json-button']"
@click="copy(connectionString)"
/>
</N8nTooltip>
</template>
</N8nInfoAccordion>
</div>
@ -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;
}
</style>

View File

@ -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,