Skip to content

fix(mcp): clamp expired-token expires_in to -1 and add proactive refresh timer - #62309

Open
diegokolling wants to merge 1 commit into
NousResearch:mainfrom
diegokolling:fix/mcp-oauth-proactive-refresh
Open

diegokolling wants to merge 1 commit into
NousResearch:mainfrom
diegokolling:fix/mcp-oauth-proactive-refresh

Conversation

@diegokolling

Copy link
Copy Markdown

Summary

MCP OAuth servers (Notion, Todoist, etc.) force a full browser re-authentication on every Hermes restart. Two independent latent bugs combine to cause this:

  1. Dead-token misdetection — expired tokens are re-classified as valid, causing the SDK to ship a stale bearer token (401 → browser OAuth, skipping silent refresh entirely).
  2. No proactive refresh — if the token is valid at startup but expires shortly after, the next tool call triggers browser OAuth during background discovery, which must not open a browser.

Both are fixed without new environment variables, without new model tools, and without touching the core tool schema. Fix B reuses the SDK's existing _refresh_token() / _handle_refresh_response() internal methods.

Root Cause Analysis

Fix A — Dead-token misdetection (tools/mcp_oauth.py)

HermesTokenStorage.get_tokens() clamps the recomputed expires_in to a minimum of 0 using int(max(..., 0)). For an expired token this produces expires_in = 0. The SDK stores token_expiry_time = time.time() + 0, making is_token_valid() evaluate time.time() <= time.time()True due to float granularity. The SDK ships the stale token, gets a 401, and falls through to browser OAuth instead of silent refresh.

Fix: clamp to -1 instead of 0calculate_token_expiry(-1) yields time.time() - 1, making is_token_valid() return False, routing to the refresh_token grant.

Fix B — No proactive refresh (tools/mcp_oauth_manager.py)

The SDK only refreshes reactively (on 401). If the token expires 5 min after startup and the next call is background discovery, the SDK opens a browser in a non-interactive context. Fix: schedule a loop.call_later timer that silently refreshes 5 min before expiry (capped at 55 min), chains on success, retries in 60s on failure. Reuses the SDK's existing _refresh_token() + _handle_refresh_response() — no hand-rolled HTTP.

Reproduction Steps

  1. Configure an MCP OAuth server (Notion/Todoist), complete browser OAuth.
  2. Force-expiry by setting expires_at in ~/.hermes/mcp-tokens/<server>.json to a past timestamp.
  3. Start gateway/desktop. Without Fix A: browser tabs open immediately. Without Fix B (but with A): refreshed only on first tool call — if background discovery, browser still opens. With both: refresh runs silently before any tool call.

Production evidence:

2026-07-09 11:06:18,065 WARNING tools.mcp_oauth_manager: MCP OAuth 'notion': proactive refresh failed (400), retry in 60s

Test Plan

Fix A: test_get_tokens_expired_clamped_to_neg1, test_get_tokens_expired_implied_expiry, test_get_tokens_valid_token_unchanged

Fix B: test_proactive_refresh_scheduled_on_init, test_proactive_refresh_skip_if_no_refresh_token, test_proactive_refresh_skip_if_no_expiry, test_proactive_refresh_delay_capped_at_55min, test_proactive_refresh_retries_on_failure, test_proactive_refresh_chains_on_success

All follow existing patterns: tmp_path + monkeypatch, MagicMock, no network I/O.

Relationship to existing issues

Attributed to: Diego Kolling + Hermes

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists tool/mcp MCP client and OAuth comp/tools Tool registry, model_tools, toolsets area/auth Authentication, OAuth, credential pools labels Jul 10, 2026
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks for the focused MCP OAuth work. Current main already has a cold-load expiry path: tools/mcp_oauth.py:313-347 reconstructs TTL, tools/mcp_oauth_manager.py:179-182 seeds SDK expiry, and tests/tools/test_mcp_oauth_cold_load_expiry.py:283-350 verifies an expired persisted token is invalid and refreshable.

