fix(mcp): make external-agent registration survive restarts (persistent token + stable fallback port) - #93
Open
Lenouw wants to merge 3 commits into
Open
fix(mcp): make external-agent registration survive restarts (persistent token + stable fallback port)#93Lenouw wants to merge 3 commits into
Lenouw wants to merge 3 commits into
Conversation
The token was random per process, so every app restart silently broke every registered external agent: the copied `claude mcp add` command carried a bearer token that no longer existed. For a Claude Code subscriber that command is the only way into the app - the built-in agent needs an API key they do not have - so the sole entry path had to be re-done from the MCP panel on every launch, which nobody discovers or does. First launch now mints the token and persists it under the HOME-anchored hidden root, deliberately NOT under the user-chosen data dir: that dir may sit in a synced folder (the point of making it configurable), and a bearer secret must not ride a sync service onto other machines - same reasoning as the data-dir pointer file. Registration becomes a one-time step. Unchanged behaviour everywhere else: OPENCHATCUT_MCP_TOKEN still overrides and is checked first, before any filesystem access; isolated dev profiles keep their own token so two checkouts never share a credential; a malformed token file is replaced rather than trusted (serving arbitrary file content would turn a corrupted write into the endpoint's credential); and an unwritable HOME degrades to the old per-process token instead of refusing to serve. The file is written 0600 under a 0700 directory. The MCP guide text stops claiming the token changes after a restart, since that was the workaround instruction for the very behaviour this removes. Tested in server/mcp-token.verify.ts (registered in verify:runtime-profile): stability across loads, ownership permissions, profile isolation, malformed-file healing, read-only degradation, and the env override winning without touching the filesystem.
When the canonical port was busy the embedded server fell back to a random port per launch. The usual occupant is a long-lived neighbour - a dev server, another local tool - so the conflict repeats at every launch, and every launch moved the endpoint: registered external agents pointed at yesterday's port and silently stopped reaching the app. Together with the per-process token this made the registration a Claude Code subscriber depends on rot twice over. The bind order becomes: the canonical, documented port first, so the address self-heals the moment the occupant goes away; then the fallback used the last time this happened; only when both are busy a fresh random port, which is persisted (0600, HOME-anchored hidden root, same placement rationale as the MCP token) and becomes the remembered fallback. Non-EADDRINUSE listen errors still throw, and an unwritable HOME degrades to the old behaviour. Tested in desktop/embedded-port.verify.ts (registered in verify:runtime-profile) against real sockets on reserved ports, never the machine's actual 5199: direct bind writes no state, first conflict persists, a repeated conflict reuses the same port, a double conflict rerolls and replaces the memory, the canonical port wins again once free, and corrupt or privileged values in the state file are ignored rather than dialled.
…file
Audit follow-ups on the two previous commits.
The token mint used a plain write, and the profile-scoped instance lock does
not serialize a packaged app against a dev server sharing the same HOME: two
concurrent first launches could both mint, last write winning while the loser
kept serving its in-memory token - recreating, once, the exact failure this
file exists to remove. The write becomes exclusive ('wx'); the loser of the
race adopts the winner, so every process serves the token the file actually
holds. Healing a malformed token file now unlinks it before writing, so a
world-readable leftover cannot lend its permissions to the fresh credential,
and a failed persistence logs one line telling the user the token will change
on restart and how to pin it - the MCP guide promises stability, so breaking
that promise silently was not acceptable.
The port memory gets the same profile scoping the token already had: a
packaged app and an isolated-dev instance may run concurrently, and one shared
slot would ping-pong at every contended launch, re-creating the instability
the previous commit removes. Both suites grow the matching cases: heal sheds
loose permissions, a lost mint race adopts the winner, and a profile's port
memory never touches the default one.
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.
Problem
For a Claude Code subscriber, the
claude mcp addcommand shown in the MCP panel is the only way into the app — the built-in agent needs an API key they don't have. That registration silently rots twice over:Change
server/mcp-token.ts— the token is minted once on first launch and persisted (0600, under the HOME-anchored hidden root — deliberately not under the user-chosen data dir, which may be a synced folder; a bearer secret must not ride a sync service, same rationale as the data-dir pointer).OPENCHATCUT_MCP_TOKENstill overrides and is checked before any filesystem access. The mint write is exclusive (wx): the profile-scoped instance lock doesn't serialize a packaged app against a dev server sharing the same HOME, so a lost race adopts the winner and every process serves the token the file holds. A malformed file is unlinked and re-minted (never trusted, and loose permissions are shed); an unwritable HOME degrades to the old per-process behaviour with one warning line. Isolated dev profiles keep their own token.desktop/embedded-port.ts— bind order becomes: canonical 5199 first (so the documented address self-heals the moment the occupant goes away), then the fallback used the last time 5199 was busy, then a fresh random port which immediately becomes the remembered one. The memory is per profile, like the token. Non-EADDRINUSEerrors still throw; 127.0.0.1 binding unchanged.The MCP guide text is updated accordingly — it previously instructed users to re-copy the configuration after each restart.
Tests
server/mcp-token.verify.tsanddesktop/embedded-port.verify.ts, both registered inverify:runtime-profile: token stability across loads, owner-only permissions, profile isolation, malformed-file healing (including permission shedding), lost-race adoption, read-only degradation, env override; port affinity over real sockets (never the machine's actual 5199) covering direct bind, first conflict, repeated conflict reusing the same port, double conflict rerolling the memory, canonical self-heal, and rejection of corrupt or privileged values.tsc,oxlint, andverify:i18nclean on top of currentmain.🤖 Generated with Claude Code