Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 11 additions & 0 deletions docs/docs/configure/mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ Run an MCP server as a local subprocess:
}
```

### Environment variable interpolation

Both syntaxes work anywhere in the config:

| Syntax | Example |
|--------|---------|
| `{env:VAR}` | `"API_KEY": "{env:MY_API_KEY}"` |
| `${VAR}` | `"API_KEY": "${MY_API_KEY}"` (shell / dotenv / VS Code style) |

If the variable is not set, it resolves to an empty string. Bare `$VAR` (without braces) is **not** interpolated — use `${VAR}` or `{env:VAR}`.

| Field | Type | Description |
|-------|------|-------------|
| `type` | `"local"` | Local subprocess server |
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,19 @@ export namespace ConfigPaths {
return typeof input === "string" ? path.dirname(input) : input.dir
}

/** Apply {env:VAR} and {file:path} substitutions to config text. */
/** Apply {env:VAR}, ${VAR}, and {file:path} substitutions to config text. */
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
// altimate_change start — accept ${VAR} shell/dotenv syntax as alias for {env:VAR}
// Users arriving from Claude Code / VS Code / dotenv / docker-compose expect this
// convention. Only matches POSIX identifier names to avoid collisions with random
// ${...} content. See issue #635.
text = text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, varName) => {
return process.env[varName] || ""
})
// altimate_change end

const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
Expand Down
75 changes: 75 additions & 0 deletions packages/opencode/test/config/paths-parsetext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,81 @@ describe("ConfigPaths.parseText: {env:VAR} substitution", () => {
})
})

describe("ConfigPaths.parseText: ${VAR} substitution (shell/dotenv alias)", () => {
const envKey = "OPENCODE_TEST_SHELL_SYNTAX_KEY"

beforeEach(() => {
process.env[envKey] = "shell-style-value"
})

afterEach(() => {
delete process.env[envKey]
})

test("substitutes ${VAR} with environment variable value", async () => {
const text = `{"apiKey": "\${${envKey}}"}`
const result = await ConfigPaths.parseText(text, "/fake/config.json")
expect(result).toEqual({ apiKey: "shell-style-value" })
})

test("substitutes to empty string when env var is not set", async () => {
const text = '{"apiKey": "${OPENCODE_TEST_SHELL_NONEXISTENT_XYZ}"}'
const result = await ConfigPaths.parseText(text, "/fake/config.json")
expect(result).toEqual({ apiKey: "" })
})

test("${VAR} and {env:VAR} both work in same config", async () => {
process.env.OPENCODE_TEST_MIXED_A = "alpha"
process.env.OPENCODE_TEST_MIXED_B = "beta"
try {
const text = '{"a": "${OPENCODE_TEST_MIXED_A}", "b": "{env:OPENCODE_TEST_MIXED_B}"}'
const result = await ConfigPaths.parseText(text, "/fake/config.json")
expect(result).toEqual({ a: "alpha", b: "beta" })
} finally {
delete process.env.OPENCODE_TEST_MIXED_A
delete process.env.OPENCODE_TEST_MIXED_B
}
})

test("ignores ${...} with non-identifier names (spaces, special chars)", async () => {
// These should pass through unmodified — not valid POSIX identifiers
const text = '{"a": "${FOO BAR}", "b": "${foo-bar}", "c": "${foo.bar}"}'
const result = await ConfigPaths.parseText(text, "/fake/config.json")
expect(result).toEqual({ a: "${FOO BAR}", b: "${foo-bar}", c: "${foo.bar}" })
})

test("does not match bare $VAR (without braces)", async () => {
process.env.OPENCODE_TEST_BARE = "should-not-match"
try {
const text = '{"value": "$OPENCODE_TEST_BARE"}'
const result = await ConfigPaths.parseText(text, "/fake/config.json")
// Bare $VAR stays literal — only ${VAR} is interpolated
expect(result).toEqual({ value: "$OPENCODE_TEST_BARE" })
} finally {
delete process.env.OPENCODE_TEST_BARE
}
})

test("works inside MCP environment config (issue #635 regression)", async () => {
process.env.OPENCODE_TEST_GITLAB_TOKEN = "glpat-xxxxx"
try {
const text = `{
"mcp": {
"gitlab": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-gitlab"],
"environment": { "GITLAB_TOKEN": "\${OPENCODE_TEST_GITLAB_TOKEN}" }
}
}
}`
const result = await ConfigPaths.parseText(text, "/fake/config.json")
expect(result.mcp.gitlab.environment.GITLAB_TOKEN).toBe("glpat-xxxxx")
} finally {
delete process.env.OPENCODE_TEST_GITLAB_TOKEN
}
})
})

describe("ConfigPaths.parseText: {file:path} substitution", () => {
test("substitutes {file:path} with file contents (trimmed)", async () => {
await using tmp = await tmpdir()
Expand Down
Loading