fix(mcp): refresh a Hub token YouTrack rejects instead of trusting expires_at #109

Merged
Claude-Run merged 1 commit from fix/YT-50-refresh-hub-token-on-401 into main 2026-07-17 01:06:13 +02:00
Member

The HTTP transport treated a stored Hub token's recorded expires_at as proof of liveness. When Hub invalidated a token out of band (observed after a client-secret rotation), the row still had time left, so the proactive refresh in client_for_user never fired and the dead bearer was handed to every tool call. Each call then failed with an opaque HTTP 401 ... Invalid token until the clock happened to pass expires_at, with no restart or re-auth path that recovered it.

A 401 from YouTrack is now the authoritative signal that the token is dead. call_with_reauth wraps every tool body at the single run_tool boundary all 38 tools funnel through: on a 401 it force-refreshes the Hub token via the new AppState::refreshed_client_for_user (ignoring expires_at) and retries the call exactly once with the new bearer. A 401 that survives the refresh is a real authorization failure and propagates, as does the original 401 when the refresh itself fails (the grant-level recovery is tracked in YT-51). The proactive expiry check stays, so a known-expired token still costs no 401 round trip, and the two refreshes log distinguishable info lines carrying user_id.

Tool errors keep only the Display rendering of ClientError, so error::is_unauthorized recognizes a 401 by that format and a unit test pins the matcher to ClientError::Status's rendering rather than letting the two drift apart. Retrying means running a tool body twice, so run_tool now takes Fn over FnOnce and the tool argument structs derive Clone.

#YT-50

The HTTP transport treated a stored Hub token's recorded `expires_at` as proof of liveness. When Hub invalidated a token out of band (observed after a client-secret rotation), the row still had time left, so the proactive refresh in `client_for_user` never fired and the dead bearer was handed to every tool call. Each call then failed with an opaque `HTTP 401 ... Invalid token` until the clock happened to pass `expires_at`, with no restart or re-auth path that recovered it. A 401 from YouTrack is now the authoritative signal that the token is dead. `call_with_reauth` wraps every tool body at the single `run_tool` boundary all 38 tools funnel through: on a 401 it force-refreshes the Hub token via the new `AppState::refreshed_client_for_user` (ignoring `expires_at`) and retries the call exactly once with the new bearer. A 401 that survives the refresh is a real authorization failure and propagates, as does the original 401 when the refresh itself fails (the grant-level recovery is tracked in YT-51). The proactive expiry check stays, so a known-expired token still costs no 401 round trip, and the two refreshes log distinguishable info lines carrying `user_id`. Tool errors keep only the `Display` rendering of `ClientError`, so `error::is_unauthorized` recognizes a 401 by that format and a unit test pins the matcher to `ClientError::Status`'s rendering rather than letting the two drift apart. Retrying means running a tool body twice, so `run_tool` now takes `Fn` over `FnOnce` and the tool argument structs derive `Clone`. #YT-50
fix(mcp): refresh a Hub token YouTrack rejects instead of trusting expires_at
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 42s
Create release / Create release from merged PR (pull_request) Has been skipped
355a3fa5cd
The HTTP transport treated a stored Hub token's recorded `expires_at` as proof of liveness. When Hub invalidated a token out of band (observed after a client-secret rotation), the row still had time left, so the proactive refresh in `client_for_user` never fired and the dead bearer was handed to every tool call. Each call then failed with an opaque `HTTP 401 ... Invalid token` until the clock happened to pass `expires_at`, with no restart or re-auth path that recovered it.

A 401 from YouTrack is now the authoritative signal that the token is dead. `call_with_reauth` wraps every tool body at the single `run_tool` boundary all 38 tools funnel through: on a 401 it force-refreshes the Hub token via the new `AppState::refreshed_client_for_user` (ignoring `expires_at`) and retries the call exactly once with the new bearer. A 401 that survives the refresh is a real authorization failure and propagates, as does the original 401 when the refresh itself fails (the grant-level recovery is tracked in YT-51). The proactive expiry check stays, so a known-expired token still costs no 401 round trip, and the two refreshes log distinguishable info lines carrying `user_id`.

Tool errors keep only the `Display` rendering of `ClientError`, so `error::is_unauthorized` recognizes a 401 by that format and a unit test pins the matcher to `ClientError::Status`'s rendering rather than letting the two drift apart. Retrying means running a tool body twice, so `run_tool` now takes `Fn` over `FnOnce` and the tool argument structs derive `Clone`.

#YT-50
Claude-Run deleted branch fix/YT-50-refresh-hub-token-on-401 2026-07-17 01:06:13 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pandoras-box/youtrack-cli!109
No description provided.