feat(mcp): give an invalidated Hub token a recovery path #110

Merged
Claude-Run merged 1 commit from feat/YT-51-hub-token-recovery-path into main 2026-07-17 01:25:35 +02:00
Member

Every refresh_hub_token failure collapsed into one opaque anyhow chain, so a dead refresh token, a client secret that disagrees with Hub, and a network blip were indistinguishable, and none of them told the caller what to do next. The stored row was also unreachable: no route or CLI cleared hub_tokens (the admin disable path revokes only app-issued tokens), and the browser session is a signed cookie the server cannot invalidate, so the only way out was hand-written SQL against the encrypted table.

Failures are now classified on the RFC 6749 error code. invalid_grant means the grant is provably dead: the unrecoverable row is deleted and the user is told to sign in again. invalid_client is the operator's misconfiguration, so the message names YOUTRACK_MCP_HUB_CLIENT_SECRET and the row is kept, since the user's grant is not at fault and works again once the secret matches. Everything else, including transport failures and any other code, stays transient and keeps the row: only the one code that proves the grant is dead may delete it. The classified error carries just its message and no source, so the oauth2 text never reaches the MCP client.

yt mcp auth reset --user <login> | --all clears rows out of band, editing the SQLite state DB directly like yt mcp allowlist (so it works whether or not the service is running; the YT-49 busy_timeout makes the concurrent write wait). One of the two targets is required: clearing a token costs someone a re-login, so there is no default. users gained a login column (migration 0002) because the Hub login an operator names a user by was not stored anywhere; email and Hub user id also resolve, which keeps pre-migration rows addressable until their next login backfills it.

Clearing a row only works if something forces the Hub leg to re-run. /authorize trusted the session cookie alone, and that cookie stays valid after the row is gone, so a reset would have moved the failure from "Invalid token" to "no Hub token stored for user". It now checks for a stored Hub token and falls through to a fresh Hub exchange when there is none. Deleting the row costs nothing else: the users row, its admin flag, and the allowlist entry all survive, unlike the CASCADE path that was previously the only way to remove a token.

Covered by unit tests per classification branch (asserting the row disposition each one implies), a /authorize fall-through test, and an end-to-end test that runs the real yt mcp auth reset code path against a live service DB and drives the same session cookie back through a full Hub exchange to a working /mcp bearer. Both new fall-through tests were confirmed to fail without the /authorize change.

#YT-51

Every `refresh_hub_token` failure collapsed into one opaque anyhow chain, so a dead refresh token, a client secret that disagrees with Hub, and a network blip were indistinguishable, and none of them told the caller what to do next. The stored row was also unreachable: no route or CLI cleared `hub_tokens` (the admin disable path revokes only app-issued tokens), and the browser session is a signed cookie the server cannot invalidate, so the only way out was hand-written SQL against the encrypted table. Failures are now classified on the RFC 6749 error code. `invalid_grant` means the grant is provably dead: the unrecoverable row is deleted and the user is told to sign in again. `invalid_client` is the operator's misconfiguration, so the message names YOUTRACK_MCP_HUB_CLIENT_SECRET and the row is kept, since the user's grant is not at fault and works again once the secret matches. Everything else, including transport failures and any other code, stays transient and keeps the row: only the one code that proves the grant is dead may delete it. The classified error carries just its message and no `source`, so the oauth2 text never reaches the MCP client. `yt mcp auth reset --user <login> | --all` clears rows out of band, editing the SQLite state DB directly like `yt mcp allowlist` (so it works whether or not the service is running; the YT-49 busy_timeout makes the concurrent write wait). One of the two targets is required: clearing a token costs someone a re-login, so there is no default. `users` gained a `login` column (migration 0002) because the Hub login an operator names a user by was not stored anywhere; email and Hub user id also resolve, which keeps pre-migration rows addressable until their next login backfills it. Clearing a row only works if something forces the Hub leg to re-run. `/authorize` trusted the session cookie alone, and that cookie stays valid after the row is gone, so a reset would have moved the failure from "Invalid token" to "no Hub token stored for user". It now checks for a stored Hub token and falls through to a fresh Hub exchange when there is none. Deleting the row costs nothing else: the `users` row, its admin flag, and the allowlist entry all survive, unlike the CASCADE path that was previously the only way to remove a token. Covered by unit tests per classification branch (asserting the row disposition each one implies), a `/authorize` fall-through test, and an end-to-end test that runs the real `yt mcp auth reset` code path against a live service DB and drives the same session cookie back through a full Hub exchange to a working `/mcp` bearer. Both new fall-through tests were confirmed to fail without the `/authorize` change. #YT-51
feat(mcp): give an invalidated Hub token a recovery path
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 1m55s
Create release / Create release from merged PR (pull_request) Has been skipped
8a2a358ff5
Every `refresh_hub_token` failure collapsed into one opaque anyhow chain, so a dead refresh token, a client secret that disagrees with Hub, and a network blip were indistinguishable, and none of them told the caller what to do next. The stored row was also unreachable: no route or CLI cleared `hub_tokens` (the admin disable path revokes only app-issued tokens), and the browser session is a signed cookie the server cannot invalidate, so the only way out was hand-written SQL against the encrypted table.

Failures are now classified on the RFC 6749 error code. `invalid_grant` means the grant is provably dead: the unrecoverable row is deleted and the user is told to sign in again. `invalid_client` is the operator's misconfiguration, so the message names YOUTRACK_MCP_HUB_CLIENT_SECRET and the row is kept, since the user's grant is not at fault and works again once the secret matches. Everything else, including transport failures and any other code, stays transient and keeps the row: only the one code that proves the grant is dead may delete it. The classified error carries just its message and no `source`, so the oauth2 text never reaches the MCP client.

`yt mcp auth reset --user <login> | --all` clears rows out of band, editing the SQLite state DB directly like `yt mcp allowlist` (so it works whether or not the service is running; the YT-49 busy_timeout makes the concurrent write wait). One of the two targets is required: clearing a token costs someone a re-login, so there is no default. `users` gained a `login` column (migration 0002) because the Hub login an operator names a user by was not stored anywhere; email and Hub user id also resolve, which keeps pre-migration rows addressable until their next login backfills it.

Clearing a row only works if something forces the Hub leg to re-run. `/authorize` trusted the session cookie alone, and that cookie stays valid after the row is gone, so a reset would have moved the failure from "Invalid token" to "no Hub token stored for user". It now checks for a stored Hub token and falls through to a fresh Hub exchange when there is none. Deleting the row costs nothing else: the `users` row, its admin flag, and the allowlist entry all survive, unlike the CASCADE path that was previously the only way to remove a token.

Covered by unit tests per classification branch (asserting the row disposition each one implies), a `/authorize` fall-through test, and an end-to-end test that runs the real `yt mcp auth reset` code path against a live service DB and drives the same session cookie back through a full Hub exchange to a working `/mcp` bearer. Both new fall-through tests were confirmed to fail without the `/authorize` change.

#YT-51
Claude-Run deleted branch feat/YT-51-hub-token-recovery-path 2026-07-17 01:25:35 +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!110
No description provided.