Repository navigation
config: unresolved {env:VAR} silently becomes an empty string (remote MCP fails with an opaque 401) #53784
Description
Activity
Thanks for the detailed report and the curl bisect. I think this is a duplicate of #33853, which reports the same thing: an unset
{env:VAR}in a remote MCPAuthorizationheader silently becomes an empty string, and the only symptom is an auth failure that doesn't mention the variable. #33853 was filed on 1.17.x, but I checked v2 and the code is unchanged:ConfigVariable.substitute()inpackages/core/src/config/variable.tsstill turns an unset variable into"". Themissingoption you found is only used for{file:...}references (a missing file is an error unlessmissingis"empty"). It never applies to{env:...}. The config docs also say an unset variable becomes an empty string, so what's missing is a diagnostic at config load that names the variable, and #33853 asks for exactly that.Possible duplicates and related issues, ordered from most to least likely:
- Missing {env:VAR} in config silently resolves to empty string #33853: same request: unset
{env:VAR}resolves to""with no error, shown with a remote MCPBearer {env:...}header - {env:VAR} substitution breaks auth.json fallback when env var is unset #34388: same empty substitution, here blocking the
auth.jsonfallback for a providerapiKey(closed as a duplicate of Missing {env:VAR} in config silently resolves to empty string #33853) - Remote MCP headers: {env:...} interpolation not working #23664: remote MCP headers with
{env:...}not producing the expected value - Remote MCP with custom headers returning 401 Unauthorized despite oauth: false #30428: remote MCP with custom headers and
oauth: falsereturning 401
The bot will close this issue as a duplicate in 1 day unless you reply explaining how your problem is different. Your point about the MCP docs (
mcp.<name>withenabledvsmcp.servers.<name>withdisabled) is a separate problem, so a separate issue for it would be welcome.- Missing {env:VAR} in config silently resolves to empty string #33853: same request: unset
Security angle (why this is worth more than a nicer warning)
A silently-empty
{env:...}pushes users toward the one fix that turns a misconfiguration into a disclosure: inlining the literal secret into the config. That is exactly what the mis-write in this report was — the key ended up in the variable-name slot of{env:...}, which is what you type when you are trying to make a placeholder work without knowing that the slot expects a name. From there it is a short step to"Authorization": "Bearer <literal key>".In the reported case that literal key was sitting in a git-tracked
opencode.json(git log -Sconfirms it was never committed, and the committed version was the correct{env:FAL_KEY}), with.gitignorenot covering the file. Onegit commit -awould have published a live credential. That is a plausible outcome of this bug for anyone whose project config is checked in — and project config is explicitly documented as "safe to be checked into Git".Three asks, in priority order:
- Name the unresolved variable (
mcp.servers.example: {env:EXAMPLE_KEY} is not set) so the placeholder's contract is obvious and inlining looks like the wrong move. Honouring the existingmissingpolicy would do this almost for free. - Redact credential-shaped values in diagnostics and logs. Please make sure
opencode mcp list,mcp auth list,debug configand the log writer never print resolvedAuthorizationheaders or values matching*_KEY/*_TOKEN/*_SECRET/Bearer …. Observed here:~/.local/share/opencode/log/opencode.logstores tool commands verbatim (message="spawning process" … args="[…]"), and a diagnosticcurlthat carried the key was written into it in full — 3 lines with the raw key, in a 43 MB world-readable file (-rw-r--r--) with no rotation. That is the log doing its job, but it means secrets reachable from a tool command are persisted unredacted. - Point at the safe path in the docs.
{file:...}already exists and is the right answer for secrets that must not live in an environment or a config; the MCP page only shows{env:…}.
No credentials are included in this comment. Separately: the key involved has been rotated since.
- Name the unresolved variable (
Thanks, and good that the key has been rotated. The security point is fair: if an unset
{env:...}fails silently, people may end up pasting the literal secret into a config file that's checked into git.This doesn't change the duplicate assessment, though. Your three asks map to different places:
- Name the unset variable at config load: this is exactly what Missing {env:VAR} in config silently resolves to empty string #33853 asks for, and it's the same code in v2.
ConfigVariable.substitute()inpackages/core/src/config/variable.tsreplaces an unset{env:VAR}with"". Itsmissingoption only applies to{file:...}references and is never read for{env:...}. Please add your security reasoning as a comment on Missing {env:VAR} in config silently resolves to empty string #33853 so it's all in one place. - Redact credentials in logs and diagnostics: this is a separate problem from
{env:...}substitution. The closest existing report is mcp: server error text can write config secrets into the local engine log #53051 (MCP server error text can write config secrets into the local engine log). Yours is broader: whole tool commands, including acurlthat carried the key, are written toopencode.log, which isn't rotated and is world-readable. Please add those details to mcp: server error text can write config secrets into the local engine log #53051, or open a new issue focused on logging if you think it's different enough. - Show
{file:...}for secrets on the MCP docs page: this is a docs change and fits naturally with themcp.<name>/mcp.servers.<name>mismatch you mentioned. Both could go in one docs issue.
Related issues, ordered by relevance:
- Missing {env:VAR} in config silently resolves to empty string #33853: unset
{env:VAR}silently resolves to""(same as this issue) - mcp: server error text can write config secrets into the local engine log #53051: secrets ending up in the local log
- Secret scanning at prompt submission: redact tokens before they reach the model/context (opt-out, on by default) #44717: secret redaction before content reaches the model (a different layer, for context)
As things stand, this issue will still be closed as a duplicate of #33853 at the scheduled time.
- Name the unset variable at config load: this is exactly what Missing {env:VAR} in config silently resolves to empty string #33853 asks for, and it's the same code in v2.
Closing this as a duplicate of #33853. That issue covers the same problem: an unset
{env:VAR}silently becomes"", and the remote MCP then fails with an auth error that doesn't name the variable. The code is the same in v2. Please add your security reasoning (that a silent failure pushes people towards inlining secrets) on #33853 so it's all in one place.Your other two asks are separate from this:
- Redacting secrets in logs: mcp: server error text can write config secrets into the local engine log #53051 is the closest report. Please add the details about
opencode.log, or open a new issue focused on logging. - Showing
{file:...}for secrets on the MCP docs page, plus themcp.<name>/mcp.servers.<name>mismatch: a separate docs issue would be welcome.
Thanks for the thorough report. If this turns out to be different from #33853, reply here and we can reopen it.
- Redacting secrets in logs: mcp: server error text can write config secrets into the local engine log #53051 is the closest report. Please add the details about
Summary
A
{env:VAR}placeholder whose variable is not set is silently substituted with an empty string. There is no warning or error at config load, and the only symptom is downstream: a remote MCP server whoseAuthorizationheader was written asBearer {env:VAR}sendsAuthorization: Bearerand fails with an opaque401 {"error":"Authentication required"}. In my case the placeholder's name was the literal credential (a natural mis-write), so this took a curl bisect to diagnose.The decompiled
substitutehelper suggests amissingpolicy exists but is not honoured:tis assigned but never used in the replacement, so unresolved placeholders always become""regardless of themissingpolicy — and because substitution happens on the raw text before parsing, there is no schema-level signal either.Environment
Reproduction
~/.config/opencode/opencode.jsoncor a projectopencode.json), configure a remote MCP server with a variable that is not exported:EXAMPLE_KEYis not set anywhere (env, shell rc,environment.d, project.env).opencode mcp list.Expected Behavior
At minimum, a diagnostic naming the unresolved placeholder — e.g.
mcp.servers.example: {env:EXAMPLE_KEY} is not set— so the failure is attributable to configuration rather than to the remote server. Ideally the declaredmissingpolicy is honoured (error, or warn-and-substitute), andopencode mcp listshows that a header resolved to empty instead of only the HTTP result.Actual Behavior
"".opencode mcp listprints only:Authorization: Bearer(empty) →401 {"error":"Authentication required"}(matches the reported failure exactly)Authorization: Bearer not-a-real-key-000→401 {"error":"Invalid or expired access token"}(different message)200initialize(200), so a401is the only clue — which is why a config-level diagnostic matters.Additional Context
{env:}substitution happens before JSONC parsing, so Windows paths break the file) and mcp: service env ({env:...}) not passed to local MCP servers — regression in 2.0.14/2.0.15 #50882 ({env:...}service env not passed to local MCP servers)./docs/mcp-serversdocuments servers directly undermcp.<name>withenabled: true, whereas this V2 config nests them undermcp.servers.<name>withdisabled: false, and the failing entry's config path was reported asmcp.servers.example. Both appear to load; flagging only because the two pages disagree.{env:VAR}(per the config docs,{file:...}is also available for secrets kept in a file).