fix(core): Add N8N_MCP_BASE_URL for split-hostname MCP OAuth deployments (#34028)

This commit is contained in:
Ricardo Espinoza
2026-07-14 06:06:06 +00:00
committed by GitHub
parent ec688511b5
commit 17905b5f35
14 changed files with 324 additions and 21 deletions
@@ -288,6 +288,8 @@ export type FrontendModuleSettings = {
mcpAccessEnabled: boolean;
/** Whether MCP settings are managed via environment variables. */
mcpManagedByEnv: boolean;
/** Public URL of the instance MCP server endpoint. */
serverUrl?: string;
};
/**
@@ -1,5 +1,6 @@
import { mock } from 'vitest-mock-extended';
import type { McpConfig } from '../mcp.config';
import type { McpSettingsService } from '../mcp.settings.service';
import type { UrlService } from '@/services/url.service';
@@ -8,10 +9,12 @@ import { McpProtectedResource } from '../mcp-protected-resource';
describe('McpProtectedResource', () => {
const urlService = mock<UrlService>();
const mcpSettingsService = mock<McpSettingsService>();
const resource = new McpProtectedResource(urlService, mcpSettingsService);
const mcpConfig = mock<McpConfig>();
const resource = new McpProtectedResource(urlService, mcpSettingsService, mcpConfig);
beforeEach(() => {
vi.clearAllMocks();
mcpConfig.baseUrl = '';
});
describe('getResourceUrl', () => {
@@ -53,4 +56,35 @@ describe('McpProtectedResource', () => {
it('should be the default audience for resource-less token requests', () => {
expect(resource.isDefault).toBe(true);
});
describe('with a dedicated MCP base URL (split-hostname deployments)', () => {
beforeEach(() => {
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.example.com');
mcpConfig.baseUrl = 'https://n8n-mcp.example.com';
});
it('should use the configured base URL as the canonical resource', () => {
expect(resource.getResourceUrl()).toBe('https://n8n-mcp.example.com/mcp-server/http');
});
it('should keep serving the instance-base-URL-derived resource', () => {
expect(resource.getResourceUrls()).toEqual([
'https://n8n-mcp.example.com/mcp-server/http',
'https://n8n.example.com/mcp-server/http',
]);
});
it('should accept audiences for both resource URLs plus the legacy audience', () => {
expect(resource.getAudiences()).toEqual([
'https://n8n-mcp.example.com/mcp-server/http',
'https://n8n.example.com/mcp-server/http',
'mcp-server-api',
]);
});
it('should collapse to a single resource URL when unset', () => {
mcpConfig.baseUrl = '';
expect(resource.getResourceUrls()).toEqual(['https://n8n.example.com/mcp-server/http']);
});
});
});
@@ -9,6 +9,7 @@ describe('McpConfig', () => {
afterEach(() => {
delete process.env.N8N_MCP_SERVER_RATE_LIMIT;
delete process.env.N8N_MCP_BASE_URL;
});
it('applies the documented default limit', () => {
@@ -43,4 +44,31 @@ describe('McpConfig', () => {
expect(config.rateLimitServer).toBe(100);
},
);
describe('baseUrl', () => {
it('defaults to unset', () => {
expect(Container.get(McpConfig).baseUrl).toBe('');
});
it('reads and normalizes the URL from its environment variable', () => {
process.env.N8N_MCP_BASE_URL = 'https://n8n-mcp.example.com/';
expect(Container.get(McpConfig).baseUrl).toBe('https://n8n-mcp.example.com');
});
it('preserves a subpath while stripping query and fragment', () => {
process.env.N8N_MCP_BASE_URL = 'https://example.com/n8n/?foo=1#bar';
expect(Container.get(McpConfig).baseUrl).toBe('https://example.com/n8n');
});
it.each(['not-a-url', 'ftp://example.com', '//half-a-url'])(
'falls back to unset when given an invalid value (%s)',
(value) => {
process.env.N8N_MCP_BASE_URL = value;
expect(Container.get(McpConfig).baseUrl).toBe('');
},
);
});
});
@@ -1,5 +1,6 @@
import { Service } from '@n8n/di';
import { McpConfig } from './mcp.config';
import { McpSettingsService } from './mcp.settings.service';
import type { ProtectedResource } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
@@ -47,17 +48,34 @@ export class McpProtectedResource implements ProtectedResource {
constructor(
private readonly urlService: UrlService,
private readonly mcpSettingsService: McpSettingsService,
private readonly mcpConfig: McpConfig,
) {}
getResourceUrl(): string {
// A dedicated MCP base URL (split-hostname deployments) takes precedence
// as the canonical resource: it is what clients are told to use.
if (this.mcpConfig.baseUrl) {
return `${this.mcpConfig.baseUrl}${MCP_RESOURCE_PATH}`;
}
const baseUrl = this.urlService.getInstanceBaseUrl().replace(/\/$/, '');
return `${baseUrl}${MCP_RESOURCE_PATH}`;
}
/**
* Canonical resource URL first, then the instance-base-URL-derived one when
* a dedicated MCP base URL is configured — clients connecting through the
* main hostname (and tokens minted before the config change) must keep
* working.
*/
getResourceUrls(): string[] {
const instanceUrl = `${this.urlService.getInstanceBaseUrl().replace(/\/$/, '')}${MCP_RESOURCE_PATH}`;
return [...new Set([this.getResourceUrl(), instanceUrl])];
}
getAudiences(): string[] {
// The legacy audience stays scoped to this resource only — it must never
// be accepted at another protected resource's gate.
return [this.getResourceUrl(), LEGACY_MCP_AUDIENCE];
return [...this.getResourceUrls(), LEGACY_MCP_AUDIENCE];
}
async getAllowedRedirectUris(): Promise<string[]> {
@@ -1,6 +1,25 @@
import { Config, Env } from '@n8n/config';
import { z } from 'zod';
/** Normalized to origin + path without a trailing slash; query and fragment are dropped. */
const baseUrlSchema = z
.string()
.refine(
(value) => {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
},
{ message: 'Must be a valid http(s) URL' },
)
.transform((value) => {
const url = new URL(value);
return `${url.origin}${url.pathname}`.replace(/\/$/, '');
});
/** Configuration for the instance MCP server module. */
@Config
export class McpConfig {
@@ -10,4 +29,19 @@ export class McpConfig {
*/
@Env('N8N_MCP_SERVER_RATE_LIMIT', z.number({ coerce: true }).int().nonnegative())
rateLimitServer: number = 100;
/**
* Public base URL at which MCP clients reach this instance's MCP server,
* when it differs from the instance base URL — for split-hostname
* deployments that front the same backend with a dedicated MCP hostname
* (e.g. `https://n8n-mcp.example.com` while `N8N_EDITOR_BASE_URL` stays on
* the main UI hostname). When set, it becomes the canonical MCP resource
* URL: advertised in discovery, accepted as an RFC 8707 resource indicator,
* and used as the access-token audience. The instance-base-URL-derived
* resource remains accepted so existing clients keep working.
*
* @example N8N_MCP_BASE_URL=https://n8n-mcp.example.com
*/
@Env('N8N_MCP_BASE_URL', baseUrlSchema)
baseUrl: string = '';
}
+7 -2
View File
@@ -26,13 +26,18 @@ export class McpModule implements ModuleInterface {
/**
* Settings exposed to the frontend under `/rest/module-settings`.
*
* The response shape will be `{ mcp: { mcpAccessEnabled: boolean, mcpManagedByEnv: boolean } }`.
* The response shape will be
* `{ mcp: { mcpAccessEnabled: boolean, mcpManagedByEnv: boolean, serverUrl: string } }`.
*/
async settings() {
const { McpSettingsService } = await import('./mcp.settings.service');
const { McpProtectedResource } = await import('./mcp-protected-resource');
const mcpAccessEnabled = await Container.get(McpSettingsService).getEnabled();
const { mcpManagedByEnv } = Container.get(InstanceSettingsLoaderConfig);
return { mcpAccessEnabled, mcpManagedByEnv };
// Canonical resource URL, so the UI shows the URL clients must actually
// use when a dedicated MCP base URL is configured.
const serverUrl = Container.get(McpProtectedResource).getResourceUrl();
return { mcpAccessEnabled, mcpManagedByEnv, serverUrl };
}
@OnShutdown()
@@ -17,7 +17,11 @@ import { OAuthAuthorizationCodeService } from '../oauth-authorization-code.servi
import { OAuthServerService } from '../oauth-server.service';
import { OAuthSessionService } from '../oauth-session.service';
import { OAuthTokenService } from '../oauth-token.service';
import { McpProtectedResource } from '@/modules/mcp/mcp-protected-resource';
import type { McpConfig } from '@/modules/mcp/mcp.config';
import type { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import type { UrlService } from '@/services/url.service';
const SUPPORTED_SCOPES = ['tool:listWorkflows', 'tool:getWorkflowDetails'];
const TEST_RESOURCE_URL = 'https://n8n.example.com/mcp-server/http';
@@ -1085,4 +1089,62 @@ describe('OAuthServerService', () => {
).rejects.toThrow(InvalidTargetError);
});
});
// Chain test with the real MCP protected resource: the resource generated
// from the configured MCP base URL must pass indicator validation.
describe('resource indicator validation with a configured MCP base URL', () => {
const makeConfiguredService = () => {
const urlService = mock<UrlService>();
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.example.com');
const mcpConfig = mock<McpConfig>();
mcpConfig.baseUrl = 'https://n8n-mcp.example.com';
const mcpResource = new McpProtectedResource(
urlService,
mock<McpSettingsService>(),
mcpConfig,
);
expect(mcpResource.getResourceUrl()).toBe('https://n8n-mcp.example.com/mcp-server/http');
const configuredRegistry = new ProtectedResourceRegistry(mock<Logger>());
configuredRegistry.register(mcpResource);
return new OAuthServerService(
logger,
mockInstance(GlobalConfig),
oauthSessionService,
oauthClientRepository,
tokenService,
authorizationCodeService,
userConsentRepository,
configuredRegistry,
);
};
it('should accept the resource generated from the configured base URL', async () => {
const configuredService = makeConfiguredService();
expect(
await (configuredService as any).resolveAndValidateResourceIndicator(
'https://n8n-mcp.example.com/mcp-server/http',
),
).toBe('https://n8n-mcp.example.com/mcp-server/http');
});
it('should keep accepting the instance-base-URL-derived resource', async () => {
const configuredService = makeConfiguredService();
expect(
await (configuredService as any).resolveAndValidateResourceIndicator(
'https://n8n.example.com/mcp-server/http',
),
).toBe('https://n8n.example.com/mcp-server/http');
});
it('should reject resources on hosts that are not configured', async () => {
const configuredService = makeConfiguredService();
await expect(
(configuredService as any).resolveAndValidateResourceIndicator(
'https://other.example.com/mcp-server/http',
),
).rejects.toThrow(InvalidTargetError);
});
});
});
@@ -13,7 +13,11 @@ import type { RefreshToken } from '../database/entities/oauth-refresh-token.enti
import { AccessTokenRepository } from '../database/repositories/oauth-access-token.repository';
import { RefreshTokenRepository } from '../database/repositories/oauth-refresh-token.repository';
import { OAuthTokenService } from '../oauth-token.service';
import { McpProtectedResource } from '@/modules/mcp/mcp-protected-resource';
import type { McpConfig } from '@/modules/mcp/mcp.config';
import type { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import type { UrlService } from '@/services/url.service';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
@@ -593,4 +597,68 @@ describe('OAuthTokenService', () => {
).resolves.toMatchObject({ clientId: 'client-456' });
});
});
// Chain test with the real MCP protected resource, matching the middleware's
// gate (expectedAudience = getResourceUrl(), which is derived from the
// configured MCP base URL when set).
describe('audience gate with a configured MCP base URL', () => {
const CONFIGURED_RESOURCE_URL = 'https://n8n-mcp.example.com/mcp-server/http';
let configuredService: OAuthTokenService;
beforeAll(() => {
const urlService = mock<UrlService>();
urlService.getInstanceBaseUrl.mockReturnValue(TEST_BASE_URL);
const mcpConfig = mock<McpConfig>();
mcpConfig.baseUrl = 'https://n8n-mcp.example.com';
const mcpResource = new McpProtectedResource(
urlService,
mock<McpSettingsService>(),
mcpConfig,
);
const configuredRegistry = new ProtectedResourceRegistry(mock<Logger>());
configuredRegistry.register(mcpResource);
configuredService = new OAuthTokenService(
logger,
jwtService,
userRepository,
accessTokenRepository,
refreshTokenRepository,
configuredRegistry,
);
});
it.each([
['the configured resource URL', CONFIGURED_RESOURCE_URL],
['the instance-base-URL-derived resource URL', TEST_RESOURCE_URL],
['the legacy audience', LEGACY_AUDIENCE],
])('should accept a token whose aud is %s', async (_, audience) => {
const token = jwtService.sign({
sub: 'user-123',
aud: audience,
client_id: 'client-456',
});
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token, clientId: 'client-456', userId: 'user-123' }),
);
await expect(
configuredService.verifyAccessToken(token, CONFIGURED_RESOURCE_URL),
).resolves.toMatchObject({ clientId: 'client-456' });
});
it('should reject a token whose aud is an unconfigured host', async () => {
const token = jwtService.sign({
sub: 'user-123',
aud: 'https://other.example.com/mcp-server/http',
client_id: 'client-456',
});
await expect(
configuredService.verifyAccessToken(token, CONFIGURED_RESOURCE_URL),
).rejects.toThrow('JWT Verification Failed');
});
});
});
@@ -37,6 +37,36 @@ describe('ProtectedResourceRegistry', () => {
expect(registry.getById('unknown')).toBeUndefined();
});
it('should resolve a resource by any of its declared resource URLs', async () => {
const multiUrlResource: ProtectedResource = {
id: 'instance-mcp-multi',
getResourceUrl: () => 'https://n8n-mcp.example.com/mcp-server/http',
getResourceUrls: () => [
'https://n8n-mcp.example.com/mcp-server/http',
'https://n8n.example.com/mcp-server/http',
],
getAudiences: () => [
'https://n8n-mcp.example.com/mcp-server/http',
'https://n8n.example.com/mcp-server/http',
],
authorize: async () => true,
scopes: [],
};
const multiRegistry = new ProtectedResourceRegistry(mock<Logger>());
multiRegistry.register(multiUrlResource);
expect(
await multiRegistry.getByResourceUrl('https://n8n-mcp.example.com/mcp-server/http'),
).toBe(multiUrlResource);
expect(await multiRegistry.getByResourceUrl('https://n8n.example.com/mcp-server/http/')).toBe(
multiUrlResource,
);
expect(
await multiRegistry.getByResourceUrl('https://other.example.com/mcp-server/http'),
).toBeUndefined();
expect(await multiRegistry.getByResourcePath('/mcp-server/http')).toBe(multiUrlResource);
});
it('should resolve resources by resource URL, ignoring trailing slashes', async () => {
expect(await registry.getByResourceUrl('https://n8n.example.com/mcp-server/http')).toBe(
resourceA,
@@ -26,6 +26,15 @@ export interface ProtectedResource {
*/
getResourceUrl(): string;
/**
* Every resource URL this resource is served at, canonical URL
* (`getResourceUrl()`) first. A configured MCP base URL becomes the
* canonical entry, and the instance-base-URL-derived resource follows so
* clients using the instance hostname keep working. Treated as
* `[getResourceUrl()]` when not implemented.
*/
getResourceUrls?(): string[];
/**
* All `aud` values accepted at this resource's gate. Must include the
* canonical resource URL; may include resource-specific legacy audiences.
@@ -96,6 +105,9 @@ export interface ProtectedResourceResolver {
const trimTrailingSlash = (url: string): string => url.replace(/\/$/, '');
const getResourceUrls = (resource: ProtectedResource): string[] =>
resource.getResourceUrls?.() ?? [resource.getResourceUrl()];
/**
* Registry of the protected resources served by this instance's shared OAuth
* server. Modules register their resources on init (e.g. instance MCP
@@ -121,11 +133,13 @@ export class ProtectedResourceRegistry {
return this.resources.get(id);
}
/** Look up a resource by its canonical URL (trailing slashes ignored). */
/** Look up a resource by any of its resource URLs (trailing slashes ignored). */
async getByResourceUrl(resourceUrl: string): Promise<ProtectedResource | undefined> {
const normalized = trimTrailingSlash(resourceUrl);
for (const resource of this.resources.values()) {
if (trimTrailingSlash(resource.getResourceUrl()) === normalized) return resource;
if (getResourceUrls(resource).some((url) => trimTrailingSlash(url) === normalized)) {
return resource;
}
}
for (const resolver of this.resolvers) {
try {
@@ -142,12 +156,14 @@ export class ProtectedResourceRegistry {
async getByResourcePath(pathname: string): Promise<ProtectedResource | undefined> {
const normalized = trimTrailingSlash(pathname);
for (const resource of this.resources.values()) {
try {
if (trimTrailingSlash(new URL(resource.getResourceUrl()).pathname) === normalized) {
return resource;
for (const url of getResourceUrls(resource)) {
try {
if (trimTrailingSlash(new URL(url).pathname) === normalized) {
return resource;
}
} catch {
continue;
}
} catch {
continue;
}
}
@@ -46,6 +46,7 @@ vi.mock('@/experiments/surfaceMcpToNewCloudUsers/stores/surfaceMcpToNewCloudUser
type MockMcpStore = {
mcpAccessEnabled: boolean;
mcpManagedByEnv: boolean;
serverUrl: string;
setMcpAccessEnabled: ReturnType<typeof vi.fn>;
};
@@ -94,6 +95,7 @@ describe('MCPOnboardingModal', () => {
mockMcpStore = reactive({
mcpAccessEnabled: false,
mcpManagedByEnv: false,
serverUrl: 'https://example.n8n.cloud/mcp-server/http',
setMcpAccessEnabled: vi.fn().mockImplementation(async () => {
mockMcpStore.mcpAccessEnabled = true;
return true;
@@ -5,12 +5,11 @@ import SurfaceMcpBridgeGraphic from '@/experiments/surfaceMcpToNewCloudUsers/com
import { SURFACE_MCP_ONBOARDING_MODAL_KEY } from '@/experiments/surfaceMcpToNewCloudUsers/constants';
import { useSurfaceMcpToNewCloudUsersStore } from '@/experiments/surfaceMcpToNewCloudUsers/stores/surfaceMcpToNewCloudUsers.store';
import MCPAccessToggle from '@/features/ai/mcpAccess/components/header/McpAccessToggle.vue';
import { MCP_ENDPOINT, MCP_SETTINGS_VIEW } from '@/features/ai/mcpAccess/mcp.constants';
import { MCP_SETTINGS_VIEW } from '@/features/ai/mcpAccess/mcp.constants';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { N8nIcon, N8nLink, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { createEventBus } from '@n8n/utils/event-bus';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { I18nT } from 'vue-i18n';
@@ -35,7 +34,6 @@ const props = defineProps<{
const i18n = useI18n();
const toast = useToast();
const rootStore = useRootStore();
const mcpStore = useMCPStore();
const experimentStore = useSurfaceMcpToNewCloudUsersStore();
const modalBus = createEventBus();
@@ -81,7 +79,7 @@ const clientOptions = computed<MCPOnboardingClientOption[]>(() => [
},
]);
const serverUrl = computed(() => `${rootStore.urlBaseEditor}${MCP_ENDPOINT}`);
const serverUrl = computed(() => mcpStore.serverUrl);
const isChatGptClient = computed(() => activeClient.value === 'chatgpt');
const showServerUrlStep = computed(() => activeClient.value === 'claude');
const showRestartStep = computed(
@@ -1,17 +1,15 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { N8nButton, N8nPopover, N8nRadioButtons } from '@n8n/design-system';
import MCPOAuthPopoverTab from '@/features/ai/mcpAccess/components/header/connectPopover/MCPOAuthPopoverTab.vue';
import MCPAccessTokenPopoverTab from '@/features/ai/mcpAccess/components/header/connectPopover/MCPAccessTokenPopoverTab.vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { MCP_ENDPOINT, MCP_CONNECT_POPOVER_WIDTH } from '@/features/ai/mcpAccess/mcp.constants';
import { MCP_CONNECT_POPOVER_WIDTH } from '@/features/ai/mcpAccess/mcp.constants';
const i18n = useI18n();
const telemetry = useTelemetry();
const rootStore = useRootStore();
const mcpStore = useMCPStore();
defineProps<{
@@ -28,7 +26,7 @@ const tabItems = ref([
{ value: TABS.ACCESS_TOKEN, label: i18n.baseText('settings.mcp.connectPopover.tab.accessToken') },
]);
const serverUrl = ref(`${rootStore.urlBaseEditor}${MCP_ENDPOINT}`);
const serverUrl = computed(() => mcpStore.serverUrl);
const activeTab = ref(tabItems.value[0].value);
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { MCP_STORE } from './mcp.constants';
import { MCP_ENDPOINT, MCP_STORE } from './mcp.constants';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import {
useWorkflowDocumentStore,
@@ -46,6 +46,13 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
const mcpAccessEnabled = computed(() => !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled);
const mcpManagedByEnv = computed(() => !!settingsStore.moduleSettings.mcp?.mcpManagedByEnv);
// Backend-provided canonical URL, so a configured dedicated MCP base URL is
// reflected; the editor-base fallback covers settings not yet loaded.
const serverUrl = computed(
() =>
settingsStore.moduleSettings.mcp?.serverUrl ?? `${rootStore.urlBaseEditor}${MCP_ENDPOINT}`,
);
async function fetchWorkflowsAvailableForMCP(
page = 1,
pageSize = 50,
@@ -216,6 +223,7 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
return {
mcpAccessEnabled,
mcpManagedByEnv,
serverUrl,
fetchWorkflowsAvailableForMCP,
setMcpAccessEnabled,
toggleWorkflowMcpAccess,