feat(mcp): 30d access tokens, 365d refresh, 5min rotation grace - #214
Merged
Merged
Conversation
Claude Code was re-authorizing against /api/mcp roughly daily. The MCP OAuth TTLs were already env-driven but defaulted conservatively, and rotation had no grace window at all. access token 24h -> 720h (30 days) DOKPLOY_MCP_ACCESS_TOKEN_HOURS refresh token 180d -> 365d (sliding) DOKPLOY_MCP_REFRESH_TOKEN_DAYS rotation grace 0s -> 300s DOKPLOY_MCP_REFRESH_GRACE_SECONDS (new) The grace window is the substantive change. better-auth rotates by inserting a new row and leaving the consumed one fully alive, so the fork retired it immediately after a successful refresh. That is the strictest option but it strands clients: a dropped refresh response or two racing requests leave the client holding a token that no longer exists, and the only way out is a browser re-auth. PostHog hit exactly this with MCP clients and responded by disabling rotation for them outright; Google, Okta and Cognito issue non-rotating refresh tokens for the same reason. deleteConsumedRefreshToken becomes consumeRotatedRefreshToken, which clamps the consumed row with LEAST() instead of deleting it. LEAST only ever shortens, so a row already expiring sooner keeps its own expiry, and one with a NULL refresh expiry ends up bounded rather than open. The daily purge reaps them either way. Setting the grace to 0 restores the previous delete-at-once behaviour, and the helper takes the value as an injectable argument because the vitest config statically defines process.env. Auth-code TTL is untouched at the plugin default of 600s. Documents all four MCP knobs in apps/dokploy/.env.example, which previously mentioned none of them, and corrects the README.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Claude Code was re-authorizing against
/api/mcproughly daily. The MCP OAuth TTLs were already env-driven, but they defaulted conservatively, none of them were documented in.env.example, and refresh rotation had no grace window at all.What changed
DOKPLOY_MCP_ACCESS_TOKEN_HOURS(720)DOKPLOY_MCP_REFRESH_TOKEN_DAYSDOKPLOY_MCP_REFRESH_GRACE_SECONDS(new)Authorization-code TTL is untouched at the better-auth default of 600 s.
The grace window is the substantive part
better-auth rotates by inserting a new row and leaving the consumed one fully alive, so the fork retired it immediately after a successful refresh. That is the strictest option, but it strands clients. A dropped refresh response or two racing requests leave the client holding a refresh token that no longer exists, and the only way out is a browser re-auth.
This is a known failure mode, not a hypothetical. PostHog hit exactly this with MCP clients and responded by disabling rotation for them outright. Google, Okta and Cognito issue non-rotating refresh tokens for the same reason.
deleteConsumedRefreshTokenbecomesconsumeRotatedRefreshToken, which clamps the consumed row withLEAST()rather than deleting it:LEASTonly ever shortens, so a row already expiring sooner keeps its own expiryLEASTignoresNULL, so a row with no recorded refresh expiry ends up bounded by the window instead of staying openDOKPLOY_MCP_REFRESH_GRACE_SECONDS=0restores the previous delete-at-once behaviourThe grace value is an injectable argument on the helper because the vitest config statically
definesprocess.env, sovi.stubEnvcannot reach it. That matches how the existing env helpers in this module are already tested.Docs
apps/dokploy/.env.examplepreviously mentioned none of the MCP knobs; all four are now documented there, including theDOKPLOY_MCP_DISABLEDkill switch. The README line claiming 24 h / 180 d is corrected.Testing
Three new cases cover the grace behaviour: an empty token touches neither
updatenordelete, the default clamps rather than deletes, and a zero grace deletes outright. The defaults test now asserts 30 d / 365 d / 300 s.The one remaining biome warning is a pre-existing optional-chain suggestion in
findMcpAccessToken, untouched here.Not deployed, no containers restarted.
To apply
No migration and no env change required. The new defaults are compiled in, so this needs a rebuild and restart of the Dokploy app. TTLs are read once when better-auth is constructed, so a restart is what picks them up.
Already-issued tokens keep their stored expiry. The new window applies to tokens issued or refreshed after the restart, so expect one more browser re-auth per MCP client, then 30-day access tokens with a sliding 365-day refresh.