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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> ---------
This commit is contained in:
co-authored by
Rotimi Best
parent
1cb752d229
commit
9fb75e11d2
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user