mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
fix(core): Add N8N_MCP_BASE_URL for split-hostname MCP OAuth deployments (#34028)
This commit is contained in:
@@ -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 = '';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
-4
@@ -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(
|
||||
|
||||
+3
-5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user