{"record":{"id":"e353f8122e34742d","repo":"toeverything/AFFiNE","slug":"unsupported-mcp-credential-expiration","errorCode":null,"errorMessage":"Unsupported MCP credential expiration","messagePattern":"Unsupported MCP credential expiration","errorType":"exception","errorClass":"BadRequestException","httpStatus":400,"severity":"error","filePath":"packages/backend/server/src/plugins/copilot/mcp/credential.ts","lineNumber":196,"sourceCode":"\n    const now = new Date();\n    await this.models.mcpCredential.touch(\n      credential.id,\n      new Date(now.getTime() - LAST_USED_WRITE_INTERVAL_MS),\n      now\n    );\n    return credential;\n  }\n\n  private async issue(\n    input: IssueMcpCredential & {\n      familyId?: string;\n      generation?: number;\n      graceEndsAt?: Date;\n    }\n  ) {\n    if (!ALLOWED_EXPIRATION_DAYS.has(input.expirationDays)) {\n      throw new BadRequestException('Unsupported MCP credential expiration');\n    }\n    const name = input.name.trim();\n    if (!name || name.length > 64) {\n      throw new BadRequestException('MCP credential name is required');\n    }\n\n    const id = randomUUID();\n    const secret = this.crypto.randomBytes(32).toString('base64url');\n    const secretHash = this.crypto.sha256(secret).toString('hex');\n    const credential = await this.models.mcpCredential.create({\n      id,\n      familyId: input.familyId ?? id,\n      generation: input.generation ?? 0,\n      name,\n      secretHash,\n      fingerprint: secretHash.slice(0, 12),\n      userId: input.userId,\n      workspaceId: input.workspaceId,","sourceCodeStart":178,"sourceCodeEnd":214,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/plugins/copilot/mcp/credential.ts#L178-L214","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Set expirationDays to exactly 30, 90, or 365","Update the client to source allowed values from server docs/schema instead of hardcoding","As a maintainer, move the allowed set into GraphQL validation so clients get schema errors earlier"],"exampleFix":"// before\nawait credentials.create({ userId, workspaceId, name, accessMode, expirationDays: 60 });\n\n// after\nconst ALLOWED = [30, 90, 365] as const;\nawait credentials.create({\n  userId, workspaceId, name, accessMode,\n  expirationDays: ALLOWED.includes(reqDays) ? reqDays : 90,\n});","handlingStrategy":"validation","validationCode":"const ALLOWED_EXPIRATION_DAYS = new Set([30, 90, 365]);\nconst expirationDays = ALLOWED_EXPIRATION_DAYS.has(input.expirationDays)\n  ? input.expirationDays\n  : 90;\nawait credentials.create({ ...input, expirationDays });","typeGuard":"const isAllowedExpiration = (d: unknown): d is 30 | 90 | 365 =>\n  d === 30 || d === 90 || d === 365;","tryCatchPattern":"try {\n  await credentials.create(input);\n} catch (e) {\n  if (e instanceof BadRequestException && /expiration/.test(e.message)) {\n    input.expirationDays = 90;\n    await credentials.create(input);\n  } else throw e;\n}","preventionTips":["Drive the expiration dropdown from the same {30, 90, 365} set as the server","Pin the allowed set in a shared constant/package so client and server cannot drift","Add schema-level enum validation on the GraphQL input to fail fast"],"tags":["mcp","credential","validation","expiration"],"backgroundTag":"unsupported-parameter-value","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}