feat(editor): Use auto-generated mcp keys in settings page (no-changelog) (#20449)

This commit is contained in:
Milorad FIlipović 2025-10-07 11:20:44 +02:00 committed by GitHub
parent 942a5b53e1
commit fbc7f6039c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 275 additions and 56 deletions

View File

@ -13,7 +13,7 @@ export type FlushableResponse = Response & { flush: () => void };
const getAuthMiddleware = () => Container.get(McpServerApiKeyService).getAuthMiddleware();
@RootLevelController('/mcp-access')
@RootLevelController('/mcp-server')
export class McpController {
constructor(
private readonly errorReporter: ErrorReporter,

View File

@ -2028,11 +2028,15 @@
"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",
"settings.mcp.instructions.apiKey.part2": "No scopes required, MCP uses your role for authorization",
"settings.mcp.instructions.apiKey.label": "Access token",
"settings.mcp.instructions.json": "Or use the following code in your 'mcp.json' file",
"settings.mcp.instructions.docs.part1": "For more detailed instructions and examples, have a look at",
"settings.mcp.instructions.docs.part2": "our docs",
"settings.mcp.instructions.rotateKey.tooltip": "Generate new token.<br/>This one will be automatically revoked.",
"settings.mcp.instructions.apiKey.tip": "Use this as an authorization token. May be named differently in different clients",
"settings.mcp.newKey.notice": "Make sure to copy your access token now. You wont be able to see or copy it again!",
"settings.mcp.error.fetching.apiKey": "Error fetching access token",
"settings.mcp.error.rotating.apiKey": "Error generating new access token",
"settings.goBack": "Go back",
"settings.personal": "Personal",
"settings.personal.basicInformation": "Basic Information",

View File

@ -14,6 +14,7 @@ import { useMCPStore } from '@/features/mcpAccess/mcp.store';
import { useUsersStore } from '@/stores/users.store';
import MCPConnectionInstructions from '@/features/mcpAccess/components/MCPConnectionInstructions.vue';
import ProjectIcon from '@/components/Projects/ProjectIcon.vue';
import { LOADING_INDICATOR_TIMEOUT } from '@/features/mcpAccess/mcp.constants';
import { ElSwitch } from 'element-plus';
import {
@ -38,6 +39,7 @@ const rootStore = useRootStore();
const workflowsLoading = ref(false);
const mcpStatusLoading = ref(false);
const mcpKeyLoading = ref(false);
const availableWorkflows = ref<WorkflowListItem[]>([]);
@ -96,6 +98,8 @@ const tableActions = ref<Array<UserAction<WorkflowListItem>>>([
},
]);
const apiKey = computed(() => mcpStore.currentUserMCPKey);
const isOwner = computed(() => usersStore.isInstanceOwner);
const isAdmin = computed(() => usersStore.isAdmin);
@ -126,7 +130,7 @@ const fetchAvailableWorkflows = async () => {
const workflows = await mcpStore.fetchWorkflowsAvailableForMCP(1, 200);
availableWorkflows.value = workflows;
} catch (error) {
toast.showError(error, 'Error fetching workflows');
toast.showError(error, i18n.baseText('workflows.list.error.fetching'));
} finally {
workflowsLoading.value = false;
}
@ -139,6 +143,7 @@ const onUpdateMCPEnabled = async (value: string | number | boolean) => {
const updated = await mcpStore.setMcpAccessEnabled(boolValue);
if (updated) {
await fetchAvailableWorkflows();
await fetchApiKey();
} else {
workflowsLoading.value = false;
}
@ -165,9 +170,39 @@ const onWorkflowAction = async (action: string, workflow: WorkflowListItem) => {
}
};
const fetchApiKey = async () => {
try {
mcpKeyLoading.value = true;
await mcpStore.getOrCreateApiKey();
} catch (error) {
toast.showError(error, i18n.baseText('settings.mcp.error.fetching.apiKey'));
} finally {
setTimeout(() => {
mcpKeyLoading.value = false;
}, LOADING_INDICATOR_TIMEOUT);
}
};
const rotateKey = async () => {
try {
mcpKeyLoading.value = true;
await mcpStore.generateNewApiKey();
} catch (error) {
toast.showError(error, i18n.baseText('settings.mcp.error.rotating.apiKey'));
} finally {
setTimeout(() => {
mcpKeyLoading.value = false;
}, LOADING_INDICATOR_TIMEOUT);
}
};
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.mcp'));
if (mcpStore.mcpAccessEnabled) await fetchAvailableWorkflows();
if (!mcpStore.mcpAccessEnabled) {
return;
}
await fetchAvailableWorkflows();
await fetchApiKey();
});
</script>
<template>
@ -208,7 +243,13 @@ onMounted(async () => {
<N8nHeading size="medium" :bold="true">
{{ i18n.baseText('settings.mcp.connection.info.heading') }}
</N8nHeading>
<MCPConnectionInstructions :base-url="rootStore.urlBaseEditor" />
<MCPConnectionInstructions
v-if="apiKey"
:loading-api-key="mcpKeyLoading"
:base-url="rootStore.urlBaseEditor"
:api-key="apiKey"
@rotate-key="rotateKey"
/>
</div>
<div :class="$style['workflow-list-container']" data-test-id="mcp-workflow-list">
<div v-if="workflowsLoading">

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { useClipboard } from '@/composables/useClipboard';
import { N8nButton, N8nTooltip } from '@n8n/design-system';
type Props = {
value: string;
allowCopy?: boolean;
maxWidth?: number;
};
const { copy, copied, isSupported } = useClipboard();
const i18n = useI18n();
const props = withDefaults(defineProps<Props>(), {
allowCopy: true,
maxWidth: undefined,
});
</script>
<template>
<div
:class="$style.container"
:style="{ maxWidth: props.maxWidth ? props.maxWidth + 'px' : 'none' }"
>
<code>{{ props.value }}</code>
<div :class="$style['copy-button-wrapper']">
<slot name="customActions" />
<N8nTooltip
:disables="!isSupported"
:content="copied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy')"
placement="right"
>
<N8nButton
v-if="props.allowCopy && isSupported"
type="tertiary"
:icon="copied ? 'clipboard-check' : 'clipboard'"
:square="true"
:class="$style['copy-button']"
@click="copy(props.value)"
/>
</N8nTooltip>
</div>
</div>
</template>
<style module lang="scss">
.container {
display: flex;
align-items: stretch;
gap: var(--spacing-2xs);
background: var(--color-background-xlight);
border: var(--border-base);
border-radius: var(--border-radius-base);
font-size: var(--font-size-s);
overflow: hidden;
button {
border: none;
border-radius: 0;
&:hover {
border-color: inherit;
}
}
button + button {
border-left: var(--border-base);
}
@media screen and (max-width: 820px) {
word-wrap: break-word;
margin-top: var(--spacing-2xs);
}
}
code {
text-overflow: ellipsis;
overflow: hidden;
white-space: pre;
padding: var(--spacing-2xs) var(--spacing-3xs);
}
.copy-button-wrapper {
display: flex;
align-items: center;
border-left: var(--border-base);
}
.copy-button {
border: none;
border-radius: 0;
}
</style>

View File

@ -5,11 +5,15 @@ import { useI18n } from '@n8n/i18n';
import {
N8nButton,
N8nInfoAccordion,
N8nLink,
N8nInfoTip,
N8nLoading,
N8nMarkdown,
N8nNotice,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import type { ApiKey } from '@n8n/api-types';
import ConnectionParameter from './ConnectionParameter.vue';
const MCP_ENDPOINT = 'mcp-server/http';
// TODO: Update once docs page is ready
@ -17,10 +21,16 @@ const DOCS_URL = 'https://docs.n8n.io/';
type Props = {
baseUrl: string;
apiKey: ApiKey;
loadingApiKey: boolean;
};
const props = defineProps<Props>();
const emit = defineEmits<{
rotateKey: [];
}>();
const { copy, copied, isSupported } = useClipboard();
const i18n = useI18n();
@ -37,7 +47,7 @@ const connectionString = computed(() => {
"--streamableHttp",
"${props.baseUrl}${MCP_ENDPOINT}",
"--header",
"authorization:Bearer <YOUR_N8N_API_KEY>"
"authorization:Bearer ${apiKeyText.value}"
]
}
}
@ -45,6 +55,10 @@ const connectionString = computed(() => {
`;
});
const isKeyRedacted = computed(() => {
return props.apiKey.apiKey.includes('******');
});
// formatted code block for markdown component
const connectionCode = computed(() => {
return `\`\`\`json${connectionString.value}\`\`\``;
@ -53,55 +67,74 @@ const connectionCode = computed(() => {
const fullServerUrl = computed(() => {
return props.baseUrl + MCP_ENDPOINT;
});
const apiKeyText = computed(() => {
if (props.loadingApiKey) {
return `<${i18n.baseText('generic.loading')}...>`;
}
return isKeyRedacted.value ? '<YOUR_ACCESS_TOKEN_HERE>' : props.apiKey.apiKey;
});
</script>
<template>
<div :class="$style.container">
<ol :class="$style.instructions">
<li>
<div :class="$style.item">
<span :class="$style.label">
{{ i18n.baseText('settings.mcp.instructions.enableAccess') }}
</span>
</div>
</li>
<li>
<div :class="$style.item">
<span :class="$style.label">
{{ i18n.baseText('settings.mcp.instructions.serverUrl') }}:
</span>
<span :class="$style.url">
<code>{{ fullServerUrl }}</code>
<N8nTooltip
:disables="!isSupported"
:content="copied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy')"
placement="right"
<div :class="$style['instructions-container']">
<ol :class="$style.instructions">
<li>
<div :class="$style.item">
<span :class="$style.label">
{{ i18n.baseText('settings.mcp.instructions.enableAccess') }}
</span>
</div>
</li>
<li>
<div :class="$style.item">
<span :class="$style.label">
{{ i18n.baseText('settings.mcp.instructions.serverUrl') }}:
</span>
<ConnectionParameter :value="fullServerUrl" />
</div>
</li>
<li>
<div :class="$style.item">
<span :class="$style.label">
{{ i18n.baseText('settings.mcp.instructions.apiKey.label') }}:
</span>
<N8nLoading
v-if="props.loadingApiKey"
:loading="props.loadingApiKey"
:class="$style['api-key-loader']"
/>
<ConnectionParameter
v-else
:value="props.apiKey.apiKey"
:max-width="400"
:allow-copy="!isKeyRedacted"
>
<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>
<li>
<div :class="$style.item">
<span :class="$style.label">
{{ i18n.baseText('settings.mcp.instructions.apiKey.part1') }}
<N8nLink to="/settings/api">{{ i18n.baseText('generic.apiKey') }}</N8nLink
>.
{{ i18n.baseText('settings.mcp.instructions.apiKey.part2') }}
</span>
</div>
</li>
</ol>
<template #customActions>
<N8nTooltip :content="i18n.baseText('settings.mcp.instructions.rotateKey.tooltip')">
<N8nButton
type="tertiary"
icon="refresh-cw"
:square="true"
@click="emit('rotateKey')"
/>
</N8nTooltip>
</template>
</ConnectionParameter>
<N8nInfoTip v-if="!props.loadingApiKey" type="tooltip" tooltip-placement="right">
{{ i18n.baseText('settings.mcp.instructions.apiKey.tip') }}
</N8nInfoTip>
</div>
</li>
</ol>
<N8nNotice
v-if="!isKeyRedacted && !props.loadingApiKey"
theme="warning"
:class="$style['copy-key-notice']"
:content="i18n.baseText('settings.mcp.newKey.notice')"
/>
</div>
<div :class="$style.connectionString">
<N8nInfoAccordion :title="i18n.baseText('settings.mcp.instructions.json')">
<template #customContent>
@ -111,7 +144,7 @@ const fullServerUrl = computed(() => {
:content="copied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy')"
>
<N8nButton
v-if="isSupported"
v-if="isSupported && !props.loadingApiKey"
type="tertiary"
:icon="copied ? 'clipboard-check' : 'clipboard'"
:square="true"
@ -137,6 +170,12 @@ const fullServerUrl = computed(() => {
flex-direction: column;
}
.instructions-container {
:global(.notice) {
margin: var(--spacing-s) var(--spacing-l) var(--spacing-m);
}
}
.instructions {
display: flex;
flex-direction: column;
@ -144,14 +183,25 @@ const fullServerUrl = computed(() => {
padding-left: var(--spacing-l);
margin: var(--spacing-s);
li {
min-height: var(--spacing-l);
}
.item {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
:global(.n8n-loading) div {
height: 32px;
width: 300px;
margin: 0;
}
}
.label {
font-size: var(--font-size-s);
flex: none;
}
.url {

View File

@ -1,3 +1,4 @@
import type { ApiKey } from '@n8n/api-types';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { makeRestApiRequest } from '@n8n/rest-api-client';
@ -17,3 +18,11 @@ export async function updateMcpSettings(
mcpAccessEnabled: enabled,
});
}
export async function fetchApiKey(context: IRestApiContext): Promise<ApiKey> {
return await makeRestApiRequest(context, 'GET', '/mcp/api-key');
}
export async function rotateApiKey(context: IRestApiContext): Promise<ApiKey> {
return await makeRestApiRequest(context, 'POST', '/mcp/api-key/rotate');
}

View File

@ -1,3 +1,5 @@
export const MCP_SETTINGS_VIEW = 'McpSettings';
export const MCP_STORE = 'mcp';
export const LOADING_INDICATOR_TIMEOUT = 200;

View File

@ -3,15 +3,19 @@ import { MCP_STORE } from './mcp.constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { WorkflowListItem } from '@/Interface';
import { useRootStore } from '@n8n/stores/useRootStore';
import { updateMcpSettings } from '@/features/mcpAccess/mcp.api';
import { computed } from 'vue';
import { fetchApiKey, updateMcpSettings, rotateApiKey } from '@/features/mcpAccess/mcp.api';
import { computed, ref } from 'vue';
import { useSettingsStore } from '@/stores/settings.store';
import { isWorkflowListItem } from '@/utils/typeGuards';
import type { ApiKey } from '@n8n/api-types';
export const useMCPStore = defineStore(MCP_STORE, () => {
const workflowsStore = useWorkflowsStore();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const currentUserMCPKey = ref<ApiKey | null>(null);
const mcpAccessEnabled = computed(() => !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled);
async function fetchWorkflowsAvailableForMCP(
@ -42,9 +46,24 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
return updated;
}
async function getOrCreateApiKey(): Promise<ApiKey> {
const apiKey = await fetchApiKey(rootStore.restApiContext);
currentUserMCPKey.value = apiKey;
return apiKey;
}
async function generateNewApiKey(): Promise<ApiKey> {
const apiKey = await rotateApiKey(rootStore.restApiContext);
currentUserMCPKey.value = apiKey;
return apiKey;
}
return {
mcpAccessEnabled,
fetchWorkflowsAvailableForMCP,
setMcpAccessEnabled,
currentUserMCPKey,
getOrCreateApiKey,
generateNewApiKey,
};
});

View File

@ -382,7 +382,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
const items: IMenuItem[] = [];
Object.entries(registeredSettingsPages.value).forEach(([moduleName, moduleItems]) => {
if (settingsStore.isModuleActive(moduleName)) {
items.push(...moduleItems);
items.push(...moduleItems.map((item) => ({ ...item, available: true })));
}
});
return items;