Skip to content

fix(oauth): refresh an expired stored token instead of dropping it - #2561

Closed
BlueX888 wants to merge 1 commit into
mcp-use:mainfrom
BlueX888:fix/prep-oauth-stored-refresh-token-never-used
Closed

BlueX888 wants to merge 1 commit into
mcp-use:mainfrom
BlueX888:fix/prep-oauth-stored-refresh-token-never-used

Conversation

@BlueX888

@BlueX888 BlueX888 commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Closes #2560

Language / Project Scope

Check all that apply:

  • TypeScript
  • Python
  • Documentation only
  • CI/CD or tooling

Changes

OAuth.initialize() now attempts a refresh when the stored token is expired and carries a
refresh_token, returning the refreshed BearerAuth instead of None. Previously it logged
"Existing token is expired", skipped refresh entirely, and returned None, so
HttpConnector.connect() fell through to OAuth.authenticate() — the interactive browser flow.

Review Readiness

  • The PR is scoped to one issue or one clearly related change
  • The PR description explains the user-visible problem and the fix
  • The diff avoids unrelated formatting, generated files, and bulk rewrites
  • I verified the affected package/docs path with the commands listed below
  • I checked for existing open PRs that already solve the same issue

Implementation Details

  1. libraries/python/mcp_use/client/auth/oauth.py — 8 added lines in OAuth.initialize(), placed
    after metadata discovery and before the final return None. The refresh is guarded by
    token_data and token_data.refresh_token; if refresh_token() returns None (rejected or
    unusable refresh token) the method keeps its previous behaviour and returns None, so the
    caller still falls back to authenticate().
  2. No new dependencies, no signature changes, no renames.

Root cause. libraries/python/mcp_use/client/auth/oauth.py:260-261 — the expired branch only
logs and falls through; token_data.refresh_token is never consulted. The refresh implementation
already existed at libraries/python/mcp_use/client/auth/oauth.py:765 (refresh_token(),
decorated @telemetry("oauth_refresh_token")) but had no call site anywhere in mcp_use/.

Ordering. The refresh must run after the discovery block at
libraries/python/mcp_use/client/auth/oauth.py:266-270: refresh_token() returns early when
self._metadata is unset, and it needs metadata.token_endpoint to reach the token service.

Python Checklist

  • Code formatted with ruff format
  • Linting passes with ruff check
  • Added or updated tests if needed
  • Updated documentation if needed — not needed; no public API or documented behaviour changed

Example Usage (Before)

# ~/.mcp_use/tokens/<host>_mcp.json holds an expired access_token plus a refresh_token.
connector = HttpConnector("https://mcp.example.com/mcp", auth={...})
await connector.connect()
# OAuth.initialize() -> None, HttpConnector.connect() -> OAuth.authenticate()
# -> local callback server, browser opened, up to 300 s wait for a human,
#    on every reconnect, even though the refresh token was still usable.

Example Usage (After)

connector = HttpConnector("https://mcp.example.com/mcp", auth={...})
await connector.connect()
# OAuth.initialize() -> BearerAuth("refreshed-token") from the stored refresh_token;
# the new token (and a rotated refresh_token, if the server sends one) is persisted.
# The browser flow only starts when there is no refresh_token or the refresh is rejected.

Documentation Updates

None.

Testing

