Skip to content

fix: resolve env-var references in MCP server environment config (closes #656)#666

Merged
anandgupta42 merged 7 commits intoAltimateAI:mainfrom
VJ-yadav:fix/mcp-env-var-interpolation
Apr 10, 2026
Merged

fix: resolve env-var references in MCP server environment config (closes #656)#666
anandgupta42 merged 7 commits intoAltimateAI:mainfrom
VJ-yadav:fix/mcp-env-var-interpolation

Conversation

@VJ-yadav
Copy link
Copy Markdown
Contributor

@VJ-yadav VJ-yadav commented Apr 9, 2026

What does this PR do?

Fixes #656${VAR} and {env:VAR} patterns in MCP server environment config blocks were passed as literal strings to child processes instead of being resolved to actual environment variable values. This caused auth failures for MCP servers expecting tokens (e.g. gitlab-mcp-server receiving literal "${GITLAB_PERSONAL_ACCESS_TOKEN}" instead of the token).

Root Cause

PR #655 added env-var interpolation to the config parsing pipeline (ConfigPaths.parseText → substitute()), but two code paths bypassed it:

  1. MCP launch site (mcp/index.ts:512) — mcp.environment values were spread directly into the child process env without resolving any remaining ${VAR} patterns. If interpolation failed upstream (config updates via updateGlobal(), timing issues), the literal string overwrote the correct value already present in process.env.

  2. External MCP discovery (discover.ts:readJsonSafe()) — configs from Claude Code (.claude.json), Cursor (.cursor/mcp.json), VS Code (.vscode/mcp.json), Copilot, and Gemini were parsed via parseJsonc() directly, completely skipping the substitute() interpolation pipeline.

Changes

File Change
packages/opencode/src/mcp/index.ts Added resolveEnvVars() safety net that resolves ${VAR}, ${VAR:-default}, {env:VAR}, and $${VAR} (escape) patterns in mcp.environment values before spawning the child process
packages/opencode/src/mcp/discover.ts Changed readJsonSafe() to use ConfigPaths.parseText() which runs substitute() on raw text before JSONC parsing, with graceful fallback to direct parse on failure
packages/opencode/test/mcp/env-var-interpolation.test.ts 14 new tests

Supported syntaxes (all three work in MCP environment blocks)

Syntax Behavior Example
${VAR} Resolves to env value, empty string if unset "TOKEN": "${GITLAB_TOKEN}"
${VAR:-default} Resolves to env value, fallback if unset "MODE": "${APP_MODE:-production}"
{env:VAR} Raw text injection (backward compat) "KEY": "{env:API_KEY}"
$${VAR} Escape — preserves literal ${VAR} "TPL": "$${VAR}""${VAR}"

Test plan

  • 11 unit tests for resolveEnvVars: ${VAR}, {env:VAR}, defaults, escapes, unset vars, plain passthrough, multiple vars in one value, mixed entries, bare $VAR not matched, empty object
  • 3 integration tests for discovery: .vscode/mcp.json with ${VAR}, .cursor/mcp.json with {env:VAR}, fallback defaults
  • 18 existing discover.test.ts tests still pass
  • 34 existing paths-parsetext.test.ts tests still pass
  • TypeScript typecheck clean (only pre-existing ClickHouse driver error)

Checklist

  • No new dependencies
  • No any types in new code
  • Follows altimate_change marker convention
  • Regex pattern matches the one in paths.ts:substitute() for consistency
  • Graceful fallback: if ConfigPaths.parseText() fails in discovery, falls back to direct parse (no regression for malformed external configs)

Fixes #656


Summary by cubic

Fixes env-var interpolation in MCP server environments so ${VAR}, ${VAR:-default}, and {env:VAR} resolve before spawn. Also interpolates external MCP configs (Cursor, VS Code, Claude Code, Copilot, Gemini). Fixes #656.

  • Bug Fixes

    • Resolve ${VAR}, ${VAR:-default}, {env:VAR}; support $${VAR} escape.
    • Run resolver at launch so mcp.environment resolves before child spawn.
    • Interpolate external configs via ConfigPaths.parseText() with fallback.
    • Export resolveEnvVars; tests cover syntax, defaults, escapes, and external discovery.
  • Refactors

    • Tests import the production resolveEnvVars and use a shared tmpdir fixture for cleanup.

Written for commit 5e26db6. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • MCP config files and local MCP environment values now support environment-variable interpolation using ${VAR}, ${VAR:-default}, and {env:VAR}; escaped $$ sequences are preserved. External MCP JSON configs are attempted to be interpolated first, with a fallback to the previous JSONC parsing behavior if interpolation fails.
  • Tests

    • Added unit and integration tests validating interpolation behavior and discovery of external MCP configs.

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 9, 2026

📝 Walkthrough

Walkthrough

Attempts env-var interpolation when reading external MCP JSON via ConfigPaths.parseText(...); on parse failure it falls back to JSONC parsing. Adds an exported resolveEnvVars() and applies it to MCP environment. Includes unit and integration tests for interpolation patterns and fallbacks.

Changes

Cohort / File(s) Summary
MCP discovery parsing
packages/opencode/src/mcp/discover.ts
readJsonSafe() now dynamically imports ConfigPaths and calls ConfigPaths.parseText(text, filePath, "empty") to interpolate env placeholders; logs debug and falls back to parseJsonc(...) on error.
Runtime env resolution
packages/opencode/src/mcp/index.ts
Added exported resolveEnvVars(environment: Record<string,string>). When constructing the local MCP StdioClientTransport, mcp.environment is passed through resolveEnvVars (or omitted if falsy) and merged with process.env and a BUN_BE_BUN override.
Tests
packages/opencode/test/mcp/env-var-interpolation.test.ts
New Bun test file covering resolveEnvVars unit cases and integration tests for discoverExternalMcp(...) with temp .vscode/mcp.json / .cursor/mcp.json, asserting ${VAR}, ${VAR:-default}, {env:VAR}, escaped $${VAR}, and cleanup of process.env.

Sequence Diagram(s)

sequenceDiagram
    participant File as External MCP File
    participant Discover as discover.ts (readJsonSafe)
    participant ConfigPaths as ConfigPaths.parseText
    participant Index as mcp/index.ts (resolveEnvVars)
    participant Transport as StdioClientTransport

    File->>Discover: provide raw config text
    Discover->>ConfigPaths: parseText(text, filePath, "empty")
    alt parseText succeeds
        ConfigPaths-->>Discover: parsed + interpolated MCP definition
        Discover-->>Index: return MCP definition
        Index->>Index: resolveEnvVars(environment)
        Index-->>Transport: pass resolved environment
    else parseText fails
        ConfigPaths-->>Discover: throw/error
        Discover->>Discover: fallback to parseJsonc(text, errors, {allowTrailingComma:true})
        Discover-->>Index: return parsed MCP definition (uninterpolated if errors)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐇 I nibble at config strings by night,

${VAR} glows and hops into sight,
Defaults leap in when none are found,
Escaped dollars stay safe and sound,
Hooray — the MCPs all run bright!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary fix: resolving env-var references in MCP server environment configuration, and explicitly references the issue being closed.
Linked Issues check ✅ Passed The PR fully addresses issue #656 by implementing env-var interpolation in MCP server environment configs, supporting ${VAR}, ${VAR:-default}, and {env:VAR} syntaxes, applying fixes to both launch sites and external discovery paths, and providing comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing env-var interpolation in MCP environments: resolveEnvVars() helper, discover.ts parsing updates, and comprehensive test coverage. No unrelated modifications detected.
Description check ✅ Passed PR description is comprehensive, well-structured, and includes required template sections with detailed explanations of changes, test coverage, and a complete checklist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@VJ-yadav VJ-yadav force-pushed the fix/mcp-env-var-interpolation branch from f77c8bd to 8e1671f Compare April 9, 2026 03:19
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/test/mcp/env-var-interpolation.test.ts">

<violation number="1" location="packages/opencode/test/mcp/env-var-interpolation.test.ts:19">
P1: These unit tests exercise a **copy** of `resolveEnvVars` pasted into the test file, not the actual production function from `src/mcp/index.ts` (which is not exported). If the production regex or logic changes, these 11 tests will still pass against the stale local copy, giving false confidence.

Export `resolveEnvVars` (and optionally `ENV_VAR_PATTERN`) from the source module and import it here instead of duplicating the implementation.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

@VJ-yadav
Copy link
Copy Markdown
Contributor Author

VJ-yadav commented Apr 9, 2026

Testing: MCP env-var interpolation fix

The problem: When users configure MCP servers with environment variable references like "GITLAB_TOKEN": "${GITLAB_TOKEN}", the MCP server receives the literal string "${GITLAB_TOKEN}" instead of the actual token value, causing auth failures (HTTP 401).

Why it happens: PR #655 (v0.5.19) added ${VAR} interpolation to the config parser, but two code paths bypass it:

  • The MCP launch site spreads mcp.environment into the child process without resolving remaining ${VAR} patterns
  • External MCP discovery (Claude Code, Cursor, Copilot, Gemini configs) parses JSON directly via parseJsonc(), skipping the substitute() pipeline entirely

The fix:

  • resolveEnvVars() safety net at the MCP launch site — catches all code paths regardless of how the config was loaded
  • readJsonSafe() in discovery now uses ConfigPaths.parseText() for interpolation before parsing, with graceful fallback

14 new tests, all passing (importing from production source, not duplicated):

Screenshot 2026-04-08 at 11 27 40 PM

Covers all syntaxes: ${VAR}, ${VAR:-default}, {env:VAR}, $${VAR} escape, unset vars, plain passthrough, multiple vars in one value, bare $VAR not matched, and 3 discovery integration tests with .vscode/mcp.json and .cursor/mcp.json.

Also addressed cubic-dev-ai review: resolveEnvVars is now exported from src/mcp/index.ts and imported in the test file instead of duplicating the implementation.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/opencode/test/mcp/env-var-interpolation.test.ts (2)

16-33: Consider testing the actual implementation rather than duplicating it.

The test file duplicates ENV_VAR_PATTERN and resolveEnvVars from index.ts. If the source implementation changes (e.g., bug fix or new pattern), these tests will still pass against the stale duplicated code, creating a false sense of coverage.

Consider either:

  1. Exporting resolveEnvVars from index.ts (or a shared utility) and importing it here
  2. Testing indirectly through the MCP launch flow (integration-style)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/test/mcp/env-var-interpolation.test.ts` around lines 16 -
33, The test duplicates ENV_VAR_PATTERN and resolveEnvVars; instead export the
real resolveEnvVars (and/or ENV_VAR_PATTERN) from index.ts or a shared util and
import it into packages/opencode/test/mcp/env-var-interpolation.test.ts so the
test exercises the actual implementation; if resolveEnvVars is not exported, add
an export (named) in the source module (index.ts or the appropriate utility
module) and update the test to import and use that exported resolveEnvVars
rather than the duplicated copy, or alternatively replace this unit test with an
integration-style test that exercises the MCP launch flow which uses
resolveEnvVars.

139-148: Use tmpdir() from fixture/fixture.ts per coding guidelines.

The test uses manual mkdtemp/rm for temporary directories. As per coding guidelines, tests should use the tmpdir function with await using syntax for automatic cleanup.

♻️ Suggested refactor using tmpdir()
+import { tmpdir as createTmpDir } from "../fixture/fixture"
+
 describe("discoverExternalMcp with env-var interpolation", () => {
-  let tempDir: string
   const ORIGINAL_ENV = { ...process.env }
 
-  beforeEach(async () => {
-    tempDir = await mkdtemp(path.join(tmpdir(), "mcp-envvar-"))
+  test("resolves ${VAR} in discovered .vscode/mcp.json environment", async () => {
+    await using tmp = await createTmpDir()
     process.env["TEST_MCP_TOKEN"] = "glpat-secret-token"
     process.env["TEST_MCP_HOST"] = "https://gitlab.internal.com"
-  })
-
-  afterEach(async () => {
-    process.env = { ...ORIGINAL_ENV }
-    await rm(tempDir, { recursive: true, force: true })
-  })
-
-  test("resolves ${VAR} in discovered .vscode/mcp.json environment", async () => {
-    await mkdir(path.join(tempDir, ".vscode"), { recursive: true })
+    
+    await mkdir(path.join(tmp.path, ".vscode"), { recursive: true })
     // ... rest of test using tmp.path instead of tempDir

As per coding guidelines: "Use the tmpdir function from fixture/fixture.ts to create temporary directories for tests with automatic cleanup" and "Always use await using syntax with tmpdir() for automatic cleanup when the variable goes out of scope".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/test/mcp/env-var-interpolation.test.ts` around lines 139 -
148, Replace the manual mkdtemp/rm setup in the test (the beforeEach/afterEach
that sets tempDir and calls mkdtemp and rm) with the tmpdir helper from
fixture/fixture.ts and use it via "await using" so the temporary directory is
created and automatically cleaned up; update the test to remove manual
process.env restore in afterEach if handled elsewhere, and reference the same
tempDir variable name in the test body but obtain it from await using tmpdir()
instead of mkdtemp, removing the explicit rm call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/opencode/test/mcp/env-var-interpolation.test.ts`:
- Around line 16-33: The test duplicates ENV_VAR_PATTERN and resolveEnvVars;
instead export the real resolveEnvVars (and/or ENV_VAR_PATTERN) from index.ts or
a shared util and import it into
packages/opencode/test/mcp/env-var-interpolation.test.ts so the test exercises
the actual implementation; if resolveEnvVars is not exported, add an export
(named) in the source module (index.ts or the appropriate utility module) and
update the test to import and use that exported resolveEnvVars rather than the
duplicated copy, or alternatively replace this unit test with an
integration-style test that exercises the MCP launch flow which uses
resolveEnvVars.
- Around line 139-148: Replace the manual mkdtemp/rm setup in the test (the
beforeEach/afterEach that sets tempDir and calls mkdtemp and rm) with the tmpdir
helper from fixture/fixture.ts and use it via "await using" so the temporary
directory is created and automatically cleaned up; update the test to remove
manual process.env restore in afterEach if handled elsewhere, and reference the
same tempDir variable name in the test body but obtain it from await using
tmpdir() instead of mkdtemp, removing the explicit rm call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c57bcd95-245a-4e8f-a47f-0a3996dada1a

📥 Commits

Reviewing files that changed from the base of the PR and between dafd16a and 8e1671f.

📒 Files selected for processing (3)
  • packages/opencode/src/mcp/discover.ts
  • packages/opencode/src/mcp/index.ts
  • packages/opencode/test/mcp/env-var-interpolation.test.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/opencode/test/mcp/env-var-interpolation.test.ts (1)

151-152: Avoid any casts when asserting discovered server environments.

These casts can be replaced with a small typed helper/assertion to keep test type-safety intact.

Typed alternative (example)
-    const env = (servers["gitlab"] as any).environment
+    const env = (servers["gitlab"] as { environment: Record<string, string> }).environment

Also applies to: 178-179, 202-203

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/test/mcp/env-var-interpolation.test.ts` around lines 151 -
152, Replace the unsafe `(servers["gitlab"] as any).environment` casts with a
small typed helper to preserve type-safety: add a helper like
`getServerEnv(servers, key)` (or `assertServerHasEnvironment`) that accepts the
`servers` map and a server key, narrows/validates the server type, and returns
the typed `environment` object; then use that helper in the three places where
`as any` is used (the assertions currently using `(servers["gitlab"] as
any).environment` and the similar occurrences around lines 178-179 and 202-203)
so tests assert `env.GITLAB_TOKEN` (and other env keys) against a correctly
typed environment rather than using `any`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/opencode/test/mcp/env-var-interpolation.test.ts`:
- Around line 3-5: Replace the manual temp-dir lifecycle (calls to mkdtemp, rm,
mkdir, writeFile) in env-var-interpolation.test.ts with the shared tmpdir
fixture: import tmpdir from "fixture/fixture.ts" and in each test use "await
using const dir = tmpdir()" to obtain a temporary directory (then create files
inside dir.path). Remove explicit mkdtemp/rm cleanup and any manual tmpdir
string handling; update tests that currently perform temp setup (the block
around the existing mkdtemp/mkdir/writeFile usage and the similar logic
referenced later) to write files into dir.path and rely on automatic cleanup
when the await-using scoped variable is released.

---

Nitpick comments:
In `@packages/opencode/test/mcp/env-var-interpolation.test.ts`:
- Around line 151-152: Replace the unsafe `(servers["gitlab"] as
any).environment` casts with a small typed helper to preserve type-safety: add a
helper like `getServerEnv(servers, key)` (or `assertServerHasEnvironment`) that
accepts the `servers` map and a server key, narrows/validates the server type,
and returns the typed `environment` object; then use that helper in the three
places where `as any` is used (the assertions currently using
`(servers["gitlab"] as any).environment` and the similar occurrences around
lines 178-179 and 202-203) so tests assert `env.GITLAB_TOKEN` (and other env
keys) against a correctly typed environment rather than using `any`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 469399ce-ff20-4305-9eac-8752b3d75c9f

📥 Commits

Reviewing files that changed from the base of the PR and between 8e1671f and a396557.

📒 Files selected for processing (2)
  • packages/opencode/src/mcp/index.ts
  • packages/opencode/test/mcp/env-var-interpolation.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/opencode/src/mcp/index.ts

@anandgupta42
Copy link
Copy Markdown
Contributor

Thanks @VJ-yadav . Can you please fix the CI-issue?

VJ-yadav added 3 commits April 9, 2026 11:18
 AltimateAI#656)

${VAR}, ${VAR:-default}, and {env:VAR} patterns in MCP server environment
blocks were passed as literal strings to child processes, causing auth
failures for tools like gitlab-mcp-server.

Two gaps fixed:
- mcp/index.ts: add resolveEnvVars() safety net at launch site that
  resolves env-var patterns in mcp.environment before spawning
- discover.ts: use ConfigPaths.parseText() in readJsonSafe() so
  external MCP configs (Claude Code, Cursor, Copilot, Gemini) get
  interpolation before JSON parsing

14 new tests covering both ${VAR} and {env:VAR} syntax, defaults,
escapes, and discovery integration.
Addresses cubic-dev-ai review: tests were exercising a copy of the
function, not the production code. Now imports from src/mcp directly.
Addresses coderabbitai review: switched discovery integration tests to
use await using tmpdir() from fixture/fixture.ts for automatic cleanup,
matching repository test standards.
@VJ-yadav VJ-yadav force-pushed the fix/mcp-env-var-interpolation branch from 58b27a9 to b8ab0d9 Compare April 9, 2026 15:18
@VJ-yadav
Copy link
Copy Markdown
Contributor Author

VJ-yadav commented Apr 9, 2026

@anandgupta42 Rebased onto latest main (v0.5.20) and CI re-ran — same 7 failures, all pre-existing infra flakes unrelated to this PR:

  • worker.test.ts:67 — TUI worker type assertion
  • dbt-first-execution.test.ts — dispatcher call failures (4 tests)
  • tracing-adversarial-2.test.ts:588 — fetch error
  • oauth-callback.ts:188 — OAuth browser test
  • ripgrep.ts:238 — file listing

None of our changed files (mcp/index.ts, mcp/discover.ts, test/mcp/env-var-interpolation.test.ts) are involved. Same failures showing on PR #649 and other open PRs in the batch. 7020 tests pass, only these 7 flaky ones fail.

@anandgupta42
Copy link
Copy Markdown
Contributor

THanks @VJ-yadav

@anandgupta42 anandgupta42 merged commit f030bf8 into AltimateAI:main Apr 10, 2026
13 checks passed
@anandgupta42
Copy link
Copy Markdown
Contributor

Multi-Model Code Review — REQUEST CHANGES

Thanks for tackling #656 — the underlying bug is real and the two-layer approach is directionally right. Reviewed by a 9-participant panel (Claude + 8 external models: GPT 5.4, Gemini 3.1 Pro, Kimi K2.5, MiniMax M2.7, GLM-5, Qwen 3.6 Plus, DeepSeek V3, MiMo-V2-Pro). Consensus verdict: REQUEST CHANGES — 1 Critical, 4 Major, 5 Minor, 3 NIT.

The big one is a double-interpolation regression that breaks $${VAR} escaping end-to-end and opens a minor variable-chain injection vector. Details below.


Critical

1. Double interpolation — $${VAR} escape broken + variable-chain injection

Location: packages/opencode/src/mcp/discover.ts:117-130 + packages/opencode/src/mcp/index.ts:28-50 (call site around line 534)

discoverExternalMcp now runs configs through ConfigPaths.parseText → substitute() (Layer 1), and then at spawn time resolveEnvVars() runs again on the already-substituted mcp.environment (Layer 2). Two independent failures:

(a) $${VAR} escape no longer survives end-to-end. Layer 1 correctly turns $${VAR} into the literal ${VAR}. Layer 2 then sees that literal ${VAR} (negative lookbehind passes — there's no preceding $ anymore) and resolves it to the live env value. After this PR there is no syntax a user can write to pass a literal ${SOMETHING} string to an MCP server. Regression from pre-PR behavior for opencode.json configs.

(b) Variable-chain injection. If any env var in the shell has a value that looks like ${OTHER}, a config referencing that first var silently expands to the second one:

EVIL_VAR="${SECRET}"   # in shell (common in Bash PS1, GH Actions leftovers, etc.)
config: { environment: { X: "${EVIL_VAR}" } }
Layer 1: X = "${SECRET}"        (literal, as intended)
Layer 2: X = <actual SECRET value>   ← wrong

Gemini independently confirmed the bug with a running POC test.

Fix — pick one layer:

  1. Preferred: scope Layer 1 to env blocks only (apply substitution inside addServersFromFile/transform, not in readJsonSafe over whole JSON text) and drop Layer 2 entirely. This also fixes Major chore(deps): Bump @gitlab/gitlab-ai-provider from 3.6.0 to 4.1.0 #5 below.
  2. Alternative: track provenance on Config.Mcp entries and run resolveEnvVars only on those that bypassed ConfigPaths.parseText() (e.g., added via updateGlobal or at runtime).

Please gate the fix on a regression test that asserts $${VAR} survives to the child process as a literal ${VAR}, plus a test for the chain-injection vector.


Major

2. Regex/resolution logic duplicated from ConfigPaths.substitute() with silent divergence

Location: packages/opencode/src/mcp/index.ts:29-48 vs packages/opencode/src/config/paths.ts:104-131

ENV_VAR_PATTERN is a character-for-character copy of the regex inside substitute(). The bodies are near-identical but diverge in three ways:

Behavior substitute() resolveEnvVars()
Output encoding JSON-escaped (raw JSON text) raw (parsed strings)
Telemetry emits config_env_interpolation counters none
Unresolved tracking counts dollarUnresolved etc. silent

The raw-vs-JSON-escape split is justified by the call sites, but there's no shared source of truth. The next regex fix will be applied to only one copy.

Fix: Extract a shared core in config/paths.ts:

export function resolveEnvVarPatterns(
  input: string,
  opts: { mode: "json-escaped" | "raw"; onUnresolved?: (name: string) => void }
): string

Both substitute() and resolveEnvVars() delegate to it — single regex, single resolution path.

3. Silent empty-string substitution on unresolved ${VAR}

Location: packages/opencode/src/mcp/index.ts:38-40

const envValue = process.env[dollarVar]
return envValue !== undefined && envValue !== "" ? envValue : (dollarDefault ?? "")

An unset ${GITLAB_TOKEN} with no default becomes "". The MCP server then launches with GITLAB_TOKEN="" and fails with an opaque auth error. No log, no warning, no telemetry. Makes diagnosing "why isn't my MCP server connecting?" needlessly painful.

Fix: Accept a serverName parameter and emit per unresolved reference:

log.warn("MCP env var unresolved, substituting empty string", { server: serverName, var: dollarVar })

4. Fallback catch {} in readJsonSafe swallows the error

Location: packages/opencode/src/mcp/discover.ts:124-127

} catch {
  log.debug("env-var interpolation failed for external MCP config, falling back to direct parse", { file: filePath })
}

ConfigPaths.parseText() can throw JsonError, InvalidError, or fail during substitution. All swallowed without the error message, and log.debug is invisible in normal operation.

Fix:

} catch (error) {
  log.warn("env-var interpolation failed for external MCP config, falling back to raw parse", {
    file: filePath,
    error: error instanceof Error ? error.message : String(error),
  })
}

5. Layer 1 substitution operates on whole JSON text, not just env fields

Location: packages/opencode/src/mcp/discover.ts:117-130

ConfigPaths.parseText runs substitute() over the full external-config JSON text — every ${VAR} anywhere in the file is resolved (command strings, URLs, args, server names), not just values inside env blocks. Foreign configs (Cursor, Copilot, Gemini) may use custom templating conventions that get clobbered.

Fix: Limit interpolation to env blocks inside addServersFromFile/transform rather than wholesale text substitution in readJsonSafe. Implementing this also cleanly eliminates Critical #1 without needing provenance tracking.


Minor

6. resolveEnvVars exported at top level instead of under MCP namespace

packages/opencode/src/mcp/index.ts:34 — every other symbol in the file is nested under export namespace MCP { ... }. Move inside the namespace and update the test import to MCP.resolveEnvVars(...).

7. mcp.headers not resolved — Authorization: Bearer ${TOKEN} still leaks through as literal

PR fixes mcp.environment but not mcp.headers. Remote MCP configs commonly put tokens in Authorization headers; the discovery sources you target (.vscode/mcp.json, .cursor/mcp.json) all support a headers field. A config with headers: { Authorization: "Bearer ${GITLAB_TOKEN}" } still hits the original bug.

Fix: Apply the resolver (single implementation per #2) to mcp.headers as well. Add a test.

8. {env:VAR} silently does not support :-default

packages/opencode/src/mcp/index.ts:31, 40-42 — the regex branch \{env:([^}]+)\} greedily captures :-default as part of the variable name. A user writing {env:VAR:-default} gets process.env["VAR:-default"], which never matches. The inline comment is also misleading about the supported syntax.

Fix: Either add explicit support (\{env:([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}) or add a test asserting the current non-support and correct the comment.

9. Missing integration tests for .copilot, .gemini, ~/.claude.json discovery sources

The integration tests only exercise .vscode/mcp.json and .cursor/mcp.json. If discovery for the other sources differs in how env blocks are located, the fix may silently not apply there.

Fix: Add tests for .copilot/mcp.json, .gemini/settings.json, and both the project-scoped and global ~/.claude.json MCP paths.

10. No test for readJsonSafe fallback path

No test covers the case where ConfigPaths.parseText() throws and execution falls through to raw parseJsonc. This is load-bearing for error resilience and the site of #4. Worth adding a test that triggers InvalidError inside parseText (e.g., a {file:missing} reference) and asserts the fallback still returns a parsed server entry.


NIT

11. Dynamic import("../config/paths") inside readJsonSafe runs per file

packages/opencode/src/mcp/discover.ts:121 — presumably avoiding a circular dep. If none exists, switch to a top-of-file static import. Otherwise hoist to module scope so it resolves once per process.

12. Test teardown process.env = { ...ORIGINAL_ENV } reassigns the object reference

packages/opencode/test/mcp/env-var-interpolation.test.ts:20, 97 — replacing the reference can desync from the native OS env backing store and leak keys into later tests. Prefer delete process.env[key] for each key set in beforeEach.

13. ENV_VAR_PATTERN at module scope with /g flag

packages/opencode/src/mcp/index.ts:31 — mutable lastIndex state, safe only with .replace(). Current single call-site is fine, but a future exec()/.test() caller would silently break. Document the contract or recreate the regex inside the function.


Positive observations

  • Two-layer defense concept is architecturally sound. The bug report correctly identified multiple code paths; the instinct to cover all of them is right. The issue is the layers aren't isolated by provenance.
  • $${VAR} literal escape is implemented and tested in isolation — many implementations forget the escape.
  • Negative lookbehind (?<!\$) correctly prevents $$${VAR} overmatching at the regex level.
  • Fall-through behavior on interpolation failure is conservative — the config still parses, even if without env resolution.
  • altimate_change markers are correctly placed on all three modified files, satisfying Marker Guard CI.
  • mcp.environment ? resolveEnvVars(...) : {} handles undefined correctly — the previous bare spread would have thrown on null.
  • Test suite organization (unit direct + integration via discoverExternalMcp) is good structure.
  • Tests import from the production module rather than re-implementing the helper — avoids stale-copy drift.

Missing tests to add

  1. End-to-end $${VAR} preservation — load opencode.json with $${VAR} under mcp.environment, assert child process env receives the literal ${VAR}. Regression gate for Critical chore(deps): Bump minimatch from 10.0.3 to 10.2.3 in /packages/altimate-code #1.
  2. Variable-chain injectionEVIL_VAR="${SECRET}", config references ${EVIL_VAR}, assert result is the literal ${SECRET}, not SECRET's value.
  3. .copilot/mcp.json, .gemini/settings.json, ~/.claude.json project + global paths — see chore(deps-dev): Bump @types/yargs from 17.0.33 to 17.0.35 #9.
  4. readJsonSafe fallback path — see chore(deps): Bump @parcel/watcher from 2.5.1 to 2.5.6 #10.
  5. mcp.headers env resolution — see chore(deps-dev): Bump @babel/core from 7.28.4 to 7.29.0 #7.
  6. Unresolved ${VAR} emits a warning — once Open-source readiness: engine bootstrap, branding, release infra #3 is implemented.
  7. Env var value containing $ (non-template) — e.g., password p$ssword. Assert it survives unchanged.

Rejected findings (for transparency)

  • "Dead code in readJsonSafe — fallback unreachable" was flagged CRITICAL by 2 models initially, both accepted the rejection at convergence. The return undefined lives inside the catch of the outer readText try/catch. On successful read, execution falls through to the new altimate_change block, which is fully reachable. Both branches work as intended.
  • "Test file copy-pastes ENV_VAR_PATTERN and resolveEnvVars" — flagged as a concern, confirmed not applicable to PR head. Tests import from the production module.

Recommended minimum fix set before merge

  1. Fix Critical chore(deps): Bump minimatch from 10.0.3 to 10.2.3 in /packages/altimate-code #1 (pick one resolution layer)
  2. Address Majors chore(deps): Bump @modelcontextprotocol/sdk from 1.25.2 to 1.26.0 in /packages/altimate-code #2, Open-source readiness: engine bootstrap, branding, release infra #3, chore(deps): Bump @ai-sdk/xai from 2.0.51 to 3.0.60 #4
  3. Add the Critical chore(deps): Bump minimatch from 10.0.3 to 10.2.3 in /packages/altimate-code #1 regression test and chain-injection test
  4. Preferred direction: implement Major chore(deps): Bump @gitlab/gitlab-ai-provider from 3.6.0 to 4.1.0 #5 (scope Layer 1 to env blocks), which cleanly eliminates the double-pass without requiring provenance tracking

Minors #6#10 can be follow-ups if you prefer, but #7 (headers) is a genuine gap in the stated fix scope for #656 and probably should land with the same PR.

Happy to re-review once changes are in.


Review methodology

Reviewed by 9 participants: Claude Opus 4.6 + 8 external models via OpenRouter (GPT 5.4, Gemini 3.1 Pro, Kimi K2.5, MiniMax M2.7, GLM-5, Qwen 3.6 Plus, DeepSeek V3, MiMo-V2-Pro). Each model independently reviewed the PR with full codebase access before findings were synthesized and sent back for a convergence round. 5/8 external models responded in convergence (above quorum=2), all confirming CHANGES NEEDED with no accuracy objections.

Severity Count Models that flagged (examples)
Critical #1 (double interpolation) 7/8 consensus GPT 5.4, Gemini (POC), GLM-5, MiniMax, Kimi, DeepSeek, Qwen
Major #2 (regex duplication) 8/8 consensus all
Major #3 (silent empty-string) 5 Claude, GLM-5, MiniMax, DeepSeek, Qwen
Major #4 (swallowed error) 4 Claude, GLM-5, DeepSeek, Gemini
Major #5 (whole-text substitution) 1 unique DeepSeek
Minor #7 (headers not resolved) 1 unique Qwen

anandgupta42 added a commit that referenced this pull request Apr 11, 2026
Follow-up to #666. The two-layer design (parse-time `ConfigPaths.parseText` in
`readJsonSafe` + launch-time `resolveEnvVars` in `mcp/index.ts`) created two
correctness bugs:

1. `$${VAR}` escape broken end-to-end: Layer 1 turned `$${VAR}` into literal
   `${VAR}`; Layer 2 then re-resolved it to the live env value. No syntax
   remained for passing a literal `${SOMETHING}` to an MCP server.

2. Variable-chain injection: with `EVIL_VAR="${SECRET}"` in the shell and a
   config referencing `${EVIL_VAR}`, Layer 1 produced the literal `"${SECRET}"`
   which Layer 2 then resolved to the actual secret — exfiltrating a variable
   the config never mentioned.

The fix collapses to a single resolution point at config load time, scoped to
the fields that need it.

Changes:
- `config/paths.ts`: export shared `ENV_VAR_PATTERN`, `resolveEnvVarsInString()`,
  and `newEnvSubstitutionStats()`. Internal `substitute()` reuses the same
  regex grammar — no more duplicate implementation in `mcp/index.ts`.
- `mcp/discover.ts`: revert the `ConfigPaths.parseText` call in `readJsonSafe`
  (was substituting across the whole JSON text, not just env blocks). Add
  `resolveServerEnvVars()` helper called from `transform()`, scoped to `env`
  and `headers` values only. Emits `log.warn` for unresolved vars with server
  and source context.
- `mcp/index.ts`: remove `resolveEnvVars()` and `ENV_VAR_PATTERN` entirely. The
  stdio launch site now uses `mcp.environment` directly — single resolution
  point already ran at config load time.
- `test/mcp/env-var-interpolation.test.ts`: switch to
  `ConfigPaths.resolveEnvVarsInString`. Add 5 regression tests: single-pass
  no-chain; `$${VAR}` survives end-to-end; chain injection blocked; `${VAR}`
  resolved in remote server `headers`; `${VAR}` in command args / URL is NOT
  resolved (scope check). Also fixes fragile `process.env = {...}` teardown.

Addresses the PR #666 consensus code review:
- Critical: double interpolation (resolution collapsed to one pass)
- Major: regex duplication (single source in `config/paths.ts`)
- Major: silent empty-string on unresolved vars (now `log.warn` with context)
- Major: whole-JSON-text scope (narrowed to `env` and `headers` only)
- Major: swallowed `catch {}` in `readJsonSafe` (removed with the whole block)
- Minor: `resolveEnvVars` top-level export (removed)
- Minor: `mcp.headers` not resolved (now resolved)

Test results: 36/36 tests pass in `test/mcp/env-var-interpolation.test.ts` +
`test/mcp/discover.test.ts`. Marker guard clean. No new TS errors in touched
files.

Relates to #656 and #666.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

${VAR} env-var interpolation in MCP server config still not working on v0.5.19

2 participants