Skip to content

feat(manifest): per-host server omission, and stop writing keys to world-readable files - #8

Closed
UnbreakableMJ wants to merge 2 commits into
feat/github-mcpfrom
feat/omit-servers-per-host
Closed

feat(manifest): per-host server omission, and stop writing keys to world-readable files#8
UnbreakableMJ wants to merge 2 commits into
feat/github-mcpfrom
feat/omit-servers-per-host

Conversation

@UnbreakableMJ

Copy link
Copy Markdown
Collaborator

Stacked on #7 — based on feat/github-mcp so the diff is just these two commits. GitHub will retarget this to main when #7 merges.

1. omit = true — a host can decline a server entirely

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 appeared while context7 and microsoft-learn sat in ~/.claude.json. We want the connectors, 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 that does the hiding. So [servers.overrides.<Host>] omit = true now means the server is not emitted for that host at all.

  • manifest.rs — new Override::omit. resolve_for filters 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". validate rejects omit set alongside fields it would silence. Manifest::parse split out of load so that rule is testable without a repo on disk.
  • check.rs — class 2 would have reported the two absences as drift. check now loads the manifest and partitions each host's absences into missing (still fails the gate) and omitted (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: ACCEPTED is 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_KEY secret, and the VS Code ${input:…} override. The YOUR_CONTEXT7_API_KEY placeholder 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_all leaves a backup directory at 0755, fs::copy preserves the source's 0644, and fs::write creates 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.

  • The vault, its timestamp directories and every copy are created owner-only.
  • The temporary is narrowed before any content reaches it, not after.
  • A rename adopts the temporary's permissions, so write_atomically now carries the replaced file's mode across. Without that the tool silently undid the user's own hardening: ~/.codex/config.toml was 0600 when it was backed up on 2026-08-03 and 0644 today — 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.

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 into disabledMcpjsonServers; omit alongside a field override is rejected), 1 reworked to derive per-host expectations from resolve_for, and 3 new in tests/permissions.rs pinning the modes.

Rehearsed against copies of the real configs, per AGENTS.md:

  • Interactive deploy --host ClaudeCode through a PTY: 14 → 12 servers, exactly the two removed, the other ~6,800 lines and all three per-project mcpServers blocks untouched.
  • A second run confirmed live configs at 0600 stay 0600 through a deploy, and the backup came out 0700/0600.

Follow-up, not in this PR

Deploying the omission to the live ~/.claude.json needs an interactive mcpctl deploy --host ClaudeCode with no Claude Code session running — --yes forces non-interactive mode and never prunes strays.

🤖 Generated with Claude Code

UnbreakableMJ and others added 2 commits August 10, 2026 00:27
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread mcpctl/src/check.rs
let excused = manifest.omitted_for(host);
let (declared_absent, absent): (Vec<String>, Vec<String>) = union
.iter()
.filter(|name| !declared.contains(name.as_str()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

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.

1 participant