mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(core): Make MCP client registration cap tunable and surface a proper limit error (#29429)
This commit is contained in:
@@ -202,6 +202,7 @@ export {
|
||||
OAuthClientResponseDto,
|
||||
ListOAuthClientsResponseDto,
|
||||
DeleteOAuthClientResponseDto,
|
||||
InstanceMcpClientStatsResponseDto,
|
||||
} from './oauth/oauth-client.dto';
|
||||
export {
|
||||
ProvisioningConfigDto,
|
||||
|
||||
@@ -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(),
|
||||
}) {}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('GlobalConfig', () => {
|
||||
formWaiting: 'form-waiting',
|
||||
mcp: 'mcp',
|
||||
mcpBuilderEnabled: true,
|
||||
mcpMaxRegisteredClients: 200,
|
||||
mcpMaxRegisteredClients: 5000,
|
||||
mcpTest: 'mcp-test',
|
||||
payloadSizeMax: 16,
|
||||
formDataFileSizeMax: 200,
|
||||
|
||||
@@ -344,6 +344,7 @@ describe('McpOAuthService', () => {
|
||||
refreshToken: 'refresh-token-456',
|
||||
});
|
||||
tokenService.saveTokenPair.mockResolvedValue();
|
||||
tokenService.getAccessTokenExpirySeconds.mockReturnValue(3600);
|
||||
|
||||
const result = await service.exchangeAuthorizationCode(
|
||||
client,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ export class McpOAuthTokenService {
|
||||
private readonly refreshTokenRepository: RefreshTokenRepository,
|
||||
) {}
|
||||
|
||||
getAccessTokenExpirySeconds(): number {
|
||||
return this.ACCESS_TOKEN_EXPIRY_SECONDS;
|
||||
}
|
||||
|
||||
generateTokenPair(
|
||||
userId: string,
|
||||
clientId: string,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<InstanceMcpClientStatsResponseDto> {
|
||||
return await this.mcpOAuthService.getInstanceClientStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an OAuth client by ID
|
||||
* This will cascade delete all related tokens, authorization codes, and user consents
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Promise<unknown>> = [fetchAvailableWorkflows(), fetchoAuthCLients()];
|
||||
if (canSeeInstanceStats.value) {
|
||||
fetches.push(mcpStore.getInstanceClientStats());
|
||||
}
|
||||
await Promise.all(fetches);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
@@ -255,6 +274,12 @@ onMounted(async () => {
|
||||
:class="$style.container"
|
||||
data-test-id="mcp-enabled-section"
|
||||
>
|
||||
<N8nNotice
|
||||
v-if="showInstanceCapacityNotice"
|
||||
theme="warning"
|
||||
data-test-id="mcp-instance-capacity-notice"
|
||||
:content="instanceCapacityNoticeContent"
|
||||
/>
|
||||
<header :class="$style['tabs-header']">
|
||||
<N8nTabs :model-value="selectedTab" :options="tabs" @update:model-value="onTabSelected" />
|
||||
<div :class="$style.actions">
|
||||
|
||||
+22
-2
@@ -9,7 +9,7 @@ import {
|
||||
N8nLoading,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
|
||||
import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServer';
|
||||
import TimeAgo from '@/app/components/TimeAgo.vue';
|
||||
@@ -24,6 +24,24 @@ type Props = {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const page = ref(0);
|
||||
const itemsPerPage = ref(10);
|
||||
|
||||
const visibleClients = computed(() => {
|
||||
const start = page.value * itemsPerPage.value;
|
||||
return props.clients.slice(start, start + itemsPerPage.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.clients.length,
|
||||
(length) => {
|
||||
const maxPage = Math.max(0, Math.ceil(length / itemsPerPage.value) - 1);
|
||||
if (page.value > maxPage) {
|
||||
page.value = maxPage;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
revokeClient: [client: OAuthClientResponseDto];
|
||||
}>();
|
||||
@@ -81,9 +99,11 @@ const onTableAction = (action: string, item: OAuthClientResponseDto) => {
|
||||
</div>
|
||||
<div v-else class="mt-s mb-xl">
|
||||
<N8nDataTableServer
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
data-test-id="oauth-clients-data-table"
|
||||
:headers="tableHeaders"
|
||||
:items="props.clients"
|
||||
:items="visibleClients"
|
||||
:items-length="props.clients.length"
|
||||
>
|
||||
<template v-if="props.clients.length === 0" #cover>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ApiKey,
|
||||
InstanceMcpClientStatsResponseDto,
|
||||
ListOAuthClientsResponseDto,
|
||||
DeleteOAuthClientResponseDto,
|
||||
} from '@n8n/api-types';
|
||||
@@ -65,6 +66,12 @@ export async function fetchOAuthClients(
|
||||
return await makeRestApiRequest(context, 'GET', '/mcp/oauth-clients');
|
||||
}
|
||||
|
||||
export async function fetchInstanceMcpClientStats(
|
||||
context: IRestApiContext,
|
||||
): Promise<InstanceMcpClientStatsResponseDto> {
|
||||
return await makeRestApiRequest(context, 'GET', '/mcp/oauth-clients/instance-stats');
|
||||
}
|
||||
|
||||
export async function deleteOAuthClient(
|
||||
context: IRestApiContext,
|
||||
clientId: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
fetchApiKey,
|
||||
rotateApiKey,
|
||||
fetchOAuthClients,
|
||||
fetchInstanceMcpClientStats,
|
||||
deleteOAuthClient,
|
||||
fetchMcpEligibleWorkflows,
|
||||
type ToggleWorkflowsMcpAccessResponse,
|
||||
@@ -22,7 +23,12 @@ import {
|
||||
import { computed, ref } from 'vue';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { isWorkflowListItem } from '@/app/utils/typeGuards';
|
||||
import type { ApiKey, OAuthClientResponseDto, DeleteOAuthClientResponseDto } from '@n8n/api-types';
|
||||
import type {
|
||||
ApiKey,
|
||||
InstanceMcpClientStatsResponseDto,
|
||||
OAuthClientResponseDto,
|
||||
DeleteOAuthClientResponseDto,
|
||||
} from '@n8n/api-types';
|
||||
import { i18n } from '@n8n/i18n';
|
||||
|
||||
export const useMCPStore = defineStore(MCP_STORE, () => {
|
||||
@@ -33,6 +39,7 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
|
||||
|
||||
const currentUserMCPKey = ref<ApiKey | null>(null);
|
||||
const oauthClients = ref<OAuthClientResponseDto[]>([]);
|
||||
const instanceClientStats = ref<InstanceMcpClientStatsResponseDto | null>(null);
|
||||
const connectPopoverOpen = ref(false);
|
||||
|
||||
const mcpAccessEnabled = computed(() => !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled);
|
||||
@@ -150,6 +157,19 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getInstanceClientStats(): Promise<InstanceMcpClientStatsResponseDto | null> {
|
||||
try {
|
||||
const stats = await fetchInstanceMcpClientStats(rootStore.restApiContext);
|
||||
instanceClientStats.value = stats;
|
||||
return stats;
|
||||
} catch {
|
||||
// Endpoint is admin-only; non-admin members get 403. Swallow silently
|
||||
// so the settings page still renders for them.
|
||||
instanceClientStats.value = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOAuthClient(clientId: string): Promise<DeleteOAuthClientResponseDto> {
|
||||
const response = await deleteOAuthClient(rootStore.restApiContext, clientId);
|
||||
// Remove the client from the local store
|
||||
@@ -185,7 +205,9 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
|
||||
generateNewApiKey,
|
||||
resetCurrentUserMCPKey,
|
||||
oauthClients,
|
||||
instanceClientStats,
|
||||
getAllOAuthClients,
|
||||
getInstanceClientStats,
|
||||
removeOAuthClient,
|
||||
getMcpEligibleWorkflows,
|
||||
connectPopoverOpen,
|
||||
|
||||
Reference in New Issue
Block a user