Two config keys differing only by a drive letter's case, and half your settings vanish
A tool registered a server, listed it as connected, and never loaded it. The config file held C:\Dev\Base and c:\Dev\Base as separate keys. One process wrote to one, the other read from the other, and both were behaving correctly.
TL;DR · THE FIX
Windows paths are case-insensitive, JSON object keys are not. Any config keyed by an absolute path will silently fork into two entries the moment two processes disagree about the case of the drive letter, and every read and write after that goes to whichever half the caller happened to produce. The tell is a write that reports success followed by a read that shows nothing. Grep the config for case-variant duplicates with a lowercased comparison, merge them, and key config by something other than a raw path where you can.
The symptom
I registered a server with a CLI and it said it worked:
Added server "gitnexus" to project config
Listing the servers showed it there and connected:
gitnexus: ... - Connected
Then I opened an interactive session in the same directory. The server was not loaded, none of its tools existed, and there was no error, warning or failed-to-start message anywhere. The session behaved as if I had never added the server. Adding it again produced the same success line, listing it again produced the same connected line, and the session went on not having it.
What I chased first
I assumed the server was crashing at startup and the session was hiding the failure, so I ran the server command by hand. It started fine and answered a handshake. I assumed a version mismatch and reinstalled, then a stale cache and restarted everything twice, then a typo and read the command character by character.
The signal I was ignoring sat in that pair of outputs: the CLI could see the entry and the session could not. I kept reading that as “the session fails to load a config it can see”, when it meant the session was not looking at the same config.
What was happening
The tool stores per-project settings in a single JSON file, keyed by the project’s absolute path:
{
"projects": {
"C:\\Dev\\Base": {
"mcpServers": { "gitnexus": { "command": "node", "args": ["..."] } }
},
"c:\\Dev\\Base": {
"mcpServers": {}
}
}
}
Look at the drive letters. C:\Dev\Base and c:\Dev\Base are the same directory as far as Windows, the file system, and any human reading the file are concerned. To a JSON object they are two different strings, so they are two different keys holding two different objects.
The CLI produced the path one way, wrote the server into that entry, and read back from the same entry, so from where it stood everything was consistent and it reported success honestly. The interactive session produced the path the other way, looked up its own entry, found no servers, and loaded none, also honestly. Two processes, both correct, disagreeing about the case of a single letter, and the disagreement is invisible in every message either of them prints.
The case variation is not usually anybody’s bug. process.cwd(), os.getcwd(), a path pasted from a shell prompt, an argument the user typed, an environment variable set years ago, and a path that has been through path.resolve() do not all agree about drive-letter case on Windows. Some layers normalise and some do not, and a config file written by more than one entry point over months collects both forms.
The general shape is that the file system considers two names equal and the config format considers them different, and anything that identifies a record by a path inherits that mismatch. Case-insensitive macOS volumes have the same trap across the whole path.
Finding it
One pass over the keys, comparing them lowercased:
import json, collections, pathlib
cfg = json.loads(pathlib.Path.home().joinpath(".claude.json").read_text(encoding="utf-8"))
groups = collections.defaultdict(list)
for key in cfg.get("projects", {}):
groups[key.lower()].append(key)
for lowered, keys in groups.items():
if len(keys) > 1:
print("DUPLICATE:", keys)
Any group with more than one member is a fork, and each member holds some fraction of the settings you thought you had. The same idea in one line, for any JSON config keyed by paths:
python -c "import json,sys;d=json.load(open(sys.argv[1],encoding='utf-8'));ks=list(d.get('projects',{}));print([k for k in ks if sum(1 for j in ks if j.lower()==k.lower())>1])" ~/.claude.json
Do not try to spot these by eye. One character of case difference in a long Windows path, in a file with dozens of entries, is exactly what reading does not find.
The fix
Merge the duplicates. Back the file up first, because you are about to hand-edit the thing that holds all of your settings:
import json, pathlib, shutil
p = pathlib.Path.home() / ".claude.json"
shutil.copy2(p, p.with_suffix(".json.bak"))
cfg = json.loads(p.read_text(encoding="utf-8"))
projects, merged = cfg.get("projects", {}), {}
for key, value in projects.items():
canonical = next((k for k in merged if k.lower() == key.lower()), None)
if canonical is None:
merged[key] = value
continue
# deep-merge the smaller entry into the one already kept
for section, contents in value.items():
if isinstance(contents, dict):
merged[canonical].setdefault(section, {}).update(contents)
elif section not in merged[canonical]:
merged[canonical][section] = contents
cfg["projects"] = merged
p.write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8")
Then, where you have the option, stop keying the thing by a path, because that is what stops it coming back. For this tool, registering the server at user scope instead of project scope moves it out of the path-keyed section entirely:
claude mcp add -s user gitnexus node /path/to/server.js
User scope has one key rather than one per directory, so there is nothing for the case variation to fork.
If you are the one writing a path-keyed config, normalise on the way in, in exactly one place:
def config_key(path: str) -> str:
resolved = os.path.realpath(os.path.abspath(path))
return os.path.normcase(resolved) # lowercases the drive letter on Windows, no-op on POSIX
os.path.normcase is the standard-library answer and it is correct per platform, which a hand-rolled .lower() is not. In Node, path.resolve() does not normalise drive-letter case, so you have to do it yourself.
The lesson
The tell was a write that reported success and a read that showed nothing, from two different processes. That pair should send you looking for two stores. A failed write reports failure; a write that succeeds while the value stays invisible almost always means the reader and the writer are pointed at different places.
More broadly, a path is a bad primary key. It is case-insensitive on some file systems and not others, it has several spellings for one location, it changes when a directory is renamed or moved, and it is produced by whatever layer happened to construct it. Each of those properties turns into a silently forked record. If you must key by a path, normalise it in a single function that every read and write goes through, and add a five-line startup check that shouts when two keys collide once lowercased.
Discussion
Powered by GitHub. Sign in to leave a comment.