DEV Community

John
John

Posted on Originally published at hexisteme.github.io

Why My Correct Config Value Was Being Ignored

Originally published on hexisteme notes.

I run a small fleet of AI agents and MCP servers on my own machine, and every so often I run a full audit pass over the harness — settings files, server configs, environment variables, all of it — just to see what's actually wired up versus what's quietly rotted. During one of those audits I found a server that looked completely dead: an MCP server that wraps an external API (in this case, a patent-search API). It had a real, valid key sitting in its project's .env file — correct format, correct length, nothing wrong with it. And yet at runtime the server behaved as if no key existed at all. It reported itself as unconfigured and refused to do lookups.

The wrong diagnosis

My first instinct was to treat this as a broken server, not a broken config. I opened the .env file, confirmed the key was there, confirmed it looked like a real key and not a leftover placeholder, and concluded the problem had to be downstream of that — maybe the server's own code had a bug reading the variable, maybe the API had changed its auth shape, maybe a reinstall would shake something loose. That's the natural place to land, because the question I was implicitly asking was "does the correct value exist somewhere in this project?" And the answer was yes. So I kept looking in the wrong place: at the server, not at everything sitting between the server and its own .env file.

That's the trap. "It exists" and "it's the value actually being used" are different questions, and when a config system has more than one layer, only the second one matters.

What was actually happening

The real cause turned up during the audit, not during debugging the server directly. My global Claude Code settings file had an mcpServers.<name>.env block for this server, and in that block, the same key name was set to an empty string — left there, I assume, as a kind of documentation: "this is the variable this server expects." That block gets injected into the process environment before the server's own code ever runs.

The server loaded its config with Python's python-dotenv, calling load_dotenv() with the library's default behavior, override=False. That default means: if a variable is already present in the environment, load_dotenv() will not overwrite it with whatever is in the .env file — even if the existing value is an empty string. So by the time the server's process started, the environment already had the key defined as "", courtesy of the outer settings file. load_dotenv() looked at that, saw the key was "already defined," and left the real value in .env untouched and unloaded. The server then did the equivalent of os.getenv("THE_KEY", ""), got back an empty string, and correctly concluded it had no key — so it self-disabled.

No exception, no error log, no warning that a .env file was being ignored. Just silence, and a server that looked dead from every outward angle while sitting on top of a perfectly good key it never saw.

The general rule

The mistake wasn't a typo or a missing file — it was asking the wrong question. When configuration is assembled from more than one layer — process environment versus .env file, CLI flags versus a config file, local settings versus global settings, a container's environment: block versus an app's own config — the convention is that the outer or higher-priority layer wins. That part is fine; it's how precedence is supposed to work.

The trap is assuming "wins" implies "was intentionally set to something meaningful." To a precedence mechanism, an empty string is just as defined as a real value. KEY="" is not the same as KEY being absent. A blank left in an upper layer "for documentation" or "as a placeholder to remind myself what this needs" is exactly as authoritative as a real value would be, and it will shadow the correct value underneath it — silently, with no error signal, because from the loader's point of view nothing went wrong. It did exactly what its precedence rules say it should do.

This isn't specific to python-dotenv. Any layered config system has the same shape wherever a higher-priority layer can declare a key with an empty or placeholder value: environment blocks in compose files, launchd plists, CI pipeline env declarations. The moment you put an empty binding for a key in any layer that has override authority over another layer holding the real value, you've planted something that looks like nothing and behaves like a landmine.

How to audit for it

The fix for the actual diagnosis question is to stop checking for existence and start checking for the effective value at the point where the code actually reads it. A one-off check like this, run in the same environment the server would start in, tells you immediately whether something upstream already claimed the key:

# diagnosis: which layer is actually winning — not whether the key exists anywhere
python3 - <<'PY'
import os
print("pre-set in env:", repr(os.environ.get("THE_KEY")))  # "" means something upstream already shadowed it
PY
Enter fullscreen mode Exit fullscreen mode

If that prints '' rather than None, some layer above your .env file has already defined the key — go looking through every layer that sits above it (global harness settings, container env blocks, shell exports, plist entries) for a declaration like KEY: "", and remove it. Not blank it further, not comment out the value — delete the binding entirely. Omitting a key defers precedence down to the next layer; declaring it as empty does not, no matter how empty it looks.

Once I found the offending block in the settings file, the fix was a one-line deletion, not a rewrite of the server or a reinstall of anything. The general move is: strip the empty declaration out of the upper layer, and let the real value live in the layer closest to the thing that actually consumes it — ideally scoped per-consumer rather than sitting in some shared, ambient layer that every tool inherits from by default. Flipping the loader to override=True instead is the tempting quick fix, and it does make this one case work — but it's the wrong fix, because it can silently break some other, unrelated case where you actually wanted the outer layer to take precedence over a different lower layer. Fixing the precedence bug by changing precedence semantics globally just relocates the same class of bug to wherever you're not currently looking.

What I'm keeping from this

A few habits came out of this one directly. I don't leave "documentation placeholder" empty values in any config layer that has override authority over a real one anymore — if I want to note what a server expects, that goes in a comment or a README, not in an empty binding that a loader will treat as a real assignment. When something that's "obviously configured correctly" still isn't working, the first move is now to print the effective value at the actual read site, not to re-confirm that the correct value exists somewhere on disk — I already know it exists; that was never the question. And when I do find a real value buried under a broken layer, the fix is to delete the layer that's shadowing it, not to change how the loader resolves precedence — because the second option usually just moves the failure mode somewhere less visible.

More notes at hexisteme.github.io/notes.

Top comments (1)

Collapse
 
liesliy profile image
liesliy

Great write-up — hit the exact same trap, two things to add.

(1) It's nastiest in CI. Locally you see '' and know an upper layer claimed it. In CI nobody runs that check — usually a pipeline env panel where someone blanked a key "to make the test pass" and never removed it. git log on the global settings file finds more real offenders than re-reading the loader.

(2) Agreed override=True is wrong — but it's seductive because it looks right at the failure site. The real takeaway: a blank is a deliberate set, so any shadowing layer shouldn't let empty values pass silently. Treat KEY="" as misconfiguration unless "clearing this key" is explicitly allowed.

And that line — "it exists" vs "it's the value being used" — deserves to be Part 0 of this series. It's the actual methodology.