Repository navigation
v7.40.4: lifeos -m <shortcut> replaces ~/.claude/.mcp.json with {"mcpServers": {}} — all 12 built-in shortcuts, no backup, exit 0 #2156
Description
The help text says lifeos -m <mcp> means "Launch with specific MCP(s)". On a stock install it removes ~/.claude/.mcp.json and writes an empty config in its place, then launches the session. Measured with the script below: a 3-server file, 303 bytes, becomes a 0-server file, 22 bytes. Exit code is 0, nothing is backed up, and the only output about it is one warning naming a file that does not exist on the install.
This happens for every one of the 12 built-in shortcuts, and for a bare lifeos -m with no argument.
Version: v7.40.4 (be9e8ef), payload at LifeOS/install/. macOS, bun 1.4.0.
What is broken
~/.claude/.mcp.json is the path the docs name as the MCP config (LIFEOS/DOCUMENTATION/Tools/Tools.md:753, "Config: ~/.claude/.mcp.json", with a server table under it), and SystemUserBoundary.md:33 lists it under SYSTEM (config). The launcher rewrites that file as a side effect of -m.
The release ships no MCPs/ directory and none of the eight profile files the shortcuts point at. Confirmed on a clean clone:
$ find . -iname 'MCPs' -o -iname '*.mcp.json' -o -iname '*-MCP.json'
(no output)
The tool agrees, on a freshly installed tree:
$ lifeos mcp list
📋 Available MCPs:
📦 Individual MCPs (use with -m):
📁 Profiles (use with 'k mcp set'):
💡 Examples:
k -m bd # Bright Data only
Zero MCPs, zero profiles, and -m bd printed as the example to follow.
Where
Reader — LIFEOS/TOOLS/lifeos.ts:279-299, mergeMcpConfigs. A missing input file warns and continues, so the empty initialiser from line 280 is what gets returned:
280 const merged: Record<string, any> = { mcpServers: {} };
282 for (const file of mcpFiles) {
283 const filepath = join(MCP_DIR, file);
284 if (!existsSync(filepath)) {
285 log(`Warning: MCP file not found: ${file}`, "⚠️");
286 continue;
287 }
...
298 return merged;Writer — LIFEOS/TOOLS/lifeos.ts:318-351, setMcpCustom. Two things meet here. The shortcut branch pushes a filename without checking it exists, while the fallback branch checks twice and exits when both miss:
322 const file = MCP_SHORTCUTS[name.toLowerCase()];
323 if (file) {
324 files.push(file); // no existsSync
325 } else {
329 if (existsSync(join(MCP_DIR, directFile))) {
331 } else if (existsSync(join(MCP_DIR, profileFile))) {
334 error(`Unknown MCP: ${name}`); // exits 1 before any writeAnd the write is unconditional on the merge result:
339 const merged = mergeMcpConfigs(files);
342 if (existsSync(ACTIVE_MCP)) {
343 unlinkSync(ACTIVE_MCP);
344 }
345 writeFileSync(ACTIVE_MCP, JSON.stringify(merged, null, 2));
347 const serverCount = Object.keys((merged as any).mcpServers || {}).length;
348 if (serverCount > 0) {
349 log(`Configured ${serverCount} MCP server(s): ${mcpNames.join(", ")}`, "✅");
350 }ACTIVE_MCP is join(CLAUDE_DIR, ".mcp.json") (line 35). Because serverCount is 0, line 349 does not print, so no output names the file or says it was replaced.
cmdLaunch calls this before the session starts (lines 509-512), so -m on a launch is enough.
Bare -m reaches the same place. The parser maps -m with no argument (and -m 0, and -m "") to the none shortcut, which is a shortcut and takes the unchecked branch:
825 // -m with no arg, or -m 0, or -m "" means no MCPs
826 if (!nextArg || nextArg.startsWith("-") || nextArg === "0" || nextArg === "") {
827 mcp = "none";Repro on a clean tree
Self-contained. Clones the tag, builds a throwaway $HOME, and deletes it at the end. The stub claude is there because requireClaudeCli() aborts before the MCP code when no claude is on PATH, and so no real session opens.
#!/usr/bin/env bash
set -euo pipefail
WORK="$(mktemp -d)"; cd "$WORK"
git clone --depth 1 --branch v7.40.4 https://github.com/danielmiessler/LifeOS.git repo >/dev/null 2>&1
TREE="$WORK/tree"; mkdir -p "$TREE/.claude" "$TREE/bin"
cp -R repo/LifeOS/install/. "$TREE/.claude/"
printf '#!/bin/sh\nexit 0\n' > "$TREE/bin/claude"; chmod +x "$TREE/bin/claude"
cat > "$TREE/.claude/.mcp.json" <<'JSON'
{ "mcpServers": {
"example-notes": { "command": "npx", "args": ["-y", "@example/notes-mcp"] },
"example-tickets": { "command": "npx", "args": ["-y", "@example/tickets-mcp"] },
"example-sql": { "command": "/usr/bin/false", "args": ["--dsn", "postgres://example.invalid/demo"] } } }
JSON
servers() { bun -e 'console.log(Object.keys(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).mcpServers).length)' "$TREE/.claude/.mcp.json"; }
echo "before: $(servers) servers, $(wc -c < "$TREE/.claude/.mcp.json" | tr -d ' ') bytes"
env -i HOME="$TREE" PATH="$TREE/bin:$(dirname "$(command -v bun)"):/usr/bin:/bin" PULSE_URL="http://127.0.0.1:9" \
bun "$TREE/.claude/LIFEOS/TOOLS/lifeos.ts" -m bd > "$TREE/out.txt" 2>&1
echo "exit code: $?" # the launcher's own status, not a pipeline's
sed 's/\x1b\[[0-9;]*m//g' "$TREE/out.txt" | grep -E 'Warning|error'
echo "after: $(servers) servers, $(wc -c < "$TREE/.claude/.mcp.json" | tr -d ' ') bytes"
cat "$TREE/.claude/.mcp.json"; echo
echo "-- any backup of the old file:"
find "$TREE" \( -name '.mcp.json.*' -o -name '*.bak' -o -name '*.orig' -o -name '*~' \) -print | grep . || echo " none"
# Control: same command, a name that is NOT a built-in shortcut.
cat > "$TREE/.claude/.mcp.json" <<'JSON'
{ "mcpServers": { "a": {}, "b": {}, "c": {} } }
JSON
env -i HOME="$TREE" PATH="$TREE/bin:$(dirname "$(command -v bun)"):/usr/bin:/bin" PULSE_URL="http://127.0.0.1:9" \
bun "$TREE/.claude/LIFEOS/TOOLS/lifeos.ts" -m nosuchmcp >/dev/null 2>&1
echo "control (-m nosuchmcp) exit code: $?"
echo "control: $(servers) servers still present"
rm -rf "$WORK"Output:
before: 3 servers, 303 bytes
⚠️ Warning: MCP file not found: Brightdata-MCP.json
after: 0 servers, 22 bytes
{
"mcpServers": {}
}
-- any backup of the old file:
none
control (-m nosuchmcp) exit code: 1
control: 3 servers still present
Every shortcut behaves the same. The same 3-server file was restored before each run; it is the fuller version of the config above, hence 303 bytes rather than the script's compacted 303. What does not move is the "after": 22 bytes, 0 servers, exit 0.
| shortcut | servers before → after | bytes before → after | exit | warning |
|---|---|---|---|---|
bd |
3 → 0 | 303 → 22 | 0 | MCP file not found: Brightdata-MCP.json |
brightdata |
3 → 0 | 303 → 22 | 0 | MCP file not found: Brightdata-MCP.json |
ap |
3 → 0 | 303 → 22 | 0 | MCP file not found: Apify-MCP.json |
apify |
3 → 0 | 303 → 22 | 0 | MCP file not found: Apify-MCP.json |
dev |
3 → 0 | 303 → 22 | 0 | MCP file not found: dev-work.mcp.json |
sec |
3 → 0 | 303 → 22 | 0 | MCP file not found: security.mcp.json |
security |
3 → 0 | 303 → 22 | 0 | MCP file not found: security.mcp.json |
research |
3 → 0 | 303 → 22 | 0 | MCP file not found: research.mcp.json |
full |
3 → 0 | 303 → 22 | 0 | MCP file not found: full.mcp.json |
min |
3 → 0 | 303 → 22 | 0 | MCP file not found: minimal.mcp.json |
minimal |
3 → 0 | 303 → 22 | 0 | MCP file not found: minimal.mcp.json |
none |
3 → 0 | 303 → 22 | 0 | MCP file not found: none.mcp.json |
Bare -m, documented as "No MCPs (maximum performance)":
$ lifeos -m
before servers: 3 bytes: 303
⚠️ Warning: MCP file not found: none.mcp.json
after servers: 0 bytes: 22
Negative controls
Three, isolating the trigger to the shortcut branch specifically.
No -m at all. Same tree, same binary, file untouched.
$ lifeos
before servers: 3 bytes: 303
after servers: 3 bytes: 303
-m with a name that is not a shortcut. The guarded branch refuses and the file survives (this is the control in the script above).
$ lifeos -m nosuchmcp
exit code: 1
❌ Unknown MCP: nosuchmcp
after servers: 3 bytes: 303
lifeos mcp set full, the same missing profile through the other entry point. setMcpProfile checks first (lines 303-305):
$ lifeos mcp set full
exit code: 1
❌ Profile 'full' not found
before servers: 3 → after servers: 3
Same target file, same missing profile, opposite outcome depending on which command reaches it.
Backups. A full file listing of the tree before and after one -m bd run is identical (diff empty). Nothing in the shipped payload copies the path either:
$ grep -rn "mcp.json" LifeOS/install --include='*.ts' --include='*.sh' | grep -iE "backup|bak|copy|cp "
(no match)
Recovery depends on the user having the file in a backup or a git remote.
Why the file matters
cmdLaunch runs process.chdir(CLAUDE_DIR) at :541 before spawning claude at :564, so ~/.claude is the session's working directory.
#646's table marks this exact path "Read by Claude Code? ❌ No", and the upstream troubleshooting docs say the same of ~/.claude/.mcp.json. That holds for the global case those describe, and not for a session rooted at ~/.claude. Measured on Claude Code 2.1.276, in a throwaway HOME containing nothing but that file:
H="$(mktemp -d)"; mkdir -p "$H/.claude" "$H/elsewhere"
printf '{"mcpServers":{"probe-server":{"command":"/usr/bin/false","args":[]}}}' > "$H/.claude/.mcp.json"
( cd "$H/.claude" && env -i PATH=/usr/bin:/bin HOME="$H" claude mcp list ) # A
( cd "$H/elsewhere" && env -i PATH=/usr/bin:/bin HOME="$H" claude mcp list ) # B
printf '{\n "mcpServers": {}\n}' > "$H/.claude/.mcp.json"
( cd "$H/.claude" && env -i PATH=/usr/bin:/bin HOME="$H" claude mcp list ) # C
rm -rf "$H"(Substitute the absolute path to the claude binary if PATH=/usr/bin:/bin does not reach it.)
A. cwd = $HOME/.claude (what lifeos.ts:541 chdir's to)
audit-probe-dotclaude: /usr/bin/false - ⏸ Pending approval (run `claude` to approve)
B. same HOME, cwd = an unrelated directory
No MCP servers configured. Use `claude mcp add` to add a server.
C. same cwd as A, after the file is emptied the way `-m` empties it
No MCP servers configured. Use `claude mcp add` to add a server.
A and B together are the reconciliation: the file is not a global config, which is what #646 measured, but it is the project-scope .mcp.json for a session rooted at the config root — the only kind of session this launcher starts. C is the loss. Claude Code's own documentation describes project-scoped servers as "storing configurations in a .mcp.json file at your project's root directory", and says it "prompts for approval in interactive sessions before using project-scoped servers from .mcp.json files". So for every non---local session this launcher starts, that file is the project-scoped MCP config, and anything in it is a server the session offers — approved already, or pending the one-time approval the probe above shows as ⏸ Pending approval. What the file buys is enumeration; approval is the step after it. Tools.md:753 names the same path, and #901's close states what this launcher is supposed to do: "the launcher defers to native /mcp plus real .mcp.*.json files". -m is the path where it does not — it rewrites the real .mcp.json instead of deferring to it.
There is also no restore. ACTIVE_MCP has no reference after the write at :345; cmdLaunch awaits the child at :570 and ends. The help text distinguishes lifeos -m <mcp> ("Launch with specific MCP(s)") from lifeos mcp set <profile> ("Set MCP profile permanently"), but -m's write is permanent too. Even on an install that has populated MCPs/, -m replaces a hand-edited .mcp.json with the merge and leaves it that way; the stock tree is just the case where the merge is empty. (Static reading — I did not exercise it with a populated MCPs/.)
What a user loses
Whatever was in that file: server entries with their command, args, and env keys, HTTP endpoints, and worker URLs. The docs' own example table for this path lists a Cloudflare Worker endpoint under a user-specific subdomain, which is the kind of value that is not trivially retyped.
Two edge variants, for completeness. If .mcp.json is a symlink (which is what mcp set leaves behind, line 313), the link is replaced by a regular empty file and the profile file it pointed at survives. If there is no .mcp.json at all, the command creates an empty one and nothing is lost.
Suggested fix
Options, none of them tested here:
-
Check the shortcut path the way the fallback path is checked. Resolve
MCP_SHORTCUTS[name]to a path and run the sameexistsSyncbefore pushing, so a missing shortcut target takes the existingerror("Unknown MCP: ...")exit rather than reaching the writer. This makes the two branches ofsetMcpCustomagree, and it is where the divergence starts. -
Refuse to write an empty merge. In
setMcpCustom, return before theunlinkSync/writeFileSyncwhenObject.keys(merged.mcpServers).length === 0and the inputs were all missing. Cheap, and it makes the worst case a no-op launch instead of a replaced file. -
Make the no-op case say so. The
serverCount > 0guard on line 348 means the quiet path is the destructive one. Anelsebranch naming the file that changed would at least leave the user something to search for. -
Copy before replacing.
writeFileSync(ACTIVE_MCP + ".bak", ...)before the unlink, or write to a temp file and rename, so the previous contents survive a surprise.
(1) or (2) alone closes the data-loss path; (3) and (4) are independent hardening.
This is the same shape as #1527, where an empty deny: [] in a user overlay replaced the system denylist: an empty merge result written over live config as though it were an intentional value. Different file and different code path, so it is a precedent rather than a duplicate.
Scope
What is inference rather than something measured here:
- I confirmed the server is picked up from that path with
claude mcp list(transcript above) but did not start an interactive session and watch it connect and run a tool. - How many installs have a non-empty
.mcp.json. Not surveyed. What is established is that it is the documented location, and thatgetCurrentProfile()(lines 265-277) returns"custom"for a regular file there. - Whether the one warning line is visible in practice. It prints before
claudeis spawned. Whether it survives on screen once the TUI takes the terminal was not measured. - Platforms. Runs were macOS/APFS only. The code path has no platform-specific branch, but that was not tested elsewhere.
- Filesystem-level undelete. Not tested. What is established is that the program makes no copy.
-m bd,apagainst a populatedMCPs/. Not exercised. The finding is about the stock tree, where the directory is absent.
- Searched open and closed issues before filing (terms:
mcp,.mcp.json,mcpServers,mergeMcpConfigs,MCP_SHORTCUTS,mcp profile,MCPs directory,lifeos -m,pai -m,launcher mcp,config overwritten,data loss,overwrite,wipes,clobber,destructive,unlinkSync,deletes,backup before,in:title mcp). Nearest are MergeSettings: empty "deny": [] in user overlay silently wipes all system deny rules #1527 (same shape, different file, fixed in MergeSettings), MCP server configs in settings.json are silently ignored by Claude Code #646 (closed) — its table marks~/.claude/.mcp.jsonas not read by Claude Code, which is correct for the global case it measured and not for a session whose working directory is~/.claude, the only kind this launcher starts; the measurement reconciling the two is in "Why the file matters" above, and it does not cover the launcher's write and v7.40.4: lifeos.ts ignores CLAUDE_CONFIG_DIR; DeployCore scaffolds MEMORY/ where SystemUserBoundary rejects it #1896 (same file and release,CLAUDE_CONFIG_DIRresolution, does not touchsetMcpCustom). No open or closed issue covers this path. - Reproduced on a clean tree: every command above runs against a fresh
git clone --depth 1 --branch v7.40.4, inside a throwaway$HOMEunderenv -i, and depends on nothing local. - Personal data removed: the config used in the repro is synthetic, the tokens are literal placeholders, the endpoints are
.invalid, and no path from a real install appears anywhere above.
Activity
danielmiessler commentedon Oct 3, 2026
Thanks @tzioup.
lifeos -mnow stops with an error naming the missing file when a shortcut maps to a config that isn't in the MCP directory, and leaves.mcp.jsonuntouched instead of writing an empty server list and exiting 0 (LIFEOS/TOOLS/lifeos.ts). Next release carries it.