Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/opencode/src/mcp/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ async function readJsonSafe(filePath: string): Promise<any | undefined> {
} catch {
return undefined
}
// altimate_change start — apply env-var interpolation to external MCP configs
// External configs (Claude Code, Cursor, Copilot, Gemini) may contain ${VAR}
// or {env:VAR} references that need resolving before JSON parsing.
// Uses ConfigPaths.parseText which runs substitute() then parses JSONC.
try {
const { ConfigPaths } = await import("../config/paths")
return await ConfigPaths.parseText(text, filePath, "empty")
} catch {
// Substitution or parse failure — fall back to direct parse without interpolation
log.debug("env-var interpolation failed for external MCP config, falling back to direct parse", { file: filePath })
}
// altimate_change end
const errors: any[] = []
const result = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length > 0) {
Expand Down
27 changes: 26 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,29 @@ import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open"
import { Telemetry } from "@/telemetry"

// altimate_change start — resolve env-var references in MCP environment values
// Handles ${VAR}, ${VAR:-default}, and {env:VAR} patterns that may have survived
// config parsing (e.g. discovered external configs, config updates via updateGlobal).
const ENV_VAR_PATTERN =
/\$\$(\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\})|(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}|\{env:([^}]+)\}/g

export function resolveEnvVars(environment: Record<string, string>): Record<string, string> {
const resolved: Record<string, string> = {}
for (const [key, value] of Object.entries(environment)) {
resolved[key] = value.replace(ENV_VAR_PATTERN, (match, escaped, dollarVar, dollarDefault, braceVar) => {
if (escaped !== undefined) return "$" + escaped
if (dollarVar !== undefined) {
const envValue = process.env[dollarVar]
return envValue !== undefined && envValue !== "" ? envValue : (dollarDefault ?? "")
}
if (braceVar !== undefined) return process.env[braceVar] || ""
return match
})
}
return resolved
}
// altimate_change end

