fix(claude): stop stale .mcp.json residue outranking a project switch, and gate stale builds #106

Merged
michal merged 3 commits from worktree-fix-statusline-project into main 2026-08-10 15:52:51 +00:00
Owner

The bug

Switching projects (mcpctl config claude --project sre) left the Claude Code status line reporting the old project forever.

config claude writes user scope (~/.claude.json) and never rewrites a checkout's .mcp.json. But statusline preferred that file unconditionally — so a legacy project-named entry left by an older mcpctl (homeautomationmcpctl mcp -p homeautomation, from before the constant server name) outranked every switch.

Reproduced live: with user scope on sre, mcpctl statusline --directory ~/developer/michalzxc/claude/debug printed mcpctl:homeautomation — a server Claude Code also had in disabledMcpServers for that directory, so the line named a project that was not mounted at all.

The fix

Rank the sources by how deliberate each is:

  1. a canonical mcpctl pin in that directory's .mcp.json — a decision, and the scope Claude Code itself prefers for a shared server name;
  2. user scope — what config claude --project writes, so a switch can win;
  3. a legacy project-named entry — residue, not a decision;
  4. the .mcpctl-project marker.

A server Claude Code has switched off for that directory (disabledMcpServers / disabledMcpjsonServers) is skipped at every step. A .mcp.json server in neither list is still awaiting approval and does count — blanking the line on a fresh checkout is the more confusing failure.

config claude now also warns when the working directory's .mcp.json contradicts the switch, naming the file. The two scopes are merged rather than chosen between, so nothing else would tell you.

Why the build guard is in here

Diagnosing this turned up a second bug with the same shape. Everyone branches off main and builds from their own branch; a branch behind main still builds and installs perfectly, it just ships an artifact missing whatever landed meanwhile — and rpm -U --force overwrites the good one with it.

That is not hypothetical: mid-investigation, a build from a stale checkout replaced /usr/bin/mcpctl with one that had no statusline command at all, months after the status line landed on main. Nothing errored; the feature simply vanished from the installed CLI.

scripts/check-main-sync.sh fetches main, compares, and fails before any work happens, listing the commits you are missing. It gates build-rpm.sh, build-deb.sh, build-mcpd.sh (each also runs standalone) and deploy-k8s.sh, where a stale branch would pin its sha in Pulumi and make it the cluster's source of truth.

--dry-run is exempt: it ships nothing, and blocking a read-only inspection is what teaches people to export MCPCTL_ALLOW_BEHIND_MAIN=1 permanently, disabling the gate for real deploys too. Offline it degrades to the last fetched origin/main, then a local main, saying which it used.

Verification

  • New statusline.test.ts — the 3 regression tests fail against the unfixed code (verified by stashing the fix) and pass with it.
  • Full suite: 2536 passed, typecheck clean, no new lint errors.
  • Live: the exact reproduction now returns mcpctl:sre; the config claude warning fires end-to-end against an isolated CLAUDE_CONFIG_DIR.
  • Build gate: passes on an up-to-date branch; against a synthetic ref one commit ahead, build-rpm.sh, build-mcpd.sh and deploy-k8s.sh each exit 1 before any build, push, pg_dump or pulumi. The ref was built with git commit-tree, so neither HEAD nor the working tree moved.
  • Released and installed; user confirmed the status line now tracks the switch.

Note: release.sh reported 2 pre-existing smoke failures in health-readiness.smoke.test.ts (docs and the smoke-aws-docs fixture have no healthCheck.tool; docmost/get_workspace 401; my-home-assistant/ha_config_list_areas unknown tool). All environmental and untouched by this branch, which changes no mcpd code.

