[BUG] Config file (.claude.json) silently reverts user changes — stale write detection logs telemetry but does not prevent overwrite
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
When users manually edit ~/.claude.json to update MCP server environment variables (e.g., changing GROK_API_KEY and GROK_API_URL under mcpServers.grok-search.env), the changes are silently reverted without any warning.
The mechanism: Claude Code frequently writes to .claude.json for internal state updates (tipsHistory, numStartups, cachedStatsigGates, OAuth tokens, migration flags, etc.). The saveConfigWithLock function includes stale write detection that checks if the file's mtime/size changed since last read — but when it detects external modification, it only logs telemetry (tengu_config_stale_write) and continues the write, effectively overwriting the user's changes.
Additionally, the fallback path (when file locking fails) calls readConfig() which may return a cached in-memory version (1-second TTL cache), further ensuring the user's disk changes are lost.
This issue has occurred repeatedly across multiple sessions and is completely invisible — the user has zero indication their MCP config was reverted until they notice their MCP server is broken.
Key finding from source code analysis (deobfuscated from cli.js):
// In saveConfigWithLock:
if (lastKnownFileStats && filePath === globalConfigPath()) {
let currentStats = statSync(filePath);
if (currentStats.mtimeMs !== lastKnownFileStats.mtime ||
currentStats.size !== lastKnownFileStats.size) {
// Logs "tengu_config_stale_write" telemetry
// ⚠️ BUT DOES NOT ABORT — continues to overwrite the file!
}
}
What Should Happen?
When stale write is detected (file modified externally since last read), Claude Code should either:
- (Preferred) Re-read the file from disk, perform a three-way merge (last known state vs. current disk state vs. intended mutation), preserving user's manual edits for keys not touched by the internal mutation
- Abort the write and display a user-visible warning that external changes were detected
- At minimum, re-read from disk (bypassing cache) before applying the mutation — this partially works in the lock path but fails in the fallback path
Long-term: Internal state (statistics, feature flags, OAuth, tips history) should be separated from user-editable config (MCP servers, env vars) into different files, so internal state writes can never touch user config.
Error Messages/Logs
No error messages are displayed — that's part of the problem. The revert is completely silent.
The only trace is the `tengu_config_stale_write` telemetry event logged internally, which is invisible to the user.
Observed behavior during live debugging session:
- Attempted to use the Edit tool to fix `.claude.json` — failed twice with "File has been modified since read" because Claude Code was continuously writing to the file during the session
- This real-time demonstration confirmed that Claude Code writes to `.claude.json` extremely frequently during normal operation
Steps to Reproduce
- Start a Claude Code session
- While the session is running, manually edit
~/.claude.jsonto change an MCP server environment variable:
``json``
"mcpServers": {
"grok-search": {
"env": {
"GROK_API_KEY": "new-key-value",
"GROK_API_URL": "https://new-api-endpoint.example.com/v1"
}
}
}
- Continue using the Claude Code session normally (chat, use tools, etc.)
- After some time, check
~/.claude.jsonagain - Result: The MCP server env vars have been silently reverted to the values that were present when the session started
This is highly reproducible — it happens every time because Claude Code writes internal state to .claude.json frequently during normal operation. Each write triggers the full read-modify-write cycle that can overwrite user changes.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.50
Platform
Other
Operating System
Windows
Terminal/Shell
Windows Terminal
Additional Information
The following functions in cli.js are involved (deobfuscated names):
saveGlobalConfig(mutatorFn)— Entry point for all config writes. Has a try/catch where the catch block falls back to a lockless write that may use cached config.
saveConfigWithLock(filePath, defaults, mutatorFn)— Acquires file lock, performs stale detection (non-blocking), re-reads file, applies mutation, creates backup, performs atomic write.
getGlobalConfig()— Reads config with 1-second in-memory cache (TTL based onperformance.now()). Cache invalidation is based on file mtime comparison.
readConfig(filePath, defaults)— Reads and parses JSON. On parse failure, backs up as.corruptedand returns defaults (which would completely reset user config including mcpServers).
Impact Assessment
- Severity: High — MCP servers silently break with no user feedback
- Frequency: Occurs every time
.claude.jsonis manually edited while a session is running - Detectability: Zero — users cannot tell their config was reverted
- Scope: Affects all user-editable fields in
.claude.json, not just MCP servers
Suggested Architectural Improvement
Consider splitting .claude.json into:
~/.claude/state.json— Internal state (stats, flags, OAuth, tips) — written frequently, not user-edited~/.claude.jsonor~/.claude/config.json— User config (mcpServers, etc.) — written only on explicit user action
This would eliminate the entire class of bugs where internal state writes interfere with user configuration.
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