diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 282512f0299..e89966a9af9 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -202,6 +202,7 @@ export { OAuthClientResponseDto, ListOAuthClientsResponseDto, DeleteOAuthClientResponseDto, + InstanceMcpClientStatsResponseDto, } from './oauth/oauth-client.dto'; export { ProvisioningConfigDto, diff --git a/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts b/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts index 91d79e4f9cd..c423dfebae4 100644 --- a/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts +++ b/packages/@n8n/api-types/src/dto/oauth/oauth-client.dto.ts @@ -40,3 +40,12 @@ export class DeleteOAuthClientResponseDto extends Z.class({ success: z.boolean(), message: z.string(), }) {} + +/** + * DTO for instance-wide MCP OAuth client capacity stats (admin-only) + */ +export class InstanceMcpClientStatsResponseDto extends Z.class({ + count: z.number(), + limit: z.number(), + atCapacity: z.boolean(), +}) {} diff --git a/packages/@n8n/config/src/configs/endpoints.config.ts b/packages/@n8n/config/src/configs/endpoints.config.ts index f590a2ac4f3..863312831a9 100644 --- a/packages/@n8n/config/src/configs/endpoints.config.ts +++ b/packages/@n8n/config/src/configs/endpoints.config.ts @@ -136,7 +136,7 @@ export class EndpointsConfig { /** Maximum number of OAuth clients that can be registered for MCP. */ @Env('N8N_MCP_MAX_REGISTERED_CLIENTS') - mcpMaxRegisteredClients: number = 200; + mcpMaxRegisteredClients: number = 5000; /** Whether to disable n8n's UI (frontend). */ @Env('N8N_DISABLE_UI') diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index db761c7a773..1a381101117 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -237,7 +237,7 @@ describe('GlobalConfig', () => { formWaiting: 'form-waiting', mcp: 'mcp', mcpBuilderEnabled: true, - mcpMaxRegisteredClients: 200, + mcpMaxRegisteredClients: 5000, mcpTest: 'mcp-test', payloadSizeMax: 16, formDataFileSizeMax: 200, diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts index 34dfd47c74d..1a1a3dfc1a8 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-oauth-service.test.ts @@ -344,6 +344,7 @@ describe('McpOAuthService', () => { refreshToken: 'refresh-token-456', }); tokenService.saveTokenPair.mockResolvedValue(); + tokenService.getAccessTokenExpirySeconds.mockReturnValue(3600); const result = await service.exchangeAuthorizationCode( client, diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.oauth-clients.controller.api.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.oauth-clients.controller.api.test.ts index 5af386af58f..0217b032c64 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.oauth-clients.controller.api.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.oauth-clients.controller.api.test.ts @@ -1,4 +1,5 @@ import { testDb } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -26,6 +27,93 @@ afterEach(async () => { await testDb.truncate(['OAuthClient', 'UserConsent']); }); +describe('GET /rest/mcp/oauth-clients', () => { + test('should return only the requesting user clients', async () => { + const ownerClient = await oauthClientRepository.save({ + id: 'owner-list-client', + name: 'Owner Client', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'none', + }); + const memberClient = await oauthClientRepository.save({ + id: 'member-list-client', + name: 'Member Client', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'none', + }); + + await userConsentRepository.save({ + userId: owner.id, + clientId: ownerClient.id, + grantedAt: Date.now(), + }); + await userConsentRepository.save({ + userId: member.id, + clientId: memberClient.id, + grantedAt: Date.now(), + }); + + const response = await testServer.authAgentFor(owner).get('/mcp/oauth-clients'); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toMatchObject({ + count: 1, + }); + expect(response.body.data.data).toHaveLength(1); + expect(response.body.data.data[0].id).toBe(ownerClient.id); + }); +}); + +describe('GET /rest/mcp/oauth-clients/instance-stats', () => { + test('should return instance-wide stats for an owner', async () => { + const response = await testServer.authAgentFor(owner).get('/mcp/oauth-clients/instance-stats'); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toMatchObject({ + count: expect.any(Number), + limit: expect.any(Number), + atCapacity: expect.any(Boolean), + }); + }); + + test('should report atCapacity=true when the instance limit is reached', async () => { + const globalConfig = Container.get(GlobalConfig); + const originalLimit = globalConfig.endpoints.mcpMaxRegisteredClients; + globalConfig.endpoints.mcpMaxRegisteredClients = 1; + + try { + await oauthClientRepository.save({ + id: 'capacity-client', + name: 'Capacity Client', + redirectUris: ['https://example.com/callback'], + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'none', + }); + + const response = await testServer + .authAgentFor(owner) + .get('/mcp/oauth-clients/instance-stats'); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toMatchObject({ + count: 1, + limit: 1, + atCapacity: true, + }); + } finally { + globalConfig.endpoints.mcpMaxRegisteredClients = originalLimit; + } + }); + + test('should return 403 when a non-admin member calls the endpoint', async () => { + const response = await testServer.authAgentFor(member).get('/mcp/oauth-clients/instance-stats'); + + expect(response.statusCode).toBe(403); + }); +}); + describe('DELETE /rest/mcp/oauth-clients/:clientId', () => { test('should allow a user to delete their own OAuth client', async () => { const client = await oauthClientRepository.save({ diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.oauth.controller.api.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.oauth.controller.api.test.ts index e8dd52b2bff..11a40c38dce 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.oauth.controller.api.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.oauth.controller.api.test.ts @@ -1,4 +1,5 @@ import { testDb } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -261,6 +262,70 @@ describe('POST /mcp-oauth/register', () => { expect(response.statusCode).toBeGreaterThanOrEqual(400); }); + + test('should reject with 503 server_error when instance client limit is reached (pre-check)', async () => { + const globalConfig = Container.get(GlobalConfig); + const originalLimit = globalConfig.endpoints.mcpMaxRegisteredClients; + globalConfig.endpoints.mcpMaxRegisteredClients = 1; + + try { + const clientData = { + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + }; + + const first = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData); + expect(first.statusCode).toBe(201); + + const second = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData); + expect(second.statusCode).toBe(503); + expect(second.body).toMatchObject({ + error: 'server_error', + error_description: expect.stringContaining('maximum of 1 registered MCP clients'), + }); + } finally { + globalConfig.endpoints.mcpMaxRegisteredClients = originalLimit; + } + }); + + test('should reject with descriptive server_error on the post-insert rollback (race path)', async () => { + const { McpOAuthService } = await import('../mcp-oauth-service'); + const globalConfig = Container.get(GlobalConfig); + const originalLimit = globalConfig.endpoints.mcpMaxRegisteredClients; + globalConfig.endpoints.mcpMaxRegisteredClients = 1; + + // Stub the pre-check guard to always pass, simulating two concurrent + // registrations that both saw count < limit and made it past the guard. + const guardSpy = jest + .spyOn(McpOAuthService.prototype, 'isClientLimitReached') + .mockResolvedValue(false); + + try { + const clientData = { + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + }; + + const first = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData); + expect(first.statusCode).toBe(201); + + // Now count = 1, limit = 1. The guard is stubbed to pass; the + // post-insert check sees count = 2 > 1 and throws. + const second = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData); + expect(second.statusCode).toBe(500); + expect(second.body).toMatchObject({ + error: 'server_error', + error_description: expect.stringContaining('maximum of 1 registered MCP clients'), + }); + } finally { + guardSpy.mockRestore(); + globalConfig.endpoints.mcpMaxRegisteredClients = originalLimit; + } + }); }); describe('GET /mcp-oauth/authorize', () => { diff --git a/packages/cli/src/modules/mcp/mcp-oauth-service.ts b/packages/cli/src/modules/mcp/mcp-oauth-service.ts index 4dfd2af95a9..e1d29270432 100644 --- a/packages/cli/src/modules/mcp/mcp-oauth-service.ts +++ b/packages/cli/src/modules/mcp/mcp-oauth-service.ts @@ -19,6 +19,7 @@ import { OAuthClientRepository } from './database/repositories/oauth-client.repo import { UserConsentRepository } from './database/repositories/oauth-user-consent.repository'; import { McpOAuthAuthorizationCodeService } from './mcp-oauth-authorization-code.service'; import { McpOAuthTokenService } from './mcp-oauth-token.service'; +import { McpClientLimitReachedError } from './mcp.errors'; import { OAuthSessionService } from './oauth-session.service'; export const SUPPORTED_SCOPES = ['tool:listWorkflows', 'tool:getWorkflowDetails']; @@ -91,17 +92,40 @@ export class McpOAuthService implements OAuthServerProvider { }; } + /** Returns true when the instance is already at or above the registered-client cap. */ + async isClientLimitReached(): Promise { + const clientCount = await this.oauthClientRepository.count(); + return clientCount >= this.globalConfig.endpoints.mcpMaxRegisteredClients; + } + + async getInstanceClientStats(): Promise<{ + count: number; + limit: number; + atCapacity: boolean; + }> { + const count = await this.oauthClientRepository.count(); + const limit = this.globalConfig.endpoints.mcpMaxRegisteredClients; + return { count, limit, atCapacity: count >= limit }; + } + /** * Check count after insert to avoid race condition between count() and insert(). * If over limit, rolls back by deleting the just-inserted client. + * + * Throws `McpClientLimitReachedError` (a `ServerError` subclass), which the + * MCP SDK's register handler will surface as a 500 with our descriptive body + * — matching the response shape of the pre-check guard at the route layer. */ private async enforceClientLimit(clientId: string): Promise { const clientCount = await this.oauthClientRepository.count(); - if (clientCount > this.globalConfig.endpoints.mcpMaxRegisteredClients) { + const limit = this.globalConfig.endpoints.mcpMaxRegisteredClients; + if (clientCount > limit) { await this.oauthClientRepository.delete({ id: clientId }); - throw new Error( - `Maximum number of registered clients (${this.globalConfig.endpoints.mcpMaxRegisteredClients}) reached`, + this.logger.warn( + 'MCP OAuth client registration rejected: instance limit reached (post-insert rollback)', + { limit, clientCount }, ); + throw new McpClientLimitReachedError(limit); } } @@ -196,7 +220,7 @@ export class McpOAuthService implements OAuthServerProvider { return { access_token: accessToken, token_type: 'Bearer', - expires_in: 3600, + expires_in: this.tokenService.getAccessTokenExpirySeconds(), refresh_token: refreshToken, }; } diff --git a/packages/cli/src/modules/mcp/mcp-oauth-token.service.ts b/packages/cli/src/modules/mcp/mcp-oauth-token.service.ts index 8cc66cd653c..4907820f8d2 100644 --- a/packages/cli/src/modules/mcp/mcp-oauth-token.service.ts +++ b/packages/cli/src/modules/mcp/mcp-oauth-token.service.ts @@ -35,6 +35,10 @@ export class McpOAuthTokenService { private readonly refreshTokenRepository: RefreshTokenRepository, ) {} + getAccessTokenExpirySeconds(): number { + return this.ACCESS_TOKEN_EXPIRY_SECONDS; + } + generateTokenPair( userId: string, clientId: string, diff --git a/packages/cli/src/modules/mcp/mcp.errors.ts b/packages/cli/src/modules/mcp/mcp.errors.ts index 520fb982535..1e10d3c5048 100644 --- a/packages/cli/src/modules/mcp/mcp.errors.ts +++ b/packages/cli/src/modules/mcp/mcp.errors.ts @@ -1,3 +1,4 @@ +import { ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import { Time } from '@n8n/constants'; import { UserError } from 'n8n-workflow'; @@ -5,6 +6,25 @@ import { AuthError } from '@/errors/response-errors/auth.error'; import type { WorkflowNotFoundReason } from './mcp.types'; +export const buildMcpClientLimitReachedMessage = (limit: number): string => + `This n8n instance has reached its maximum of ${limit} registered MCP clients. Ask an administrator to revoke unused clients or raise N8N_MCP_MAX_REGISTERED_CLIENTS.`; + +/** + * Thrown from the DCR registration path when the instance-wide registered-client + * cap is hit. Subclasses the MCP SDK's `ServerError` so that the SDK's register + * handler surfaces our descriptive body instead of its generic + * "Internal Server Error" fallback. + */ +export class McpClientLimitReachedError extends ServerError { + readonly limit: number; + + constructor(limit: number) { + super(buildMcpClientLimitReachedMessage(limit)); + this.name = 'McpClientLimitReachedError'; + this.limit = limit; + } +} + /** * Error thrown when MCP workflow execution times out */ diff --git a/packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts b/packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts index ac1908b83e4..880ea96be90 100644 --- a/packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.oauth-clients.controller.ts @@ -1,5 +1,6 @@ import { DeleteOAuthClientResponseDto, + InstanceMcpClientStatsResponseDto, ListOAuthClientsResponseDto, OAuthClientResponseDto, } from '@n8n/api-types'; @@ -50,6 +51,17 @@ export class McpOAuthClientsController { }; } + /** + * Instance-wide MCP OAuth client capacity stats. Admin-only — gated by + * the `mcp:manage` global scope, matching the existing administrative + * MCP settings endpoint. + */ + @GlobalScope('mcp:manage') + @Get('/instance-stats') + async getInstanceStats(): Promise { + return await this.mcpOAuthService.getInstanceClientStats(); + } + /** * Delete an OAuth client by ID * This will cascade delete all related tokens, authorization codes, and user consents diff --git a/packages/cli/src/modules/mcp/mcp.oauth.controller.ts b/packages/cli/src/modules/mcp/mcp.oauth.controller.ts index 8ea904724cf..901974a7a3f 100644 --- a/packages/cli/src/modules/mcp/mcp.oauth.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.oauth.controller.ts @@ -2,6 +2,8 @@ import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/hand import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register.js'; import { revocationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/revoke.js'; import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js'; +import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { Get, Options, RootLevelController, StaticRouterMetadata } from '@n8n/decorators'; import { Container } from '@n8n/di'; @@ -11,10 +13,13 @@ import { UrlService } from '@/services/url.service'; import { McpOAuthService, SUPPORTED_SCOPES } from './mcp-oauth-service'; import { MCP_ACCESS_DISABLED_ERROR_MESSAGE } from './mcp.constants'; +import { buildMcpClientLimitReachedMessage } from './mcp.errors'; import { McpSettingsService } from './mcp.settings.service'; const mcpOAuthService = Container.get(McpOAuthService); const mcpSettingsService = Container.get(McpSettingsService); +const globalConfig = Container.get(GlobalConfig); +const logger = Container.get(Logger); /** * Middleware that rejects requests when MCP access is disabled. @@ -29,6 +34,33 @@ const mcpEnabledGuard: RequestHandler = async (_req, res, next) => { next(); }; +/** + * Pre-check guard for the unauthenticated DCR endpoint. Short-circuits with + * a structured `server_error` response when the instance is at the + * registered-client cap. Returns HTTP 503 because limit exhaustion is a + * temporary capacity condition, not an internal failure. + * + * The post-insert rollback in `enforceClientLimit` throws + * `McpClientLimitReachedError` (a `ServerError` subclass) so the SDK + * surfaces the same body shape on the rare race path; the SDK's register + * handler hardcodes 500 for `ServerError`, so that path returns 500 with + * an identical body. + */ +const mcpClientLimitGuard: RequestHandler = async (_req, res, next) => { + if (await mcpOAuthService.isClientLimitReached()) { + const limit = globalConfig.endpoints.mcpMaxRegisteredClients; + logger.warn('MCP OAuth client registration rejected: instance limit reached (pre-check)', { + limit, + }); + res.status(503).json({ + error: 'server_error', + error_description: buildMcpClientLimitReachedMessage(limit), + }); + return; + } + next(); +}; + @RootLevelController('/') export class McpOAuthController { constructor(private readonly urlService: UrlService) {} @@ -46,7 +78,7 @@ export class McpOAuthController { path: '/mcp-oauth/register', router: clientRegistrationHandler({ clientsStore: mcpOAuthService.clientsStore }) as Router, skipAuth: true, - middlewares: [mcpEnabledGuard], + middlewares: [mcpEnabledGuard, mcpClientLimitGuard], ipRateLimit: { limit: 10, windowMs: 5 * Time.minutes.toMilliseconds }, }, { diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 18b1c5eb93d..5b27266ae1c 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2779,6 +2779,7 @@ "settings.mcp.emptyState.docs.part1": "Read our docs to", "settings.mcp.tabs.workflows": "Workflows", "settings.mcp.tabs.oauth": "Connected clients", + "settings.mcp.instanceCapacity.warning": "MCP client registrations are at the instance limit ({count}/{limit}). New OAuth connections will be rejected until clients are revoked or N8N_MCP_MAX_REGISTERED_CLIENTS is raised.", "settings.mcp.access.token.notice": "Make sure to copy your access token, you won't be able to see it again", "settings.mcp.workflows.table.action.removeMCPAccess": "Remove access", "settings.mcp.workflows.table.action.updateDescription": "Edit description", diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts index e198b17b3b8..45d407dfedc 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts @@ -96,6 +96,8 @@ describe('SettingsMCPView', () => { mcpManagedByEnv: false, }, }; + + mcpStore.getAllOAuthClients.mockResolvedValue([]); }); afterEach(() => { @@ -466,4 +468,90 @@ describe('SettingsMCPView', () => { ); }); }); + + describe('Instance capacity notice', () => { + beforeEach(() => { + settingsStore.moduleSettings = { + mcp: { + mcpAccessEnabled: true, + mcpManagedByEnv: false, + }, + }; + mcpStore.fetchWorkflowsAvailableForMCP.mockResolvedValue([]); + mcpStore.getInstanceClientStats.mockResolvedValue(null); + }); + + it('should render the notice for an instance owner when atCapacity is true', async () => { + usersStore.isInstanceOwner = true; + mcpStore.instanceClientStats = { count: 2, limit: 2, atCapacity: true }; + + const { findByTestId } = createComponent({ pinia }); + + const notice = await findByTestId('mcp-instance-capacity-notice'); + expect(notice).toBeVisible(); + expect(notice.textContent).toContain('2/2'); + }); + + it('should render the notice for an admin when atCapacity is true', async () => { + usersStore.isAdmin = true; + mcpStore.instanceClientStats = { count: 5, limit: 5, atCapacity: true }; + + const { findByTestId } = createComponent({ pinia }); + + const notice = await findByTestId('mcp-instance-capacity-notice'); + expect(notice).toBeVisible(); + }); + + it('should NOT render the notice for a non-admin member', async () => { + usersStore.isInstanceOwner = false; + usersStore.isAdmin = false; + // Even if a stats payload sneaks in (shouldn't happen — store guards 403), + // the view should still hide the notice for non-admins. + mcpStore.instanceClientStats = { count: 2, limit: 2, atCapacity: true }; + + const { queryByTestId } = createComponent({ pinia }); + await nextTick(); + + expect(queryByTestId('mcp-instance-capacity-notice')).not.toBeInTheDocument(); + }); + + it('should NOT render the notice when atCapacity is false', async () => { + usersStore.isInstanceOwner = true; + mcpStore.instanceClientStats = { count: 1, limit: 5, atCapacity: false }; + + const { queryByTestId } = createComponent({ pinia }); + await nextTick(); + + expect(queryByTestId('mcp-instance-capacity-notice')).not.toBeInTheDocument(); + }); + + it('should NOT render the notice when stats have not been fetched', async () => { + usersStore.isInstanceOwner = true; + mcpStore.instanceClientStats = null; + + const { queryByTestId } = createComponent({ pinia }); + await nextTick(); + + expect(queryByTestId('mcp-instance-capacity-notice')).not.toBeInTheDocument(); + }); + + it('should fetch instance stats on mount for an admin/owner', async () => { + usersStore.isInstanceOwner = true; + + createComponent({ pinia }); + await nextTick(); + + expect(mcpStore.getInstanceClientStats).toHaveBeenCalled(); + }); + + it('should not fetch instance stats on mount for a regular member', async () => { + usersStore.isInstanceOwner = false; + usersStore.isAdmin = false; + + createComponent({ pinia }); + await nextTick(); + + expect(mcpStore.getInstanceClientStats).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue index 7ff4bd58130..ef80a61de94 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue @@ -18,6 +18,7 @@ import WorkflowsTable from '@/features/ai/mcpAccess/components/tabs/WorkflowsTab import OAuthClientsTable from '@/features/ai/mcpAccess/components/tabs/OAuthClientsTable.vue'; import { N8nHeading, + N8nNotice, N8nTabs, N8nTooltip, N8nButton, @@ -68,6 +69,20 @@ const isAdmin = computed(() => usersStore.isAdmin); const canToggleMCP = computed(() => (isOwner.value || isAdmin.value) && !mcpStore.mcpManagedByEnv); +const canSeeInstanceStats = computed(() => isOwner.value || isAdmin.value); + +const showInstanceCapacityNotice = computed( + () => canSeeInstanceStats.value && mcpStore.instanceClientStats?.atCapacity === true, +); + +const instanceCapacityNoticeContent = computed(() => { + const stats = mcpStore.instanceClientStats; + if (!stats) return ''; + return i18n.baseText('settings.mcp.instanceCapacity.warning', { + interpolate: { count: String(stats.count), limit: String(stats.limit) }, + }); +}); + const showConnectWorkflowsButton = computed(() => { return selectedTab.value === 'workflows' && availableWorkflows.value.length > 0; }); @@ -164,7 +179,7 @@ const fetchoAuthCLients = async () => { try { oAuthClientsLoading.value = true; const clients = await mcpStore.getAllOAuthClients(); - connectedOAuthClients.value = clients; + connectedOAuthClients.value = clients ?? []; } catch (error) { toast.showError(error, i18n.baseText('settings.mcp.error.fetching.oAuthClients')); } finally { @@ -207,7 +222,11 @@ onMounted(async () => { if (!mcpStore.mcpAccessEnabled) { return; } - await fetchAvailableWorkflows(); + const fetches: Array> = [fetchAvailableWorkflows(), fetchoAuthCLients()]; + if (canSeeInstanceStats.value) { + fetches.push(mcpStore.getInstanceClientStats()); + } + await Promise.all(fetches); });