## The bug Switching projects (`mcpctl config claude --project sre`) left the Claude Code status line reporting the old project forever. `config claude` writes **user scope** (`~/.claude.json`) and never rewrites a checkout's `.mcp.json`. But `statusline` preferred that file unconditionally — so a **legacy project-named entry** left by an older mcpctl (`homeautomation` → `mcpctl mcp -p homeautomation`, from before the constant server name) outranked every switch. Reproduced live: with user scope on `sre`, `mcpctl statusline --directory ~/developer/michalzxc/claude/debug` printed `mcpctl:homeautomation` — a server Claude Code *also* had in `disabledMcpServers` for that directory, so the line named a project that was not mounted at all. ## The fix Rank the sources by how deliberate each is: 1. a canonical `mcpctl` pin in that directory's `.mcp.json` — a decision, and the scope Claude Code itself prefers for a shared server name; 2. **user scope** — what `config claude --project` writes, so a switch can win; 3. a legacy project-named entry — residue, not a decision; 4. the `.mcpctl-project` marker. A server Claude Code has switched off for that directory (`disabledMcpServers` / `disabledMcpjsonServers`) is skipped at every step. A `.mcp.json` server in neither list is still awaiting approval and does count — blanking the line on a fresh checkout is the more confusing failure. `config claude` now also warns when the working directory's `.mcp.json` contradicts the switch, naming the file. The two scopes are merged rather than chosen between, so nothing else would tell you. ## Why the build guard is in here Diagnosing this turned up a second bug with the same shape. Everyone branches off main and builds from their own branch; a branch behind main still builds and installs perfectly, it just ships an artifact missing whatever landed meanwhile — and `rpm -U --force` overwrites the good one with it. That is not hypothetical: mid-investigation, a build from a stale checkout replaced `/usr/bin/mcpctl` with one that had **no `statusline` command at all**, months after the status line landed on main. Nothing errored; the feature simply vanished from the installed CLI. `scripts/check-main-sync.sh` fetches main, compares, and fails before any work happens, listing the commits you are missing. It gates `build-rpm.sh`, `build-deb.sh`, `build-mcpd.sh` (each also runs standalone) and `deploy-k8s.sh`, where a stale branch would pin its sha in Pulumi and make it the cluster's source of truth. `--dry-run` is exempt: it ships nothing, and blocking a read-only inspection is what teaches people to export `MCPCTL_ALLOW_BEHIND_MAIN=1` permanently, disabling the gate for real deploys too. Offline it degrades to the last fetched `origin/main`, then a local `main`, saying which it used. ## Verification - New `statusline.test.ts` — the 3 regression tests **fail against the unfixed code** (verified by stashing the fix) and pass with it. - Full suite: **2536 passed**, typecheck clean, no new lint errors. - Live: the exact reproduction now returns `mcpctl:sre`; the `config claude` warning fires end-to-end against an isolated `CLAUDE_CONFIG_DIR`. - Build gate: passes on an up-to-date branch; against a synthetic ref one commit ahead, `build-rpm.sh`, `build-mcpd.sh` and `deploy-k8s.sh` each exit 1 before any build, push, pg_dump or pulumi. The ref was built with `git commit-tree`, so neither HEAD nor the working tree moved. - Released and installed; user confirmed the status line now tracks the switch. Note: `release.sh` reported 2 pre-existing smoke failures in `health-readiness.smoke.test.ts` (`docs` and the `smoke-aws-docs` fixture have no `healthCheck.tool`; `docmost/get_workspace` 401; `my-home-assistant/ha_config_list_areas` unknown tool). All environmental and untouched by this branch, which changes no mcpd code.
michal added 3 commits 2026-08-10 15:52:34 +00:00
`mcpctl config claude --project X` writes user scope and never rewrites a
checkout's `.mcp.json`. The status line, however, preferred that file
unconditionally — so a legacy project-named entry an older mcpctl left behind
(`homeautomation` -> `mcpctl mcp -p homeautomation`) kept naming the old project
for good, and every switch looked like it had done nothing.

Reproduced live: with user scope on `sre`, `mcpctl statusline --directory
~/developer/michalzxc/claude/debug` printed `mcpctl:homeautomation` — a project
Claude Code also had in `disabledMcpServers` for that directory, so the line
named a server that was not even mounted.

Rank the sources by how deliberate each one is instead: a canonical `mcpctl`
pin, then user scope, then legacy residue, then the marker. A pin is a decision
and still wins; residue is not and no longer does. At every step, skip a server
Claude Code has switched off for that directory.

`config claude` now also warns when the working directory's `.mcp.json`
contradicts the switch, naming the file — the two scopes are merged rather than
chosen between, so nothing else would tell you.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
Everyone branches off main and builds from their own branch. A branch that is
behind main still builds and installs perfectly — it just packages a binary
missing whatever landed on main meanwhile, and `rpm -U --force` overwrites the
good one with it. Nothing reports an error; the release succeeds and the feature
simply vanishes from the installed CLI.

That is what happened today: a build from a stale checkout replaced
/usr/bin/mcpctl with one that has no `statusline` command at all, months after
the status line landed on main (`mcpctl statusline` -> "unknown command").

`check-main-sync.sh` fetches main, compares, and fails before any work happens,
listing the commits the branch is missing and the merge that fixes it. Sourced
by build-rpm.sh and build-deb.sh — both are run standalone, so neither can rely
on the other having checked.

A hard failure rather than a warning: a warning scrolls past in a build log, and
the whole point is to stop before the artifact exists.
MCPCTL_ALLOW_BEHIND_MAIN=1 is the escape hatch for a deliberate old-tree build;
MCPCTL_BASE_BRANCH retargets the comparison. Offline it degrades to the last
fetched origin/main, then a local main, saying which it used; outside a git
checkout it skips.

Verified: passes on this branch (up to date with main); against a synthetic ref
one commit ahead, build-rpm.sh aborts with exit 1 before ensure_build_deps and
before any compilation; the escape hatch bypasses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
build: extend the main-sync gate to the image build and the k8s deploy
Some checks failed
CI/CD / lint (pull_request) Successful in 1m15s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m59s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 4m58s
CI/CD / publish (pull_request) Has been skipped
96e27c8716
Same hazard as the package build, with the cluster on the receiving end: a
branch behind main builds images missing whatever landed there, and deploy-k8s.sh
pins that sha in Pulumi — making the stale build the cluster's source of truth.

build-mcpd.sh gets its own call because it is run standalone as well as from
deploy-k8s.sh, so neither can rely on the other having checked.

`--dry-run` is exempt. It builds and cuts over nothing, and blocking a read-only
inspection is exactly what teaches people to export MCPCTL_ALLOW_BEHIND_MAIN=1
permanently — which would disable the gate for the real deploys too.

The failure text is now artifact-agnostic ("produce an artifact" / "shipping
it"), since one helper now speaks for packages, images and deploys.

Verified against a synthetic ref one commit ahead: build-mcpd.sh exits 1 before
any docker work, and deploy-k8s.sh exits 1 before the test gate, the pg_dump,
the image build and pulumi. Neither the working tree nor HEAD was moved to test
this — the ref was built with git commit-tree and deleted afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
michal merged commit 13f1ff28eb into main 2026-08-10 15:52:51 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: michal/mcpctl#106