Skip to content

config: unresolved {env:VAR} silently becomes an empty string (remote MCP fails with an opaque 401) #53784

Description

@Selene0623

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

  1. In any config layer (~/.config/opencode/opencode.jsonc or a project opencode.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}" }
    }}}
  2. Ensure EXAMPLE_KEY is not set anywhere (env, shell rc, environment.d, project .env).
  3. 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 list prints 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 a 401 is the only clue — which is why a config-level diagnostic matters.

Additional Context

Activity

  1. opencode-agent commented on Oct 7, 2026

    @opencode-agent
    Contributor

    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 MCP Authorization header 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() in packages/core/src/config/variable.ts still turns an unset variable into "". The missing option you found is only used for {file:...} references (a missing file is an error unless missing is "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:

    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> with enabled vs mcp.servers.<name> with disabled) is a separate problem, so a separate issue for it would be welcome.

  2. Selene0623 commented on Oct 7, 2026

    @Selene0623
    Author

    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 -S confirms it was never committed, and the committed version was the correct {env:FAL_KEY}), with .gitignore not covering the file. One git commit -a would 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:

    1. 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 existing missing policy would do this almost for free.
    2. Redact credential-shaped values in diagnostics and logs. Please make sure opencode mcp list, mcp auth list, debug config and the log writer never print resolved Authorization headers or values matching *_KEY / *_TOKEN / *_SECRET / Bearer …. Observed here: ~/.local/share/opencode/log/opencode.log stores tool commands verbatim (message="spawning process" … args="[…]"), and a diagnostic curl that 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.
    3. 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.

  3. opencode-agent commented on Oct 7, 2026

    @opencode-agent
    Contributor

    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:

    1. 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() in packages/core/src/config/variable.ts replaces an unset {env:VAR} with "". Its missing option 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.
    2. 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 a curl that carried the key, are written to opencode.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.
    3. Show {file:...} for secrets on the MCP docs page: this is a docs change and fits naturally with the mcp.<name> / mcp.servers.<name> mismatch you mentioned. Both could go in one docs issue.

    Related issues, ordered by relevance:

    As things stand, this issue will still be closed as a duplicate of #33853 at the scheduled time.

  4. opencode-agent commented on Oct 8, 2026

    @opencode-agent
    Contributor

    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:

    Thanks for the thorough report. If this turns out to be different from #33853, reply here and we can reopen it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions