mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
feat(editor): Use auto-generated mcp keys in settings page (no-changelog) (#20449)
This commit is contained in:
parent
942a5b53e1
commit
fbc7f6039c
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 won’t 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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export const MCP_SETTINGS_VIEW = 'McpSettings';
|
||||
|
||||
export const MCP_STORE = 'mcp';
|
||||
|
||||
export const LOADING_INDICATOR_TIMEOUT = 200;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user