fix: persist refresh_token and add auto-refresh on token expiry#11
fix: persist refresh_token and add auto-refresh on token expiry#11rmichelena wants to merge 3 commits into
Conversation
Three bugs prevented long-term unattended operation of the MCP server: 1. mcp_server_dash.py: authenticate() discarded refresh_token from OAuth response — called save(access_token) without passing the refresh_token. 2. token_store.py save(): saved to keyring first, only falling back to file storage if keyring failed. Since keyring stores a single string, the refresh_token was always lost even when the file was written. 3. token_store.py load(): had no refresh logic. When the access_token expired (4h TTL), the server became permanently unauthenticated, requiring manual re-auth via the full OAuth flow. Fixes: - save() now always writes to file (including refresh_token), plus keyring - load() tries access_token first, falls back to refresh_token via SDK, persists the new access_token after refresh - _read_token_file() returns dict with both tokens - New _save_to_file() method for clean persistence
🔍 Multi-Model PR Review: dropbox/mcp-server-dash #11PR: fix: persist refresh_token and add auto-refresh on token expiry Reviewers: GPT-5.5 · GLM-5.1 · DeepSeek V4 Pro SummaryThe PR adds refresh token persistence and auto-refresh-on-expiry logic to Verdict: 🟡 Approve with changes requested Findings🔴 HIGH — Tests not updated for
|
HIGH: Tests updated for _read_token_file return type change - test_read_token_file_invalid now asserts dict with no access_token - test_save_both_keyring_and_file_fail uses broader exception match - Added 5 new tests: refresh flow, network-error-doesn't-clear, save+load with refresh_token, file fallback with refresh_token, try_refresh variants - Renamed helper to avoid pytest collection warning HIGH: save() no longer silently swallows persistence failures - _persist() raises RuntimeError if keyring fails AND file write fails - save() propagates this (no silent swallowing) HIGH: Transient network errors no longer trigger clear() - load() catches AuthError → tries refresh; other Exceptions → returns False WITHOUT clearing (credentials preserved for retry) - New test_load_network_error_does_not_clear verifies this MEDIUM: Runtime token expiry attempts refresh before clearing - dash_company_search / dash_get_file_details now call token_store.try_refresh() on PermissionError, retry the request - Only clears if refresh also fails MEDIUM: Keyring updated after token refresh - try_refresh() and load()'s refresh path both call _persist() which writes to keyring (and file fallback) MEDIUM: No private SDK attribute (_oauth2_access_token) - _do_refresh() makes direct HTTP call to Dropbox token endpoint via httpx, no Dropbox SDK internals MEDIUM: No plaintext file when keyring works - _persist() tries keyring for BOTH tokens (separate keys: KEYRING_ACCESS_USERNAME, KEYRING_REFRESH_USERNAME) - File only written when keyring unavailable
🔍 Multi-Model PR Review (Round 2): dropbox/mcp-server-dash #11PR: fix: persist refresh_token and add auto-refresh on token expiry Reviewers: GPT-5.5 · GLM-5.1 · DeepSeek V4 Pro Verification of Prior Findings
All 7 prior findings confirmed fixed. ✅ New Findings🟡 MEDIUM —
|
…sh errors MEDIUM: _persist() silently loses refresh_token when keyring write partially fails - Track access_ok and refresh_ok independently - If either keyring write fails, fall back to file for BOTH tokens (transactional) - New test: test_persist_refresh_keyring_failure_falls_back_to_file MEDIUM: Transient failures during refresh destroy valid credentials - New TransientRefreshError exception for network/5xx errors - _do_refresh() raises TransientRefreshError for httpx.TransportError and 5xx - _do_refresh() returns None only for definitive auth failures (4xx) - load() catches TransientRefreshError → returns False WITHOUT clearing - try_refresh() catches TransientRefreshError → returns False WITHOUT raising - New tests: test_try_refresh_transient_error, test_load_refresh_transient_does_not_clear
Problem
The MCP server breaks permanently after the access token expires (4h TTL). Three bugs cause this:
Bug 1:
refresh_tokendiscarded during authenticationIn
mcp_server_dash.py, thedash_authenticate()handler callstoken_store.save(access_token)without passing therefresh_tokenthat Dropbox returns in the OAuth response. The PKCE flow explicitly requeststoken_access_type=offline, but the token is never stored.Bug 2:
refresh_tokenlost due to keyring-first save strategysave()attempts keyring first and only falls back to file storage if keyring fails. Since keyring stores a single opaque string, therefresh_tokenis always lost — even when the file fallback writes, it only contains{"access_token": "..."}.This particularly affects headless Linux environments (Docker containers, servers) where keyring has no backend and silently uses a no-op store, but the bug manifests everywhere.
Bug 3: No token refresh in
load()load()validates the access token and clears everything if validation fails. There is no logic to use a refresh token to obtain a new access token. After 4 hours, the server becomes permanently unauthenticated and requires the full interactive OAuth flow again.Fix
mcp_server_dash.py: Passtoken_data.get("refresh_token")tosave()token_store.py save(): Always write to file (with both tokens) regardless of keyring success. Keyring is used as a secondary store for the access token only.token_store.py load(): Try access token first. On failure, attempt refresh viadropbox.Dropbox(oauth2_refresh_token=..., app_key=...). Persist the refreshed token._read_token_file(): Returnsdict[str, str | None]with both tokens instead of a single string._save_to_file()method for clean file persistence withchmod 0600.Testing
access_tokenandrefresh_tokenload()successfully refreshes via SDK and continues operatingtoken_access_type=offline) now correctly round-trips the refresh token