export namespace MCP {
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
Expand Down Expand Up @@ -509,7 +532,9 @@ export namespace MCP {
env: {
...process.env,
...(cmd === "altimate" || cmd === "altimate-code" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
// altimate_change start — resolve ${VAR} / {env:VAR} patterns that survived config parsing
...(mcp.environment ? resolveEnvVars(mcp.environment) : {}),
// altimate_change end
},
})
transport.stderr?.on("data", (chunk: Buffer) => {
Expand Down
209 changes: 209 additions & 0 deletions packages/opencode/test/mcp/env-var-interpolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// altimate_change start — tests for MCP env-var interpolation (closes #656)
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { mkdir, writeFile } from "fs/promises"
import path from "path"
import { resolveEnvVars } from "../../src/mcp"
import { tmpdir } from "../fixture/fixture"

// -------------------------------------------------------------------------
// resolveEnvVars — safety-net resolver at MCP launch site
// -------------------------------------------------------------------------

describe("resolveEnvVars", () => {
const ORIGINAL_ENV = { ...process.env }

beforeEach(() => {
process.env["TEST_TOKEN"] = "secret-123"
process.env["TEST_HOST"] = "gitlab.example.com"
})

afterEach(() => {
process.env = { ...ORIGINAL_ENV }
})

test("resolves ${VAR} syntax", () => {
const result = resolveEnvVars({
API_TOKEN: "${TEST_TOKEN}",
HOST: "${TEST_HOST}",
})
expect(result.API_TOKEN).toBe("secret-123")
expect(result.HOST).toBe("gitlab.example.com")
})

test("resolves {env:VAR} syntax", () => {
const result = resolveEnvVars({
API_TOKEN: "{env:TEST_TOKEN}",
})
expect(result.API_TOKEN).toBe("secret-123")
})

test("resolves ${VAR:-default} with fallback when unset", () => {
const result = resolveEnvVars({
MODE: "${UNSET_VAR:-production}",
})
expect(result.MODE).toBe("production")
})

test("resolves ${VAR:-default} to env value when set", () => {
const result = resolveEnvVars({
TOKEN: "${TEST_TOKEN:-fallback}",
})
expect(result.TOKEN).toBe("secret-123")
})

test("preserves $${VAR} as literal ${VAR}", () => {
const result = resolveEnvVars({
TEMPLATE: "$${TEST_TOKEN}",
})
expect(result.TEMPLATE).toBe("${TEST_TOKEN}")
})

test("resolves unset variable to empty string", () => {
const result = resolveEnvVars({
MISSING: "${COMPLETELY_UNSET_VAR_XYZ}",
})
expect(result.MISSING).toBe("")
})

test("passes through plain values without modification", () => {
const result = resolveEnvVars({
PLAIN: "just-a-string",
URL: "https://gitlab.com/api/v4",
})
expect(result.PLAIN).toBe("just-a-string")
expect(result.URL).toBe("https://gitlab.com/api/v4")
})

test("resolves multiple variables in a single value", () => {
const result = resolveEnvVars({
URL: "https://${TEST_HOST}/api?token=${TEST_TOKEN}",
})
expect(result.URL).toBe("https://gitlab.example.com/api?token=secret-123")
})

test("handles mixed resolved and plain entries", () => {
const result = resolveEnvVars({
TOKEN: "${TEST_TOKEN}",
STATIC_URL: "https://gitlab.com/api/v4",
HOST: "{env:TEST_HOST}",
})
expect(result.TOKEN).toBe("secret-123")
expect(result.STATIC_URL).toBe("https://gitlab.com/api/v4")
expect(result.HOST).toBe("gitlab.example.com")
})

test("does not interpolate bare $VAR without braces", () => {
const result = resolveEnvVars({
TOKEN: "$TEST_TOKEN",
})
expect(result.TOKEN).toBe("$TEST_TOKEN")
})

test("handles empty environment object", () => {
const result = resolveEnvVars({})
expect(result).toEqual({})
})
})

// -------------------------------------------------------------------------
// Discovery integration — env vars in external MCP configs
// -------------------------------------------------------------------------

describe("discoverExternalMcp with env-var interpolation", () => {
const ORIGINAL_ENV = { ...process.env }

beforeEach(() => {
process.env["TEST_MCP_TOKEN"] = "glpat-secret-token"
process.env["TEST_MCP_HOST"] = "https://gitlab.internal.com"
})

afterEach(() => {
process.env = { ...ORIGINAL_ENV }
})

test("resolves ${VAR} in discovered .vscode/mcp.json environment", async () => {
await using tmp = await tmpdir()
const dir = tmp.path
await mkdir(path.join(dir, ".vscode"), { recursive: true })
await writeFile(
path.join(dir, ".vscode/mcp.json"),
JSON.stringify({
servers: {
gitlab: {
command: "node",
args: ["gitlab-server.js"],
env: {
GITLAB_TOKEN: "${TEST_MCP_TOKEN}",
GITLAB_HOST: "${TEST_MCP_HOST}",
STATIC_VALUE: "no-interpolation-needed",
},
},
},
}),
)

const { discoverExternalMcp } = await import("../../src/mcp/discover")
const { servers } = await discoverExternalMcp(dir)

expect(servers["gitlab"]).toBeDefined()
expect(servers["gitlab"].type).toBe("local")
const env = (servers["gitlab"] as any).environment
expect(env.GITLAB_TOKEN).toBe("glpat-secret-token")
expect(env.GITLAB_HOST).toBe("https://gitlab.internal.com")
expect(env.STATIC_VALUE).toBe("no-interpolation-needed")
})

test("resolves {env:VAR} in discovered .cursor/mcp.json environment", async () => {
await using tmp = await tmpdir()
const dir = tmp.path
await mkdir(path.join(dir, ".cursor"), { recursive: true })
await writeFile(
path.join(dir, ".cursor/mcp.json"),
JSON.stringify({
mcpServers: {
"my-tool": {
command: "npx",
args: ["-y", "my-mcp-tool"],
env: {
API_KEY: "{env:TEST_MCP_TOKEN}",
},
},
},
}),
)

const { discoverExternalMcp } = await import("../../src/mcp/discover")
const { servers } = await discoverExternalMcp(dir)

expect(servers["my-tool"]).toBeDefined()
const env = (servers["my-tool"] as any).environment
expect(env.API_KEY).toBe("glpat-secret-token")
})

test("resolves ${VAR:-default} with fallback in discovered config", async () => {
await using tmp = await tmpdir()
const dir = tmp.path
await mkdir(path.join(dir, ".vscode"), { recursive: true })
await writeFile(
path.join(dir, ".vscode/mcp.json"),
JSON.stringify({
servers: {
svc: {
command: "node",
args: ["svc.js"],
env: {
MODE: "${UNSET_VAR_ABC:-production}",
},
},
},
}),
)

const { discoverExternalMcp } = await import("../../src/mcp/discover")
const { servers } = await discoverExternalMcp(dir)

const env = (servers["svc"] as any).environment
expect(env.MODE).toBe("production")
})
})
// altimate_change end
Loading