feat(manifest): per-host server omission, and stop writing keys to world-readable files - #8
feat(manifest): per-host server omission, and stop writing keys to world-readable files#8UnbreakableMJ wants to merge 2 commits into
Conversation
Claude Code hides a claude.ai connector when a locally configured MCP server claims the same URL, so "claude.ai Context7" and "claude.ai Microsoft Learn" never appear while `context7` and `microsoft-learn` sit in ~/.claude.json. The user wants the connectors instead, and only on Claude Code — no other host has one to fall back on. `enabled = false` cannot express that. A disabled server is still written into the host's config and merely switched off, and it is presence of the URL, not the switch, that does the hiding. So add `omit = true` to `[servers.overrides.<Host>]`: the server is not emitted for that host at all. `resolve_for` filters omitted servers rather than exposing the fact and asking each caller to remember it — render, deploy and fill-keys all mean "the servers this host has". `validate` rejects an override that sets `omit` alongside fields that would then have no effect. `check` compares templates against each other and would report the two absences as class-2 drift. It now loads the manifest and partitions each host's absences into `missing`, which still fails the gate, and `omitted`, which is reported for visibility. Reading it from the manifest rather than a second hardcoded table follows the line the module already drew: `ACCEPTED` is for what a host *cannot* do, not for what the user declared. Rehearsed against a copy of the live config, per AGENTS.md: the two entries become strays, an interactive `deploy` prunes them, and the other 6,800 lines — including the per-project `mcpServers` blocks — come through untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every copy this tool makes of a live config holds the same real API keys that config holds, and the defaults publish them. `create_dir_all` leaves a backup directory at 0755, `fs::copy` preserves the source's 0644, and `fs::write` creates the pre-rename temporary at 0644 — so `~/.mcp-backup/` had accumulated 19 timestamped directories containing 103 world-readable files with live Context7, Brave and Perplexity keys in them. Unlike the live config a backup is never looked at again, so nothing was ever going to surface it. The vault, its timestamp directories and every copy are now created owner-only, and the temporary is narrowed before any content reaches it rather than after. A rename adopts the temporary's permissions, so `write_atomically` also carries the replaced file's mode across. Without that the tool silently undoes the user's own hardening: ~/.codex/config.toml was 0600 when it was backed up on 2026-08-03 and is 0644 today, and a deploy is what widened it. `backup` and `write_atomically` become `pub` so the new tests can reach them; the lib target exists for exactly that reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ce905590b5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let excused = manifest.omitted_for(host); | ||
| let (declared_absent, absent): (Vec<String>, Vec<String>) = union | ||
| .iter() | ||
| .filter(|name| !declared.contains(name.as_str())) |
There was a problem hiding this comment.
Reject templates that reintroduce omitted servers
When an omitted server is accidentally restored to a host template, this logic never examines it because it filters only names absent from that template. For example, restoring context7 to ClaudeCode/.mcp.json makes it present across hosts, so it is neither classified as omitted nor missing, and mcpctl check can exit successfully even though the endpoint again hides the Claude connector. Compare each host's declared names against manifest.omitted_for(host) and fail or otherwise report any intersection.
AGENTS.md reference: AGENTS.md:L141-L146
Useful? React with 👍 / 👎.
Stacked on #7 — based on
feat/github-mcpso the diff is just these two commits. GitHub will retarget this tomainwhen #7 merges.1.
omit = true— a host can decline a server entirelyClaude Code hides a claude.ai connector when a locally configured MCP server claims the same URL, so
claude.ai Context7andclaude.ai Microsoft Learnnever appeared whilecontext7andmicrosoft-learnsat in~/.claude.json. We want the connectors, and only on Claude Code — no other host has one to fall back on.enabled = falsecannot express that. A disabled server is still written into the host's config and merely switched off, and it is presence of the URL that does the hiding. So[servers.overrides.<Host>] omit = truenow means the server is not emitted for that host at all.manifest.rs— newOverride::omit.resolve_forfilters omitted servers rather than exposing the fact and asking each caller to remember it; all four callers (render, deploy ×2, fill-keys) mean "the servers this host has".validaterejectsomitset alongside fields it would silence.Manifest::parsesplit out ofloadso that rule is testable without a repo on disk.check.rs— class 2 would have reported the two absences as drift.checknow loads the manifest and partitions each host's absences intomissing(still fails the gate) andomitted(reported for visibility). Reading it from the manifest rather than a second hardcoded table follows the line the module already drew in its own doc comment:ACCEPTEDis for what a host cannot do, not for what the user declared. Class 2 loses nothing — hand-delete a server from a template and it still fails.ClaudeCode/.mcp.json— the only regenerated template, −11 lines.Every other host keeps both servers, the
CONTEXT7_API_KEYsecret, and the VS Code${input:…}override. TheYOUR_CONTEXT7_API_KEYplaceholder list in the README is deliberately unchanged — it is still correct for the other twelve templates.2. Credentials were being written to world-readable files
Every copy this tool makes of a live config holds the same real API keys that config holds, and the defaults publish them.
create_dir_allleaves a backup directory at 0755,fs::copypreserves the source's 0644, andfs::writecreates the pre-rename temporary at 0644.~/.mcp-backup/had accumulated 19 timestamped directories containing 103 world-readable files with live Context7, Brave and Perplexity keys in them. Unlike a live config, a backup is never looked at again, so nothing was going to surface it.write_atomicallynow carries the replaced file's mode across. Without that the tool silently undid the user's own hardening:~/.codex/config.tomlwas 0600 when it was backed up on 2026-08-03 and 0644 today — a deploy is what widened it.backupandwrite_atomicallybecomepubso the new tests can reach them; the lib target exists for exactly that reason.Verification
Gates:
cargo fmt --check,clippy --all-targets -D warnings(pedantic + restriction), 19 tests,mcpctl check,mcpctl render --check,reuse lint.Tests added — 3 in
tests/render.rs(Claude Code omits both while the other twelve carry them; an omission does not leak intodisabledMcpjsonServers;omitalongside a field override is rejected), 1 reworked to derive per-host expectations fromresolve_for, and 3 new intests/permissions.rspinning the modes.Rehearsed against copies of the real configs, per
AGENTS.md:deploy --host ClaudeCodethrough a PTY: 14 → 12 servers, exactly the two removed, the other ~6,800 lines and all three per-projectmcpServersblocks untouched.Follow-up, not in this PR
Deploying the omission to the live
~/.claude.jsonneeds an interactivemcpctl deploy --host ClaudeCodewith no Claude Code session running —--yesforces non-interactive mode and never prunes strays.🤖 Generated with Claude Code