fix: improve MCP page setup UX and remove automation credit quotas (#821)

* Remove MCP credit quotas and improve MCP setup page

- Always show client setup snippets with placeholder keys
- Add View Docs link to MCP page header
- Remove monthly automation credit enforcement and UI
- Update plan feature text to reflect free MCP access

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix: address PR review feedback for MCP page changes

- Clear generatedSecret when revoking MCP keys
- Use Button link mode for View Docs with rel noopener noreferrer
- Update all dashboard locale files for MCP translations
- Make MCP rate-limit admission atomic with transactional reservations
- Wrap MCP route handlers with withMcpAutomationUsage helper

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* revert: remove atomic MCP rate-limit reservations

The transactional advisory-lock reservation was unnecessary complexity
for our MCP usage model. Restore the simpler check-then-record flow.

Also only clear generatedSecret when revoking the key it belongs to.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix: address remaining MCP page bot review feedback

- Add blank lines after confirm guards in key handlers
- Skip showing rotated secret if key was revoked during rotation
- Fix vi keys_title and de rate_limits_title translations

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix: prevent late key rotation from un-revoking or exposing secrets

- Rotate only when revoked_at IS NULL at update time (DB)
- Reject rotate requests for already-revoked keys (API)
- Ignore stale rotate responses in automation client state
- Guard MCP UI against showing secrets for revoked keys

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------
This commit is contained in:
Rotimi Best
2026-08-16 22:12:04 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 1cb752d229
commit 9fb75e11d2
18 changed files with 120 additions and 94 deletions
@@ -13,6 +13,7 @@ import {
getActiveOrganizationApiKeyByHash,
getOrganizationApiKeyById,
listOrganizationApiKeys,
rotateOrganizationApiKeyIfActive,
updateOrganizationApiKey
} from '@cio/db/queries/organization';
import { assertOrganizationAutomationKeyCreationAllowed } from './automation-usage';
@@ -135,16 +136,26 @@ export async function rotateOrganizationApiKeyService(organizationId: string, ke
throw new AppError('Organization API key not found', ErrorCodes.ORGANIZATION_API_KEY_NOT_FOUND, 404);
}
if (existing.revokedAt) {
throw new AppError(
'Cannot rotate a revoked organization API key',
ErrorCodes.ORGANIZATION_API_KEY_ROTATE_FAILED,
400
);
}
const secret = generateOrganizationApiKeySecret(existing.type);
const updated = await updateOrganizationApiKey(organizationId, keyId, {
const updated = await rotateOrganizationApiKeyIfActive(organizationId, keyId, {
secretPrefix: getSecretPrefix(secret),
secretHash: hashSecret(secret),
revokedAt: null,
lastUsedAt: null
secretHash: hashSecret(secret)
});
if (!updated) {
throw new AppError('Failed to rotate organization API key', ErrorCodes.ORGANIZATION_API_KEY_ROTATE_FAILED, 500);
throw new AppError(
'Organization API key was revoked before rotation completed',
ErrorCodes.ORGANIZATION_API_KEY_ROTATE_FAILED,
409
);
}
return {
@@ -6,7 +6,6 @@ import {
countOrganizationAutomationUsageSinceByKey,
createOrganizationAutomationUsage,
getActiveOrganizationPlan,
getOrganizationAutomationCreditsUsedSince,
listRecentOrganizationAutomationUsage
} from '@cio/db/queries/organization';
import type { TOrganizationApiKey, TOrganizationApiKeyType, TPlan } from '@db/types';
@@ -15,7 +14,6 @@ import {
getMcpAutomationCategory,
canUsePublicApi,
getMcpAutomationLimits,
MCP_TOOL_CREDIT_COST,
type TMcpToolName
} from '@cio/utils/plans';
@@ -24,9 +22,6 @@ export type OrganizationAutomationUsageSummary = {
planName: TPlan;
billingPeriodStart: string;
billingPeriodEnd: string;
monthlyCreditsIncluded: number;
monthlyCreditsUsed: number;
monthlyCreditsRemaining: number;
activeKeys: number;
maxActiveKeys: number;
rateLimits: ReturnType<typeof getMcpAutomationLimits>['rateLimits'];
@@ -104,9 +99,6 @@ export async function getOrganizationAutomationUsageSummaryService(
planName,
billingPeriodStart: billingPeriodStart.toISOString(),
billingPeriodEnd: billingPeriodEnd.toISOString(),
monthlyCreditsIncluded: 0,
monthlyCreditsUsed: 0,
monthlyCreditsRemaining: 0,
activeKeys: await countActiveOrganizationApiKeys(organizationId, type),
maxActiveKeys: 0,
rateLimits: {
@@ -118,8 +110,7 @@ export async function getOrganizationAutomationUsageSummaryService(
}
const limits = getMcpAutomationLimits(planName);
const [monthlyCreditsUsed, activeKeys, recentActions] = await Promise.all([
getOrganizationAutomationCreditsUsedSince(organizationId, type, billingPeriodStart.toISOString()),
const [activeKeys, recentActions] = await Promise.all([
countActiveOrganizationApiKeys(organizationId, type),
listRecentOrganizationAutomationUsage(organizationId, type, 10)
]);
@@ -129,9 +120,6 @@ export async function getOrganizationAutomationUsageSummaryService(
planName,
billingPeriodStart: billingPeriodStart.toISOString(),
billingPeriodEnd: billingPeriodEnd.toISOString(),
monthlyCreditsIncluded: limits.monthlyCredits,
monthlyCreditsUsed,
monthlyCreditsRemaining: Math.max(limits.monthlyCredits - monthlyCreditsUsed, 0),
activeKeys,
maxActiveKeys: limits.maxActiveKeys,
rateLimits: limits.rateLimits,
@@ -151,24 +139,13 @@ export async function assertMcpAutomationUsageAllowed(
const limits = getMcpAutomationLimits(planName);
const category = getMcpAutomationCategory(toolName);
const now = new Date();
const billingPeriodStart = getBillingPeriodStart(now).toISOString();
const minuteWindowStart = getMinuteWindowStart(now).toISOString();
const [creditsUsed, keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([
getOrganizationAutomationCreditsUsedSince(automationKey.organizationId, automationKey.type, billingPeriodStart),
const [keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([
countOrganizationAutomationUsageSinceByKey(automationKey.id, category, minuteWindowStart),
countOrganizationAutomationUsageSince(automationKey.organizationId, automationKey.type, category, minuteWindowStart)
]);
const creditsNeeded = MCP_TOOL_CREDIT_COST[toolName];
if (creditsUsed + creditsNeeded > limits.monthlyCredits) {
throw new AppError(
'Monthly automation credits exhausted for this organization',
ErrorCodes.AUTOMATION_CREDIT_LIMIT_EXCEEDED,
429
);
}
const keyLimit =
category === 'read'
? limits.rateLimits.perKey.readPerMinute
@@ -199,7 +176,7 @@ export async function recordMcpAutomationUsage(
type: automationKey.type,
action: toolName,
category: getMcpAutomationCategory(toolName),
creditsConsumed: MCP_TOOL_CREDIT_COST[toolName],
creditsConsumed: 0,
metadata
});
}