mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-30 16:26:59 +02:00
feat(editor): Improve MCP UX (no-changelog) (#20247)
This commit is contained in:
parent
af1391853b
commit
071dcd836d
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user