Problems

  • The proposed timer is not cancelled before replacement. Main can reinitialize a provider after disk invalidation (tools/mcp_oauth_manager.py:599-606), while remove() only drops the entry/files (:559-569), so duplicate or orphaned callbacks can race refresh-token rotation.
  • The proposed retry is ineffective after an HTTP refresh failure: MCP 1.26.0 clears current_tokens in _handle_refresh_response on non-200 (venv/lib/python3.11/site-packages/mcp/client/auth/oauth2.py:445-464); the next callback's can_refresh_token() check then returns false.
  • The PR diff contains no tests, although the description lists timer and expiry tests.

Suggested changes

  • Make timer replacement/cancellation part of the provider-entry lifecycle, including eviction and repeated initialization.
  • Preserve a retryable refresh state only for explicitly transient failures, and add deterministic tests for duplicate scheduling, eviction, failure retry, and successful chaining.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 11, 2026
Addresses teknium1 review on PR NousResearch#62309:

1. Timer lifecycle: cancel before schedule/re-init/remove so orphaned
   callbacks cannot race refresh_token rotation after provider eviction
   or disk-invalidation reinitialize.

2. Retry only on transient failures (timeouts, network errors,
   408/425/429/5xx) and only while can_refresh_token() still holds.
   Permanent 4xx / invalid payload stop without calling the SDK
   non-200 path that clears current_tokens.

3. Deterministic tests for duplicate schedule cancel, re-init cancel,
   manager eviction, success chaining, permanent vs transient retry,
   and expires_in=-1 clamp (SDK is_token_valid treats 0 as valid).
@diegokolling
diegokolling force-pushed the fix/mcp-oauth-proactive-refresh branch from 86ca786 to 8d2032c Compare July 12, 2026 16:17
@diegokolling

Copy link
Copy Markdown
Author

Thanks for the review — all three points are addressed in the force-pushed tip (8d2032c).

1. Timer cancellation / lifecycle

  • Added _cancel_proactive_refresh() and made _schedule_proactive_refresh() always cancel any previous asyncio.TimerHandle before installing a new one.
  • _initialize cancels first, then schedules — so disk-invalidation re-init cannot leave an orphaned callback racing a new provider entry.
  • MCPOAuthProviderManager.remove() now pops the entry and cancels the provider's timer before deleting on-disk tokens/client files.

2. Retry only for transient failures

MCP 1.26.0 clears current_tokens inside _handle_refresh_response on non-200, which makes a naive “retry next minute” ineffective (can_refresh_token() then returns false).

New policy in _do_proactive_refresh:

  • Success (HTTP 200 + valid payload) → chain the next proactive schedule.
  • Transient (transport/timeouts, 408/425/429, 5xx) → retry once after 60s only if can_refresh_token() still holds.
  • Permanent 4xx / invalid payloadstop. We deliberately do not call _handle_refresh_response on non-200, so permanent HTTP failures don't wipe refreshability as a side effect of our timer path; recovery remains the normal re-auth path.

3. Deterministic tests

New file tests/tools/test_mcp_oauth_proactive_refresh.py covers:

  • duplicate schedule cancels the previous handle
  • re-_initialize cancels the orphan
  • manager remove() cancels on eviction
  • success chains the next schedule
  • permanent failure does not reschedule
  • transient failure reschedules only while still refreshable

Also updated tests/tools/test_mcp_oauth_cold_load_expiry.py assertions for the expires_in=-1 clamp (why: SDK calculate_token_expiry(0) + is_token_valid treats now as still valid under float granularity).

Verification

Local: 27 passed across cold-load + proactive-refresh + bidirectional + integration OAuth suites, rebased onto current main.

Diego Kolling (@diegokolling) + Hermes Agent (team)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/mcp MCP client and OAuth type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants