upstream: recover discovery after a restart, drop stale personal links - #46
Merged
Merged
Conversation
Fixes #42 and #44 — two ways the gateway kept serving a state that no longer existed. #42: connection.ts already had isStaleSession() and the retry-once dance, but only on callTool(). listTools() had neither, so when an upstream restarted and 404'd our forgotten session, discovery threw, the manager omitted that upstream, and every one of its tools left the catalog until somebody bounced it by hand. Discovery now recovers the same way a call does. The second half of #42 was that this was invisible: the transport still reported connected, so health showed connected:true / lastError:null / toolCount:0, which reads exactly like "this server has no tools". The manager now records why an upstream contributed nothing and surfaces it as lastError, clearing it when discovery succeeds again. #44: personal per-user links are memoized per principal and were only ever flushed by the upstream lifecycle, so a credential rotated on /me kept being ignored — silently — until the upstream was bounced. The /me credential write and delete paths now drop that caller's own link (and only theirs), and report `reconnected` so the UI can say so. While fixing #44 the flush turned out to be dead code: the pool was keyed with `${upstreamId} ${sessionKey}` (space) while closePersonalLinks searched for a control-character prefix, so it had never matched anything — the existing "upstream removal forgets personal links" test passed only because upsert rebuilds the shared link and the assertion counted factory calls. Both sites now go through one PERSONAL_KEY_SEP constant, and manager.ts is text again (the raw NUL bytes that hid this made the file binary to git and grep). Tests: discovery re-initializes exactly once against the fake upstream that expires sessions (fails without the fix); a discovery failure is reported and then cleared; a credential write rebuilds only the writer's link, another principal's link survives, and a delete drops the link and falls back to the shared credential. The pre-existing "no needless session churn" assertion is now relative, so adding cases above it can't shift an absolute count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
selic
pushed a commit
that referenced
this pull request
Aug 20, 2026
Minor: admins can forget a user - row, prefs, credential refs, refresh tokens and live sessions in one call (#41); tool discovery recovers from an upstream restart instead of silently emptying that upstream's half of the catalog, and a failed discovery is surfaced as lastError rather than looking healthy (#42, #46); a personal credential written or deleted on /me drops the caller's pooled per-user link so the change takes effect immediately (#44, #46). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #42, fixes #44 — two ways the gateway kept serving state that no longer existed.
#42 — a restarted upstream lost its tools permanently
connection.tsalready hadisStaleSession()and the retry-once dance, but only oncallTool().listTools()had neither, so when an upstream restarted and 404'd our forgotten session, discovery threw,doRefreshomitted that upstream, and every one of its tools left the catalog — with no self-recovery, because the local transport still looked healthy andconnect()kept succeeding. Discovery now recovers exactly the way a call does.The other half of the report was that this was invisible:
connected: true / lastError: null / toolCount: 0reads exactly like "this server has no tools". The manager now records why an upstream contributed nothing and surfaces it aslastError, clearing it when discovery works again.#44 — rotated credentials kept being ignored
Personal per-user links are memoized per principal and were only flushed by the upstream lifecycle, so a credential saved on
/mehad no effect until someone bounced the upstream. The credential write and delete paths now drop that caller's own link — and only theirs — and returnreconnectedso the UI can say what happened.Found while fixing it: the flush was dead code
The pool was keyed
${upstreamId} ${sessionKey}(a space) whileclosePersonalLinks()searched for a control-character prefix, so it had never matched anything. The existing "upstream removal closes and forgets personal links" test passed for the wrong reason:upsertUpstreamrebuilds the shared link and the assertion counted factory calls.Both sites now go through a single
PERSONAL_KEY_SEPconstant, andmanager.tsis a text file again — the raw NUL bytes that made this impossible to see (git and grep both treated it as binary) are gone.Tests
connection.test.ts— discovery re-initializes exactly once against the fake upstream that expires sessions. Verified to fail without the fix.manager.test.ts— a discovery failure is reported aslastErrorand cleared on the next good refresh.personal-sessions.test.ts— a credential write rebuilds only the writer's link; another principal's link survives; a delete drops the link and the next call falls back to the shared credential rather than a ref that no longer exists.npm run buildclean,npm test288 passed. Not deployed.🤖 Generated with Claude Code