fix(core): Make MCP client registration cap tunable and surface a proper limit error (#29429)

This commit is contained in:
Ricardo Espinoza
2026-05-04 13:54:59 +00:00
committed by GitHub
parent dc6bd68de3
commit dad423155f
18 changed files with 431 additions and 12 deletions
+1
View File
@@ -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')
+1 -1
View File
@@ -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">
@@ -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,