Unit tests added in libraries/python/tests/unit/test_oauth_initialize.py (5 tests, pytest-asyncio,
matching pytest.ini's asyncio_mode = auto and the existing tests/unit layout). AsyncOAuth2Client
is patched, so no network is involved.

Covered: refresh on an expired token (asserts the call reaches metadata.token_endpoint with the
stored refresh token, that the returned BearerAuth carries the new access token, and that the new
and rotated tokens are persisted); refresh when metadata comes from discovery rather than
oauth_provider; None when there is no refresh token; None when the refresh is rejected with
OAuth2Error (asserting the refresh was actually attempted); and an unexpired token still short-
circuiting without any client being constructed.

Commands and results (Python 3.12, libraries/python):

# new test, unpatched tree (source restored from main) — 3 of the 5 fail:
$ MCP_USE_ANONYMIZED_TELEMETRY=false python -m pytest tests/unit/test_oauth_initialize.py -q
FAILED tests/unit/test_oauth_initialize.py::TestOAuthInitializeExpiredToken::test_refreshes_expired_token_with_stored_refresh_token
FAILED tests/unit/test_oauth_initialize.py::TestOAuthInitializeExpiredToken::test_refreshes_expired_token_when_metadata_is_discovered
FAILED tests/unit/test_oauth_initialize.py::TestOAuthInitializeExpiredToken::test_failed_refresh_returns_none
3 failed, 2 passed, 5 warnings in 0.42s

# new test, patched tree:
$ MCP_USE_ANONYMIZED_TELEMETRY=false python -m pytest tests/unit/test_oauth_initialize.py -q
5 passed, 5 warnings in 0.37s

# existing unit suite (CONTRIBUTING.md §4):
$ MCP_USE_ANONYMIZED_TELEMETRY=false python -m pytest tests/unit -q
1 failed, 309 passed, 37 warnings in 24.75s

The one failure, tests/unit/test_config.py::TestConnectorCreation::test_create_sandboxed_stdio_connector,
is pre-existing and unrelated: it reproduces identically on a stashed (unpatched) tree, with
ImportError: E2B SDK (e2b-code-interpreter) not found ... (mcp_use/client/connectors/sandbox.py:110)
— an optional extra that isn't installed in the verification environment.

$ ruff check .
All checks passed!

$ ruff format --check .
225 files already formatted

$ ty check
Found 203 diagnostics

ty reports the same 203 diagnostics before and after the change (all pre-existing, in
mcp_use/telemetry/); none are in mcp_use/client/auth/oauth.py.

End-to-end check of the issue's repro — expired token plus refresh_token in FileTokenStorage,
with a real HTTP token endpoint on 127.0.0.1:8199 and only authenticate() stubbed:

unpatched: initialize() -> None            | token endpoint hits: 0 | stored access_token: expired-token       | refresh_token: r1
patched:   initialize() -> refreshed-token | token endpoint hits: 1 | stored access_token: refreshed-token   | refresh_token: r2

Backwards Compatibility

Backwards compatible. The only behavioural change is on the path that previously returned None
and forced the interactive flow: that path now first tries the already-implemented refresh_token().
Every other branch of initialize() is untouched. A server that rejects the refresh still ends up
in authenticate() exactly as before.

Related Issues

Closes #2560


Summary by cubic

Fixes #2560: OAuth.initialize() now refreshes an expired stored OAuth token when a refresh token is available, instead of returning None and triggering the interactive browser flow. Successful refreshes return the new auth and persist updated tokens; missing or rejected refresh tokens retain the existing fallback.

  • Adds unit coverage for discovered and configured metadata, token rotation, failed refreshes, and valid-token handling.

Written for commit a3b3f23. Summary will update on new commits.

Review in cubic

OAuth.initialize() discarded an expired token and returned None even when
the stored token carried a refresh_token, so HttpConnector.connect() fell
through to the interactive browser flow although OAuth.refresh_token()
was available. Attempt the refresh after metadata discovery and reuse the
refreshed token.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="libraries/python/mcp_use/client/auth/oauth.py">

<violation number="1" location="libraries/python/mcp_use/client/auth/oauth.py:275">
P1: When a token was obtained through dynamic registration and the client restarts without a configured `client_id`, this call still returns `None` without contacting the token endpoint. `refresh_token()` refuses to construct its temporary client when `self.client_id` is missing, while `authenticate()` is the only path that loads `_load_client_registration()`, so expired DCR tokens always fall back to interactive login despite a valid refresh token. Load the stored registration before calling `refresh_token()`.</violation>

<violation number="2" location="libraries/python/mcp_use/client/auth/oauth.py:275">
P2: When the token endpoint is temporarily unreachable, `refresh_token()` re-raises `httpx.RequestError` (only `OAuth2Error` is caught there), and this new call site lets it escape `initialize()` and `connect()`. So a transient network failure now breaks the session instead of falling back to interactive auth. Catch `httpx.HTTPError` around the call and treat it as a failed refresh.</violation>
</file>

<file name="libraries/python/tests/unit/test_oauth_initialize.py">

<violation number="1" location="libraries/python/tests/unit/test_oauth_initialize.py:131">
P3: The PR advertises "if refresh_token() returns None (refresh rejected or unusable), preserve existing behavior and return None", but no test covers the non-raising None path. test_failed_refresh_returns_none only exercises the OAuth2Error-raise branch. A regression that removes the client_id guard or breaks the silent-None fallback in refresh_token() would pass the current suite. Add a test that builds OAuth with expired stored tokens plus a refresh_token but no client_id (client_id=None), verifies refresh_token() is called, initialize() returns None, and the interactive flow can proceed.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

# Refresh needs the token endpoint, hence after metadata discovery
if token_data and token_data.refresh_token:
logger.debug("Existing token is expired but has a refresh token, attempting refresh")
refreshed_auth = await self.refresh_token()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a token was obtained through dynamic registration and the client restarts without a configured client_id, this call still returns None without contacting the token endpoint. refresh_token() refuses to construct its temporary client when self.client_id is missing, while authenticate() is the only path that loads _load_client_registration(), so expired DCR tokens always fall back to interactive login despite a valid refresh token. Load the stored registration before calling refresh_token().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At libraries/python/mcp_use/client/auth/oauth.py, line 275:

<comment>When a token was obtained through dynamic registration and the client restarts without a configured `client_id`, this call still returns `None` without contacting the token endpoint. `refresh_token()` refuses to construct its temporary client when `self.client_id` is missing, while `authenticate()` is the only path that loads `_load_client_registration()`, so expired DCR tokens always fall back to interactive login despite a valid refresh token. Load the stored registration before calling `refresh_token()`.</comment>

<file context>
@@ -269,6 +269,14 @@ async def initialize(self, client: httpx.AsyncClient) -> BearerAuth | None:
+        # Refresh needs the token endpoint, hence after metadata discovery
+        if token_data and token_data.refresh_token:
+            logger.debug("Existing token is expired but has a refresh token, attempting refresh")
+            refreshed_auth = await self.refresh_token()
+            if refreshed_auth:
+                logger.debug("OAuth.initialize returning refreshed BearerAuth")
</file context>
Suggested change
refreshed_auth = await self.refresh_token()
if not self.client_id:
await self._load_client_registration()
refreshed_auth = await self.refresh_token()

# Refresh needs the token endpoint, hence after metadata discovery
if token_data and token_data.refresh_token:
logger.debug("Existing token is expired but has a refresh token, attempting refresh")
refreshed_auth = await self.refresh_token()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the token endpoint is temporarily unreachable, refresh_token() re-raises httpx.RequestError (only OAuth2Error is caught there), and this new call site lets it escape initialize() and connect(). So a transient network failure now breaks the session instead of falling back to interactive auth. Catch httpx.HTTPError around the call and treat it as a failed refresh.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At libraries/python/mcp_use/client/auth/oauth.py, line 275:

<comment>When the token endpoint is temporarily unreachable, `refresh_token()` re-raises `httpx.RequestError` (only `OAuth2Error` is caught there), and this new call site lets it escape `initialize()` and `connect()`. So a transient network failure now breaks the session instead of falling back to interactive auth. Catch `httpx.HTTPError` around the call and treat it as a failed refresh.</comment>

<file context>
@@ -269,6 +269,14 @@ async def initialize(self, client: httpx.AsyncClient) -> BearerAuth | None:
+        # Refresh needs the token endpoint, hence after metadata discovery
+        if token_data and token_data.refresh_token:
+            logger.debug("Existing token is expired but has a refresh token, attempting refresh")
+            refreshed_auth = await self.refresh_token()
+            if refreshed_auth:
+                logger.debug("OAuth.initialize returning refreshed BearerAuth")
</file context>
Suggested change
refreshed_auth = await self.refresh_token()
try:
refreshed_auth = await self.refresh_token()
except httpx.HTTPError:
logger.debug("Token refresh failed, falling back to interactive flow")
refreshed_auth = None

mock_client_class.assert_not_called()

@pytest.mark.asyncio
async def test_failed_refresh_returns_none(self, tmp_path):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The PR advertises "if refresh_token() returns None (refresh rejected or unusable), preserve existing behavior and return None", but no test covers the non-raising None path. test_failed_refresh_returns_none only exercises the OAuth2Error-raise branch. A regression that removes the client_id guard or breaks the silent-None fallback in refresh_token() would pass the current suite. Add a test that builds OAuth with expired stored tokens plus a refresh_token but no client_id (client_id=None), verifies refresh_token() is called, initialize() returns None, and the interactive flow can proceed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At libraries/python/tests/unit/test_oauth_initialize.py, line 131:

<comment>The PR advertises "if refresh_token() returns None (refresh rejected or unusable), preserve existing behavior and return None", but no test covers the non-raising None path. test_failed_refresh_returns_none only exercises the OAuth2Error-raise branch. A regression that removes the client_id guard or breaks the silent-None fallback in refresh_token() would pass the current suite. Add a test that builds OAuth with expired stored tokens plus a refresh_token but no client_id (client_id=None), verifies refresh_token() is called, initialize() returns None, and the interactive flow can proceed.</comment>

<file context>
@@ -0,0 +1,168 @@
+        mock_client_class.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_failed_refresh_returns_none(self, tmp_path):
+        """A rejected refresh falls back to the interactive flow instead of raising."""
+        storage = FileTokenStorage(base_dir=tmp_path)
</file context>

@khandrew1

Copy link
Copy Markdown
Contributor

Thanks for taking the time to contribute. Our focus right now is on the TypeScript SDK, so we're limiting Python PRs to fixes for concrete problems that contributors have run into firsthand. This PR doesn't appear to address that kind of issue, so we're closing it for now, along with the linked issue. If this was prompted by something you hit in practice, please reply on the issue with what you were doing when it came up and how to reproduce it, and we'll reopen the issue and take another look.

@khandrew1 khandrew1 closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants