Skip to content

feat(mcp): 30d access tokens, 365d refresh, 5min rotation grace - #214

Merged
AminDhouib merged 1 commit into
canaryfrom
feat/mcp-auth-window
Sep 15, 2026
Merged

AminDhouib merged 1 commit into
canaryfrom
feat/mcp-auth-window

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

Why

Claude Code was re-authorizing against /api/mcp roughly 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

Knob Before After Env var
Access token 24 h 30 days DOKPLOY_MCP_ACCESS_TOKEN_HOURS (720)
Refresh token 180 d sliding 365 d sliding DOKPLOY_MCP_REFRESH_TOKEN_DAYS
Rotation grace none 300 s DOKPLOY_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.

deleteConsumedRefreshToken becomes consumeRotatedRefreshToken, which clamps the consumed row with LEAST() rather than deleting it:

  • LEAST only ever shortens, so a row already expiring sooner keeps its own expiry
  • LEAST ignores NULL, so a row with no recorded refresh expiry ends up bounded by the window instead of staying open
  • the existing daily purge reaps clamped rows either way
  • DOKPLOY_MCP_REFRESH_GRACE_SECONDS=0 restores the previous delete-at-once behaviour

The grace value is an injectable argument on the helper because the vitest config statically defines process.env, so vi.stubEnv cannot reach it. That matches how the existing env helpers in this module are already tested.

Docs

apps/dokploy/.env.example previously mentioned none of the MCP knobs; all four are now documented there, including the DOKPLOY_MCP_DISABLED kill switch. The README line claiming 24 h / 180 d is corrected.

Testing

$ vitest --config __test__/vitest.config.ts run __test__/mcp
PASS (112) FAIL (0)

$ tsc --noEmit          # packages/server
(exit 0)

$ biome check <the 4 changed source files>
(exit 0)

Three new cases cover the grace behaviour: an empty token touches neither update nor delete, 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.

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.
@AminDhouib
AminDhouib merged commit 34c7c2c into canary Sep 15, 2026
3 checks passed
@AminDhouib
AminDhouib deleted the feat/mcp-auth-window branch September 15, 2026 03:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant