toeverything/AFFiNE · error · BadRequestException

Unsupported MCP credential expiration

Error message

Unsupported MCP credential expiration

What it means

BadRequestException('Unsupported MCP credential expiration') thrown in the private issue() helper when input.expirationDays is not in ALLOWED_EXPIRATION_DAYS = {30, 90, 365}. Both create and rotate paths funnel through issue(), so any create/rotate request with a different lifetime (7, 60, 180, 0, negative, undefined) is rejected before anything is stored.

Source

Thrown at packages/backend/server/src/plugins/copilot/mcp/credential.ts:196

    const now = new Date();
    await this.models.mcpCredential.touch(
      credential.id,
      new Date(now.getTime() - LAST_USED_WRITE_INTERVAL_MS),
      now
    );
    return credential;
  }

  private async issue(
    input: IssueMcpCredential & {
      familyId?: string;
      generation?: number;
      graceEndsAt?: Date;
    }
  ) {
    if (!ALLOWED_EXPIRATION_DAYS.has(input.expirationDays)) {
      throw new BadRequestException('Unsupported MCP credential expiration');
    }
    const name = input.name.trim();
    if (!name || name.length > 64) {
      throw new BadRequestException('MCP credential name is required');
    }

    const id = randomUUID();
    const secret = this.crypto.randomBytes(32).toString('base64url');
    const secretHash = this.crypto.sha256(secret).toString('hex');
    const credential = await this.models.mcpCredential.create({
      id,
      familyId: input.familyId ?? id,
      generation: input.generation ?? 0,
      name,
      secretHash,
      fingerprint: secretHash.slice(0, 12),
      userId: input.userId,
      workspaceId: input.workspaceId,

View on GitHub (pinned to b4c8548c09)

Solutions

  1. Set expirationDays to exactly 30, 90, or 365
  2. Update the client to source allowed values from server docs/schema instead of hardcoding
  3. As a maintainer, move the allowed set into GraphQL validation so clients get schema errors earlier

Example fix

// before
await credentials.create({ userId, workspaceId, name, accessMode, expirationDays: 60 });

// after
const ALLOWED = [30, 90, 365] as const;
await credentials.create({
  userId, workspaceId, name, accessMode,
  expirationDays: ALLOWED.includes(reqDays) ? reqDays : 90,
});
Defensive patterns

Strategy: validation

Validate before calling

const ALLOWED_EXPIRATION_DAYS = new Set([30, 90, 365]);
const expirationDays = ALLOWED_EXPIRATION_DAYS.has(input.expirationDays)
  ? input.expirationDays
  : 90;
await credentials.create({ ...input, expirationDays });

Type guard

const isAllowedExpiration = (d: unknown): d is 30 | 90 | 365 =>
  d === 30 || d === 90 || d === 365;

Try / catch

try {
  await credentials.create(input);
} catch (e) {
  if (e instanceof BadRequestException && /expiration/.test(e.message)) {
    input.expirationDays = 90;
    await credentials.create(input);
  } else throw e;
}

Prevention

When it happens

Trigger: createMcpCredential or rotateMcpCredential with expirationDays outside {30,90,365}; passing 0 or a negative number to mean 'no expiry'; enum desync where a client offers 7 or 60 days.

Common situations: Client UI dropdown drifts from server-allowed values after a version change; scripts hardcode a lifetime the server later removed; GraphQL input validated loosely and hits this server-side guard.

Related errors


AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18). Data as JSON: /api/errors/e353f8122e34742d. Report an issue: GitHub.