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:
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, getActiveOrganizationApiKeyByHash,
getOrganizationApiKeyById, getOrganizationApiKeyById,
listOrganizationApiKeys, listOrganizationApiKeys,
rotateOrganizationApiKeyIfActive,
updateOrganizationApiKey updateOrganizationApiKey
} from '@cio/db/queries/organization'; } from '@cio/db/queries/organization';
import { assertOrganizationAutomationKeyCreationAllowed } from './automation-usage'; 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); 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 secret = generateOrganizationApiKeySecret(existing.type);
const updated = await updateOrganizationApiKey(organizationId, keyId, { const updated = await rotateOrganizationApiKeyIfActive(organizationId, keyId, {
secretPrefix: getSecretPrefix(secret), secretPrefix: getSecretPrefix(secret),
secretHash: hashSecret(secret), secretHash: hashSecret(secret)
revokedAt: null,
lastUsedAt: null
}); });
if (!updated) { 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 { return {
@@ -6,7 +6,6 @@ import {
countOrganizationAutomationUsageSinceByKey, countOrganizationAutomationUsageSinceByKey,
createOrganizationAutomationUsage, createOrganizationAutomationUsage,
getActiveOrganizationPlan, getActiveOrganizationPlan,
getOrganizationAutomationCreditsUsedSince,
listRecentOrganizationAutomationUsage listRecentOrganizationAutomationUsage
} from '@cio/db/queries/organization'; } from '@cio/db/queries/organization';
import type { TOrganizationApiKey, TOrganizationApiKeyType, TPlan } from '@db/types'; import type { TOrganizationApiKey, TOrganizationApiKeyType, TPlan } from '@db/types';
@@ -15,7 +14,6 @@ import {
getMcpAutomationCategory, getMcpAutomationCategory,
canUsePublicApi, canUsePublicApi,
getMcpAutomationLimits, getMcpAutomationLimits,
MCP_TOOL_CREDIT_COST,
type TMcpToolName type TMcpToolName
} from '@cio/utils/plans'; } from '@cio/utils/plans';
@@ -24,9 +22,6 @@ export type OrganizationAutomationUsageSummary = {
planName: TPlan; planName: TPlan;
billingPeriodStart: string; billingPeriodStart: string;
billingPeriodEnd: string; billingPeriodEnd: string;
monthlyCreditsIncluded: number;
monthlyCreditsUsed: number;
monthlyCreditsRemaining: number;
activeKeys: number; activeKeys: number;
maxActiveKeys: number; maxActiveKeys: number;
rateLimits: ReturnType<typeof getMcpAutomationLimits>['rateLimits']; rateLimits: ReturnType<typeof getMcpAutomationLimits>['rateLimits'];
@@ -104,9 +99,6 @@ export async function getOrganizationAutomationUsageSummaryService(
planName, planName,
billingPeriodStart: billingPeriodStart.toISOString(), billingPeriodStart: billingPeriodStart.toISOString(),
billingPeriodEnd: billingPeriodEnd.toISOString(), billingPeriodEnd: billingPeriodEnd.toISOString(),
monthlyCreditsIncluded: 0,
monthlyCreditsUsed: 0,
monthlyCreditsRemaining: 0,
activeKeys: await countActiveOrganizationApiKeys(organizationId, type), activeKeys: await countActiveOrganizationApiKeys(organizationId, type),
maxActiveKeys: 0, maxActiveKeys: 0,
rateLimits: { rateLimits: {
@@ -118,8 +110,7 @@ export async function getOrganizationAutomationUsageSummaryService(
} }
const limits = getMcpAutomationLimits(planName); const limits = getMcpAutomationLimits(planName);
const [monthlyCreditsUsed, activeKeys, recentActions] = await Promise.all([ const [activeKeys, recentActions] = await Promise.all([
getOrganizationAutomationCreditsUsedSince(organizationId, type, billingPeriodStart.toISOString()),
countActiveOrganizationApiKeys(organizationId, type), countActiveOrganizationApiKeys(organizationId, type),
listRecentOrganizationAutomationUsage(organizationId, type, 10) listRecentOrganizationAutomationUsage(organizationId, type, 10)
]); ]);
@@ -129,9 +120,6 @@ export async function getOrganizationAutomationUsageSummaryService(
planName, planName,
billingPeriodStart: billingPeriodStart.toISOString(), billingPeriodStart: billingPeriodStart.toISOString(),
billingPeriodEnd: billingPeriodEnd.toISOString(), billingPeriodEnd: billingPeriodEnd.toISOString(),
monthlyCreditsIncluded: limits.monthlyCredits,
monthlyCreditsUsed,
monthlyCreditsRemaining: Math.max(limits.monthlyCredits - monthlyCreditsUsed, 0),
activeKeys, activeKeys,
maxActiveKeys: limits.maxActiveKeys, maxActiveKeys: limits.maxActiveKeys,
rateLimits: limits.rateLimits, rateLimits: limits.rateLimits,
@@ -151,24 +139,13 @@ export async function assertMcpAutomationUsageAllowed(
const limits = getMcpAutomationLimits(planName); const limits = getMcpAutomationLimits(planName);
const category = getMcpAutomationCategory(toolName); const category = getMcpAutomationCategory(toolName);
const now = new Date(); const now = new Date();
const billingPeriodStart = getBillingPeriodStart(now).toISOString();
const minuteWindowStart = getMinuteWindowStart(now).toISOString(); const minuteWindowStart = getMinuteWindowStart(now).toISOString();
const [creditsUsed, keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([ const [keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([
getOrganizationAutomationCreditsUsedSince(automationKey.organizationId, automationKey.type, billingPeriodStart),
countOrganizationAutomationUsageSinceByKey(automationKey.id, category, minuteWindowStart), countOrganizationAutomationUsageSinceByKey(automationKey.id, category, minuteWindowStart),
countOrganizationAutomationUsageSince(automationKey.organizationId, automationKey.type, 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 = const keyLimit =
category === 'read' category === 'read'
? limits.rateLimits.perKey.readPerMinute ? limits.rateLimits.perKey.readPerMinute
@@ -199,7 +176,7 @@ export async function recordMcpAutomationUsage(
type: automationKey.type, type: automationKey.type,
action: toolName, action: toolName,
category: getMcpAutomationCategory(toolName), category: getMcpAutomationCategory(toolName),
creditsConsumed: MCP_TOOL_CREDIT_COST[toolName], creditsConsumed: 0,
metadata metadata
}); });
} }
@@ -99,6 +99,12 @@ class AutomationApi extends BaseApiWithErrors {
requestFn: () => classroomio.organization.automation.keys[':keyId'].rotate.$post({ param: { keyId } }), requestFn: () => classroomio.organization.automation.keys[':keyId'].rotate.$post({ param: { keyId } }),
logContext: 'rotating automation key', logContext: 'rotating automation key',
onSuccess: (response) => { onSuccess: (response) => {
const existingKey = this.keys.find((key) => key.id === response.data.key.id);
if (existingKey?.revokedAt) {
this.generatedSecret = null;
return;
}
this.generatedSecret = response.data.secret; this.generatedSecret = response.data.secret;
this.keys = this.keys.map((key) => (key.id === response.data.key.id ? response.data.key : key)); this.keys = this.keys.map((key) => (key.id === response.data.key.id ? response.data.key : key));
snackbar.success('snackbar.automation.rotated'); snackbar.success('snackbar.automation.rotated');
@@ -21,7 +21,6 @@
import * as Tabs from '@cio/ui/base/tabs'; import * as Tabs from '@cio/ui/base/tabs';
import { InputField } from '@cio/ui/custom/input-field'; import { InputField } from '@cio/ui/custom/input-field';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import CoinsIcon from '@lucide/svelte/icons/coins';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import GaugeIcon from '@lucide/svelte/icons/gauge'; import GaugeIcon from '@lucide/svelte/icons/gauge';
import KeyIcon from '@lucide/svelte/icons/key'; import KeyIcon from '@lucide/svelte/icons/key';
@@ -32,6 +31,7 @@
let activeSetupTab = $state('cursor'); let activeSetupTab = $state('cursor');
let generatedSecret = $state<string | null>(null); let generatedSecret = $state<string | null>(null);
let generatedSecretKeyId = $state<string | null>(null);
let isCreateKeyModalOpen = $state(false); let isCreateKeyModalOpen = $state(false);
let keyLabel = $state(''); let keyLabel = $state('');
@@ -52,6 +52,7 @@
if (!result) return; if (!result) return;
generatedSecret = automationApi.generatedSecret; generatedSecret = automationApi.generatedSecret;
generatedSecretKeyId = result.key.id;
automationApi.clearGeneratedSecret(); automationApi.clearGeneratedSecret();
isCreateKeyModalOpen = false; isCreateKeyModalOpen = false;
resetCreateKeyModal(); resetCreateKeyModal();
@@ -59,13 +60,25 @@
async function onRevokeKey(keyId: string) { async function onRevokeKey(keyId: string) {
if (!confirm(t.get('automation.keys.revoke_confirm'))) return; if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
await automationApi.revokeKey(keyId);
const result = await automationApi.revokeKey(keyId);
if (result && generatedSecretKeyId === keyId) {
generatedSecret = null;
generatedSecretKeyId = null;
}
} }
async function onRotateKey(keyId: string) { async function onRotateKey(keyId: string) {
if (!confirm(t.get('automation.keys.rotate_confirm'))) return; if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
await automationApi.rotateKey(keyId);
const result = await automationApi.rotateKey(keyId);
if (!result || result.key.revokedAt) return;
const key = automationApi.keys.find((entry) => entry.id === keyId);
if (!key || key.revokedAt) return;
generatedSecret = automationApi.generatedSecret; generatedSecret = automationApi.generatedSecret;
generatedSecretKeyId = keyId;
automationApi.clearGeneratedSecret(); automationApi.clearGeneratedSecret();
} }
const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp')); const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp'));
@@ -73,13 +86,6 @@
if (!automationApi.usage) return []; if (!automationApi.usage) return [];
return [ return [
{
icon: CoinsIcon,
title: t.get('automation.mcp.usage.credits_title'),
percentage: automationApi.usage.monthlyCreditsRemaining,
description: `${t.get('automation.mcp.usage.used_label')} ${automationApi.usage.monthlyCreditsUsed} / ${automationApi.usage.monthlyCreditsIncluded}`,
hidePercentage: true
},
{ {
icon: KeyIcon, icon: KeyIcon,
title: t.get('automation.mcp.usage.keys_title'), title: t.get('automation.mcp.usage.keys_title'),
@@ -98,7 +104,6 @@
}); });
const isLastUsageCard = (index: number) => index === usageCards.length - 1; const isLastUsageCard = (index: number) => index === usageCards.length - 1;
const hasActiveMcpKey = $derived(mcpKeys.some((key) => !key.revokedAt));
</script> </script>
<Field.Group class="mx-auto w-full space-y-2"> <Field.Group class="mx-auto w-full space-y-2">
@@ -112,7 +117,7 @@
{/if} {/if}
{#if $isOrgAdmin && automationApi.usage} {#if $isOrgAdmin && automationApi.usage}
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-4 md:grid-cols-2">
{#each usageCards as card, i (i)} {#each usageCards as card, i (i)}
<ActivityCard <ActivityCard
activity={card} activity={card}
@@ -228,8 +233,7 @@
{/if} {/if}
</div> </div>
{#if hasActiveMcpKey} <Field.Set class="gap-3!">
<Field.Set class="gap-3!">
<Field.Legend class="flex items-center gap-2"> <Field.Legend class="flex items-center gap-2">
<SparklesIcon class="size-5" /> <SparklesIcon class="size-5" />
{$t('automation.mcp.setup.title')} {$t('automation.mcp.setup.title')}
@@ -277,7 +281,6 @@
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
</Field.Set> </Field.Set>
{/if}
</Field.Group> </Field.Group>
<Dialog.Root <Dialog.Root
@@ -4081,16 +4081,15 @@
}, },
"usage": { "usage": {
"title": "Anvendelse og grænser", "title": "Anvendelse og grænser",
"description": "MCP er tilgængelig på alle abonnementer, men brugen styres med månedlige automatiseringskreditter og nøglegrænser.", "description": "MCP er tilgængelig på alle abonnementer. Brugen styres af nøglegrænser og hastighedsgrænser.",
"credits_title": "Kreditter tilbage",
"credits_description": "Muterende MCP-handlinger bruger kreditter. Læsehandlinger er gratis.",
"keys_title": "Aktive nøgler", "keys_title": "Aktive nøgler",
"keys_description": "Brug én MCP-nøgle pr. integration, så adgang kan roteres eller tilbagekaldes rent.", "keys_description": "Brug én MCP-nøgle pr. integration, så adgang kan roteres eller tilbagekaldes rent.",
"rate_limits_title": "Satsgrænser", "rate_limits_title": "Satsgrænser",
"rate_limits_description": "Læs/skriv/publicér anmodninger tilladt pr. minut.", "rate_limits_description": "Læs/skriv/publicér anmodninger tilladt pr. minut.",
"used_label": "Brugt:" "used_label": "Brugt:"
}, },
"page_subtitle": "Forbind AI-værktøjer til ClassroomIO med organisationsnøgler, du kan rotere når som helst." "page_subtitle": "Forbind AI-værktøjer til ClassroomIO med organisationsnøgler, du kan rotere når som helst.",
"view_docs": "Se dokumentation"
}, },
"api": { "api": {
"subtitle": "Administrer API-nøgler til integration med ClassroomIOs offentlige API.", "subtitle": "Administrer API-nøgler til integration med ClassroomIOs offentlige API.",
@@ -4081,16 +4081,15 @@
}, },
"usage": { "usage": {
"title": "Nutzung und Grenzen", "title": "Nutzung und Grenzen",
"description": "MCP ist in jedem Plan verfügbar, die Nutzung wird jedoch durch monatliche Automatisierungsguthaben und Schlüssellimits gesteuert.", "description": "MCP ist in jedem Plan verfügbar. Die Nutzung wird durch Schlüssellimits und Ratenlimits gesteuert.",
"credits_title": "Verbleibende Credits",
"credits_description": "Mutierende MCP-Aktionen verbrauchen Credits. Leseaktionen sind kostenlos.",
"keys_title": "Aktive Schlüssel", "keys_title": "Aktive Schlüssel",
"keys_description": "Verwenden Sie einen MCP-Schlüssel pro Integration, damit der Zugriff sauber rotiert oder widerrufen werden kann.", "keys_description": "Verwenden Sie einen MCP-Schlüssel pro Integration, damit der Zugriff sauber rotiert oder widerrufen werden kann.",
"rate_limits_title": "Tarifbegrenzungen", "rate_limits_title": "Anfragenlimits pro Minute",
"rate_limits_description": "Zulässige Lese-/Schreib-/Veröffentlichungsanfragen pro Minute.", "rate_limits_description": "Zulässige Lese-/Schreib-/Veröffentlichungsanfragen pro Minute.",
"used_label": "Verwendet:" "used_label": "Verwendet:"
}, },
"page_subtitle": "Verbinden Sie KI-Tools mit ClassroomIO über organisationsbezogene Schlüssel, die Sie jederzeit rotieren können." "page_subtitle": "Verbinden Sie KI-Tools mit ClassroomIO über organisationsbezogene Schlüssel, die Sie jederzeit rotieren können.",
"view_docs": "Dokumentation anzeigen"
}, },
"api": { "api": {
"subtitle": "Verwalten Sie API-Schlüssel zur Integration mit der öffentlichen ClassroomIO-API.", "subtitle": "Verwalten Sie API-Schlüssel zur Integration mit der öffentlichen ClassroomIO-API.",
@@ -4015,9 +4015,7 @@
"page_subtitle": "Connect AI tools to ClassroomIO with org-scoped keys you can rotate anytime.", "page_subtitle": "Connect AI tools to ClassroomIO with org-scoped keys you can rotate anytime.",
"usage": { "usage": {
"title": "Usage and limits", "title": "Usage and limits",
"description": "MCP is available on every plan, but usage is controlled with monthly automation credits and key limits.", "description": "MCP is available on every plan. Usage is controlled by key limits and rate limits.",
"credits_title": "Credits remaining",
"credits_description": "Mutating MCP actions consume credits. Read actions are free.",
"keys_title": "Active keys", "keys_title": "Active keys",
"keys_description": "Use one MCP key per integration so access can be rotated or revoked cleanly.", "keys_description": "Use one MCP key per integration so access can be rotated or revoked cleanly.",
"rate_limits_title": "Rate limits", "rate_limits_title": "Rate limits",
@@ -4045,7 +4043,8 @@
"codex_description": "Adds the ClassroomIO MCP server to Codex with the same org-scoped key.", "codex_description": "Adds the ClassroomIO MCP server to Codex with the same org-scoped key.",
"cursor_description": "Paste this into `~/.cursor/mcp.json` or your workspace MCP config.", "cursor_description": "Paste this into `~/.cursor/mcp.json` or your workspace MCP config.",
"opencode_description": "Add this to `opencode.json` in your project root or `~/.config/opencode/opencode.json`." "opencode_description": "Add this to `opencode.json` in your project root or `~/.config/opencode/opencode.json`."
} },
"view_docs": "View Docs"
}, },
"api": { "api": {
"subtitle": "Manage API keys to integrate with the ClassroomIO public API.", "subtitle": "Manage API keys to integrate with the ClassroomIO public API.",
@@ -4081,16 +4081,15 @@
}, },
"usage": { "usage": {
"title": "Uso y límites", "title": "Uso y límites",
"description": "MCP está disponible en todos los planes, pero el uso se controla con créditos de automatización mensuales y límites clave.", "description": "MCP está disponible en todos los planes. El uso se controla mediante límites de claves y límites de tasa.",
"credits_title": "Créditos restantes",
"credits_description": "Las acciones mutantes de MCP consumen créditos. Las acciones de lectura son gratuitas.",
"keys_title": "Claves activas", "keys_title": "Claves activas",
"keys_description": "Utilice una clave MCP por integración para que el acceso se pueda rotar o revocar limpiamente.", "keys_description": "Utilice una clave MCP por integración para que el acceso se pueda rotar o revocar limpiamente.",
"rate_limits_title": "Límites de tarifas", "rate_limits_title": "Límites de tarifas",
"rate_limits_description": "Solicitudes de lectura/escritura/publicación permitidas por minuto.", "rate_limits_description": "Solicitudes de lectura/escritura/publicación permitidas por minuto.",
"used_label": "Usado:" "used_label": "Usado:"
}, },
"page_subtitle": "Conecta herramientas de IA a ClassroomIO con claves con ámbito de organización que puedes rotar en cualquier momento." "page_subtitle": "Conecta herramientas de IA a ClassroomIO con claves con ámbito de organización que puedes rotar en cualquier momento.",
"view_docs": "Ver documentación"
}, },
"api": { "api": {
"subtitle": "Administra las claves API para integrarte con la API pública de ClassroomIO.", "subtitle": "Administra las claves API para integrarte con la API pública de ClassroomIO.",
@@ -4081,16 +4081,15 @@
}, },
"usage": { "usage": {
"title": "Utilisation et limites", "title": "Utilisation et limites",
"description": "MCP est disponible sur chaque forfait, mais l'utilisation est contrôlée par des crédits d'automatisation mensuels et des limites clés.", "description": "MCP est disponible sur tous les forfaits. L'utilisation est contrôlée par les limites de clés et les limites de taux.",
"credits_title": "Crédits restants",
"credits_description": "Les actions MCP en mutation consomment des crédits. Les actions de lecture sont gratuites.",
"keys_title": "Clés actives", "keys_title": "Clés actives",
"keys_description": "Utilisez une clé MCP par intégration afin que l'accès puisse être alterné ou révoqué proprement.", "keys_description": "Utilisez une clé MCP par intégration afin que l'accès puisse être alterné ou révoqué proprement.",
"rate_limits_title": "Limites de taux", "rate_limits_title": "Limites de taux",
"rate_limits_description": "Requêtes de lecture/écriture/publication autorisées par minute.", "rate_limits_description": "Requêtes de lecture/écriture/publication autorisées par minute.",
"used_label": "Utilisé :" "used_label": "Utilisé :"
}, },
"page_subtitle": "Connectez des outils IA à ClassroomIO avec des clés à l'échelle de l'organisation que vous pouvez faire pivoter à tout moment." "page_subtitle": "Connectez des outils IA à ClassroomIO avec des clés à l'échelle de l'organisation que vous pouvez faire pivoter à tout moment.",
"view_docs": "Voir la documentation"
}, },
"api": { "api": {
"subtitle": "Gérez les clés API pour intégrer l'API publique ClassroomIO.", "subtitle": "Gérez les clés API pour intégrer l'API publique ClassroomIO.",
@@ -4081,16 +4081,15 @@
}, },
"usage": { "usage": {
"title": "उपयोग और सीमाएँ", "title": "उपयोग और सीमाएँ",
"description": "एमसीपी हर योजना पर उपलब्ध है, लेकिन उपयोग को मासिक स्वचालन क्रेडिट और प्रमुख सीमाओं के साथ नियंत्रित किया जाता है।", "description": "एमसीपी हर योजना पर उपलब्ध है। उपयोग कुंजी सीमाओं और दर सीमाओं से नियंत्रित होता है।",
"credits_title": "क्रेडिट शेष",
"credits_description": "MCP क्रियाओं को बदलने से क्रेडिट की खपत होती है। पढ़ें क्रियाएँ निःशुल्क हैं।",
"keys_title": "सक्रिय कुंजियाँ", "keys_title": "सक्रिय कुंजियाँ",
"keys_description": "प्रति एकीकरण एक एमसीपी कुंजी का उपयोग करें ताकि पहुंच को घुमाया जा सके या सफाई से रद्द किया जा सके।", "keys_description": "प्रति एकीकरण एक एमसीपी कुंजी का उपयोग करें ताकि पहुंच को घुमाया जा सके या सफाई से रद्द किया जा सके।",
"rate_limits_title": "दर सीमा", "rate_limits_title": "दर सीमा",
"rate_limits_description": "प्रति मिनट पढ़ने/लिखने/प्रकाशित करने के अनुरोधों की अनुमति है।", "rate_limits_description": "प्रति मिनट पढ़ने/लिखने/प्रकाशित करने के अनुरोधों की अनुमति है।",
"used_label": "प्रयुक्त:" "used_label": "प्रयुक्त:"
}, },
"page_subtitle": "किसी भी समय घुमाने योग्य संगठन-स्तरीय कुंजियों के साथ AI टूल्स को ClassroomIO से कनेक्ट करें।" "page_subtitle": "किसी भी समय घुमाने योग्य संगठन-स्तरीय कुंजियों के साथ AI टूल्स को ClassroomIO से कनेक्ट करें।",
"view_docs": "दस्तावेज़ देखें"
}, },
"api": { "api": {
"subtitle": "ClassroomIO सार्वजनिक API के साथ एकीकृत करने के लिए API कुंजियाँ प्रबंधित करें।", "subtitle": "ClassroomIO सार्वजनिक API के साथ एकीकृत करने के लिए API कुंजियाँ प्रबंधित करें।",
@@ -4081,16 +4081,15 @@
}, },
"usage": { "usage": {
"title": "Zastosowanie i ograniczenia", "title": "Zastosowanie i ograniczenia",
"description": "MCP jest dostępne w każdym planie, ale wykorzystanie jest kontrolowane za pomocą miesięcznych kredytów automatyzacji i limitów kluczowych.", "description": "MCP jest dostępne w każdym planie. Wykorzystanie jest kontrolowane przez limity kluczy i limity żądań.",
"credits_title": "Pozostały kredyty",
"credits_description": "Mutowanie działań MCP zużywa kredyty. Czytanie akcji jest bezpłatne.",
"keys_title": "Aktywne klucze", "keys_title": "Aktywne klucze",
"keys_description": "Użyj jednego klucza MCP na każdą integrację, aby umożliwić łatwą rotację lub cofnięcie dostępu.", "keys_description": "Użyj jednego klucza MCP na każdą integrację, aby umożliwić łatwą rotację lub cofnięcie dostępu.",
"rate_limits_title": "Limity stawek", "rate_limits_title": "Limity stawek",
"rate_limits_description": "Żądania odczytu/zapisu/publikowania dozwolone na minutę.", "rate_limits_description": "Żądania odczytu/zapisu/publikowania dozwolone na minutę.",
"used_label": "Używane:" "used_label": "Używane:"
}, },
"page_subtitle": "Połącz narzędzia AI z ClassroomIO za pomocą kluczy o zasięgu organizacji, które możesz w dowolnym momencie obrócić." "page_subtitle": "Połącz narzędzia AI z ClassroomIO za pomocą kluczy o zasięgu organizacji, które możesz w dowolnym momencie obrócić.",
"view_docs": "Zobacz dokumentację"
}, },
"api": { "api": {
"subtitle": "Zarządzaj kluczami API, aby integrować się z publicznym API ClassroomIO.", "subtitle": "Zarządzaj kluczami API, aby integrować się z publicznym API ClassroomIO.",
@@ -4081,16 +4081,15 @@
}, },
"usage": { "usage": {
"title": "Uso e limites", "title": "Uso e limites",
"description": "O MCP está disponível em todos os planos, mas o uso é controlado com créditos mensais de automação e limites principais.", "description": "O MCP está disponível em todos os planos. O uso é controlado por limites de chaves e limites de taxa.",
"credits_title": "Créditos restantes",
"credits_description": "As ações mutantes do MCP consomem créditos. As ações de leitura são gratuitas.",
"keys_title": "Chaves ativas", "keys_title": "Chaves ativas",
"keys_description": "Use uma chave MCP por integração para que o acesso possa ser alternado ou revogado de forma limpa.", "keys_description": "Use uma chave MCP por integração para que o acesso possa ser alternado ou revogado de forma limpa.",
"rate_limits_title": "Limites de taxa", "rate_limits_title": "Limites de taxa",
"rate_limits_description": "Solicitações de leitura/gravação/publicação permitidas por minuto.", "rate_limits_description": "Solicitações de leitura/gravação/publicação permitidas por minuto.",
"used_label": "Usado:" "used_label": "Usado:"
}, },
"page_subtitle": "Conecte ferramentas de IA ao ClassroomIO com chaves da organização que você pode rotacionar a qualquer momento." "page_subtitle": "Conecte ferramentas de IA ao ClassroomIO com chaves da organização que você pode rotacionar a qualquer momento.",
"view_docs": "Ver documentação"
}, },
"api": { "api": {
"subtitle": "Gerencie chaves de API para integrar com a API pública do ClassroomIO.", "subtitle": "Gerencie chaves de API para integrar com a API pública do ClassroomIO.",
@@ -4081,16 +4081,15 @@
}, },
"usage": { "usage": {
"title": "Использование и ограничения", "title": "Использование и ограничения",
"description": "MCP доступен в каждом плане, но использование контролируется с помощью ежемесячных кредитов на автоматизацию и ключевых лимитов.", "description": "MCP доступен в каждом плане. Использование контролируется лимитами ключей и лимитами запросов.",
"credits_title": "Оставшиеся кредиты",
"credits_description": "Мутирующие действия MCP потребляют кредиты. Действия чтения бесплатны.",
"keys_title": "Активные ключи", "keys_title": "Активные ключи",
"keys_description": "Используйте один ключ MCP для каждой интеграции, чтобы можно было без проблем менять или отзывать доступ.", "keys_description": "Используйте один ключ MCP для каждой интеграции, чтобы можно было без проблем менять или отзывать доступ.",
"rate_limits_title": "Ограничения ставок", "rate_limits_title": "Ограничения ставок",
"rate_limits_description": "Запросы на чтение/запись/публикацию разрешены в минуту.", "rate_limits_description": "Запросы на чтение/запись/публикацию разрешены в минуту.",
"used_label": "Использовано:" "used_label": "Использовано:"
}, },
"page_subtitle": "Подключайте ИИ-инструменты к ClassroomIO с помощью ключей организации, которые можно менять в любое время." "page_subtitle": "Подключайте ИИ-инструменты к ClassroomIO с помощью ключей организации, которые можно менять в любое время.",
"view_docs": "Документация"
}, },
"api": { "api": {
"subtitle": "Управляйте API-ключами для интеграции с публичным API ClassroomIO.", "subtitle": "Управляйте API-ключами для интеграции с публичным API ClassroomIO.",
@@ -4081,16 +4081,15 @@
}, },
"usage": { "usage": {
"title": "Cách sử dụng và giới hạn", "title": "Cách sử dụng và giới hạn",
"description": "MCP có sẵn trên mọi gói, nhưng việc sử dụng được kiểm soát bằng tín dụng tự động hóa hàng tháng và các giới hạn chính.", "description": "MCP có sẵn trên mọi gói. Việc sử dụng được kiểm soát bằng giới hạn khóa và giới hạn tần suất.",
"credits_title": "Tín dụng còn lại", "keys_title": "Khóa đang hoạt động",
"credits_description": "Các hành động MCP đột biến sẽ tiêu tốn tín dụng. Hành động đọc là miễn phí.",
"keys_title": "Phím hoạt động",
"keys_description": "Sử dụng một khóa MCP cho mỗi lần tích hợp để có thể luân chuyển hoặc thu hồi quyền truy cập một cách dễ dàng.", "keys_description": "Sử dụng một khóa MCP cho mỗi lần tích hợp để có thể luân chuyển hoặc thu hồi quyền truy cập một cách dễ dàng.",
"rate_limits_title": "Giới hạn tỷ lệ", "rate_limits_title": "Giới hạn tỷ lệ",
"rate_limits_description": "Yêu cầu đọc/ghi/xuất bản được phép mỗi phút.", "rate_limits_description": "Yêu cầu đọc/ghi/xuất bản được phép mỗi phút.",
"used_label": "Đã sử dụng:" "used_label": "Đã sử dụng:"
}, },
"page_subtitle": "Kết nối công cụ AI với ClassroomIO bằng khóa trong phạm vi tổ chức mà bạn có thể xoay bất cứ lúc nào." "page_subtitle": "Kết nối công cụ AI với ClassroomIO bằng khóa trong phạm vi tổ chức mà bạn có thể xoay bất cứ lúc nào.",
"view_docs": "Xem tài liệu"
}, },
"api": { "api": {
"subtitle": "Quản lý khóa API để tích hợp với API công khai ClassroomIO.", "subtitle": "Quản lý khóa API để tích hợp với API công khai ClassroomIO.",
@@ -3,6 +3,8 @@
import { automationApi } from '$features/automation/api/automation.svelte'; import { automationApi } from '$features/automation/api/automation.svelte';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { Button } from '@cio/ui/base/button';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
let { data } = $props(); let { data } = $props();
@@ -24,6 +26,17 @@
<Page.Title>{$t('automation.tabs.mcp')}</Page.Title> <Page.Title>{$t('automation.tabs.mcp')}</Page.Title>
<Page.Subtitle>{$t('automation.mcp.page_subtitle')}</Page.Subtitle> <Page.Subtitle>{$t('automation.mcp.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action>
<Button
variant="outline"
href="https://classroomio.com/docs/mcp"
target="_blank"
rel="noopener noreferrer"
>
<ArrowUpRightIcon size={16} />
{$t('automation.mcp.view_docs')}
</Button>
</Page.Action>
</Page.Header> </Page.Header>
<Page.Body> <Page.Body>
@@ -119,3 +119,33 @@ export const updateOrganizationApiKey = async (
throw new Error('Failed to update organization API key'); throw new Error('Failed to update organization API key');
} }
}; };
export const rotateOrganizationApiKeyIfActive = async (
organizationId: string,
keyId: string,
data: Pick<TOrganizationApiKey, 'secretPrefix' | 'secretHash'>
): Promise<TOrganizationApiKey | null> => {
try {
const [row] = await db
.update(schema.organizationApiKey)
.set({
secretPrefix: data.secretPrefix,
secretHash: data.secretHash,
lastUsedAt: null,
updatedAt: new Date().toISOString()
})
.where(
and(
eq(schema.organizationApiKey.organizationId, organizationId),
eq(schema.organizationApiKey.id, keyId),
isNull(schema.organizationApiKey.revokedAt)
)
)
.returning();
return row || null;
} catch (error) {
console.error('rotateOrganizationApiKeyIfActive error:', error);
throw new Error('Failed to rotate organization API key');
}
};
-4
View File
@@ -36,7 +36,6 @@ export type TMcpToolName = keyof typeof MCP_TOOL_CREDIT_COST;
export type TAutomationUsageCategory = (typeof AUTOMATION_USAGE_CATEGORY)[keyof typeof AUTOMATION_USAGE_CATEGORY]; export type TAutomationUsageCategory = (typeof AUTOMATION_USAGE_CATEGORY)[keyof typeof AUTOMATION_USAGE_CATEGORY];
export type McpAutomationLimits = { export type McpAutomationLimits = {
monthlyCredits: number;
maxActiveKeys: number; maxActiveKeys: number;
rateLimits: { rateLimits: {
perKey: { perKey: {
@@ -54,7 +53,6 @@ export type McpAutomationLimits = {
export const MCP_AUTOMATION_LIMITS_BY_PLAN: Record<string, McpAutomationLimits> = { export const MCP_AUTOMATION_LIMITS_BY_PLAN: Record<string, McpAutomationLimits> = {
[PLAN.BASIC]: { [PLAN.BASIC]: {
monthlyCredits: 20,
maxActiveKeys: 1, maxActiveKeys: 1,
rateLimits: { rateLimits: {
perKey: { perKey: {
@@ -70,7 +68,6 @@ export const MCP_AUTOMATION_LIMITS_BY_PLAN: Record<string, McpAutomationLimits>
} }
}, },
[PLAN.EARLY_ADOPTER]: { [PLAN.EARLY_ADOPTER]: {
monthlyCredits: 500,
maxActiveKeys: 5, maxActiveKeys: 5,
rateLimits: { rateLimits: {
perKey: { perKey: {
@@ -86,7 +83,6 @@ export const MCP_AUTOMATION_LIMITS_BY_PLAN: Record<string, McpAutomationLimits>
} }
}, },
[PLAN.ENTERPRISE]: { [PLAN.ENTERPRISE]: {
monthlyCredits: 5000,
maxActiveKeys: 25, maxActiveKeys: 25,
rateLimits: { rateLimits: {
perKey: { perKey: {
+2 -2
View File
@@ -15,7 +15,7 @@ export default {
'20 Students', '20 Students',
'Advanced Course Builder (no video upload)', 'Advanced Course Builder (no video upload)',
'500K AI credits / month', '500K AI credits / month',
'MCP access with 20 automation credits / month', 'MCP access',
'ClassroomIO branding' 'ClassroomIO branding'
], ],
CTA: { CTA: {
@@ -41,7 +41,7 @@ export default {
'10K Students', '10K Students',
'Course Builder with Video Upload & Certificate', 'Course Builder with Video Upload & Certificate',
'3M AI credits / month', '3M AI credits / month',
'MCP access with 500 automation credits / month', 'MCP access',
'Custom Branding', 'Custom Branding',
'Custom Domain', 'Custom Domain',
'API with limits', 'API with limits',