Repository navigation
config: unresolved {env:VAR} silently becomes an empty string (remote MCP fails with an opaque 401) #53784
Description
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 whose Authorization header was written as Bearer {env:VAR} sends Authorization: Bearer and fails with an opaque 401 {"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 substitute helper suggests a missing policy exists but is not honoured:
async function d(o) {
let t = o.missing ?? "error",
r = o.text.replace(/\{env:([^}]+)\}/g, (D, n) => {
return (o.env?.[n] ?? process.env[n]) || "" // unset -> ""
}),
...
}t is assigned but never used in the replacement, so unresolved placeholders always become "" regardless of the missing policy — and because substitution happens on the raw text before parsing, there is no schema-level signal either.
Environment
- opencode version: 2.0.24
- OS: Linux cachyos-x8664 7.2.8-1-cachyos x86_64 (CachyOS)
- Terminal: TERM=xterm-256color, COLORTERM=truecolor (Konsole)
- Shell: /bin/fish
- Install/channel: latest
- Active plugins: billion-context, opencode-orchestrator, superpowers@git+https://github.com/obra/superpowers.git
Reproduction
- In any config layer (
~/.config/opencode/opencode.jsoncor a projectopencode.json), configure a remote MCP server with a variable that is not exported:"mcp": { "servers": { "example": { "type": "remote", "url": "https://mcp.example.com/mcp", "oauth": false, "headers": { "Authorization": "Bearer {env:EXAMPLE_KEY}" } }}}
- Ensure
EXAMPLE_KEYis not set anywhere (env, shell rc,environment.d, project.env). - Run
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 declared missing policy is honoured (error, or warn-and-substitute), and opencode mcp list shows that a header resolved to empty instead of only the HTTP result.
Actual Behavior
- No warning or error anywhere; the placeholder becomes
"". opencode mcp listprints only:✗ example failed: Error POSTing to endpoint: {"error":"Authentication required"} (HTTP 401)- The remote server distinguishes the cases, but the client never surfaces them:
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)- valid key →
200
- Worth noting: a malformed-but-non-empty token was accepted at MCP
initialize(200), so a401is the only clue — which is why a config-level diagnostic matters.
Additional Context
- Reproducible consistently on v2.0.24; survived OpenCode restarts.
- The same mis-write existed in both the global and project config, so it failed in both.
- Related reports, none of which cover this: # OpenCode {env:USERPROFILE} bug - reference and analysis #43691 / {env:} substitution breaks on Windows paths due to unescaped backslashes in JSON #35536 (
{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). - Possible doc drift while I was in here:
/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. - Workaround that resolves it: export the variable and keep
{env:VAR}(per the config docs,{file:...}is also available for secrets kept in a file). - No credentials are included in this report.
Activity
opencode-agent commentedon Oct 7, 2026
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:
{env:VAR}resolves to""with no error, shown with a remote MCPBearer {env:...}headerauth.jsonfallback for a providerapiKey(closed as a duplicate of Missing {env:VAR} in config silently resolves to empty string #33853){env:...}not producing the expected valueoauth: falsereturning 401The 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.Selene0623 commentedon Oct 7, 2026
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:
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.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.{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.
opencode-agent commentedon Oct 7, 2026
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:
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.{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.{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:
{env:VAR}silently resolves to""(same as this issue)As things stand, this issue will still be closed as a duplicate of #33853 at the scheduled time.
opencode-agent commentedon Oct 8, 2026
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:
opencode.log, or open a new issue focused on logging.{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.