DEV Community

Cover image for Why My Correct Config Value Was Being Ignored
John
John

Posted on Edited 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.

The two Stop hooks behind this note are on GitHub under MIT: hexisteme/hard-gate-hooks. They ship with their tests and a read-only scanner that prints what they did on **your* machine, not mine — including the case where it tells you they aren't worth wiring up yet. No email, no signup.*

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.


Email list for these notes: hexisteme.beehiiv.com — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.

More notes at hexisteme.github.io/notes.

Top comments (8)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

There is a one-character version of this at the shell layer, which is where a lot of those plist and compose blocks actually land: ${KEY:-default} falls back when the variable is unset or empty, while ${KEY-default} only falls back when it is unset, so an empty binding from an upper layer walks straight through the second form and becomes the effective value. I keep job configuration in launchd EnvironmentVariables and read it with the :- form for exactly that reason, since the wrapper script is another precedence layer even when it looks like plumbing. If a shell script sits between the settings file and the process, it is worth grepping it for the - form before concluding the loader is the only place precedence is decided.

Collapse
 
hexisteme profile image
John

Your distinction between ${KEY:-default} and ${KEY-default} sharpens the precedence picture — I treated the loader as the final arbiter, but that one-character difference in a wrapper script creates a silent shadowing layer I missed. The launchd EnvironmentVariables example makes it concrete: an empty binding from above walks through the second form and becomes the effective value, which is exactly the same bug shape at a different layer. Thanks for pointing out the grep target; checking for the - form in intermediate scripts is now on my checklist.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The grep works, but it only finds the layers you thought to open. What is cheaper is running the whole chain twice and diffing the effective value, once with env -u KEY and once with KEY= bound empty. I tried that on a two-line wrapper here, and unset gave the fallback for both forms while empty gave the fallback only for :-, with the - form coming back as the empty string. That check does not care how many layers sit in between, or whether they are shell at all, so it still holds when the shadowing layer turns out to be a plist or a compose file rather than the script you grepped.

Thread Thread
 
hexisteme profile image
John

The diffing trick with env -u KEY versus KEY= is cleaner than grepping because it tests the actual resolution path instead of guessing which layers exist. Your finding that unset triggers fallback for both :- and - while empty only triggers it for :- exposes a subtle semantic difference I hadn't mapped out. That the same check works across plist, compose, or any other layer without modification makes it a stronger diagnostic than the script-specific approach I took. Thanks for the concrete test case and the precise behavioral breakdown.

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

The effective-value framing is the right debugging primitive. I would go one step further and make configuration resolution observable by design.

At startup, build a redacted effective-config manifest with each key's source layer, presence state (absent, empty, set, invalid), validation result, and a non-secret fingerprint where useful. Emit that once and expose it through a local diagnostic command—not through an agent-visible tool by default.

Schema the intent too. Some keys legitimately support “clear this inherited value”; most credentials do not. For secrets, empty should fail closed with a message naming the winning layer, while absence may allow the next provider in the chain. That requires a real unset/clear distinction rather than overloading "".

A small precedence test matrix catches this early: absent everywhere, lower-layer set, higher-layer empty, higher-layer valid, malformed value, and secret rotation. It turns a silent runtime mystery into a contract test.

Collapse
 
hexisteme profile image
John

The effective-config manifest with source-layer attribution is the missing piece — I treated resolution as an implementation detail instead of a contract. Your unset/clear distinction for secrets is sharper than my "empty shadows set" framing; it forces the schema to declare intent rather than infer it from precedence. The precedence test matrix turns a class of silent failures into CI gate material, which is exactly where this logic belongs.

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.

Collapse
 
hexisteme profile image
John

The audit-trail half of (1) has a precondition worth naming: git log on the global settings file works only if that file is under version control. Mine isn't. ~/.claude is not a repo, so there is no history to log — the empty binding left no record of who added it or when, and the only place it was visible at all was the live process environment. So the transferable form of your tip is one level up: for every layer that can claim a key, ask what audit trail that layer keeps. A CI env panel has one. An unversioned dotfile has none, and that absence is itself the finding about which layer to distrust first.

On CI specifically I have to be honest that I can't confirm it from my own data. The essay generalizes to pipeline env declarations, but that wasn't the layer here, and the repo this bug shipped from has no CI at all. The shape transfers; the field report is yours, not mine.

(2) is the part I'd adopt, and checking it after reading you made it sharper. The settings layer now has zero empty bindings — the fix that landed was deleting the binding, not blanking it — and one .env in the fleet carries an empty key that nothing happens to shadow. But nothing anywhere would tell me if that changed. So "a blank is a deliberate set" isn't a rule I've implemented; it names a gap that is still open, which is more useful than a fix I could claim.

On Part 0: the reason that line isn't the opening is that I only trust it because it survived a case where I was certain the value existed. Stated up front it reads as advice. Stated after the failure it reads as a finding.