Skip to content

fix(mcp): make external-agent registration survive restarts (persistent token + stable fallback port) - #93

Closed
Lenouw wants to merge 3 commits into
0xsline:mainfrom
Lenouw:feat/persistent-mcp-token
Closed

fix(mcp): make external-agent registration survive restarts (persistent token + stable fallback port)#93
Lenouw wants to merge 3 commits into
0xsline:mainfrom
Lenouw:feat/persistent-mcp-token

Conversation

@Lenouw

@Lenouw Lenouw commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Problem

For a Claude Code subscriber, the claude mcp add command 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:

  1. The bearer token is random per process. Every app restart invalidates the token the user copied, and nothing tells them: the registered client just stops reaching the app. The panel's own help text documents this as a workaround ("copy the configuration again" after each restart), which nobody discovers or does.
  2. The fallback port is random per launch. When 5199 is taken — typically by a long-lived neighbour such as a dev server, so the conflict repeats at every launch — the endpoint moves each time, and the registered URL points at yesterday's port.

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_TOKEN still 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-EADDRINUSE errors 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.ts and desktop/embedded-port.verify.ts, both registered in verify: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, and verify:i18n clean on top of current main.

🤖 Generated with Claude Code

Lenouw added 3 commits August 18, 2026 15:35
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.
@0xsline

0xsline commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Merged into main as ff after rebase (commits 57714d9..89e946a). tsc/lint clean, both new verifies and verify:runtime-profile pass, desktop main-process build OK.

@0xsline 0xsline closed this Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants