detached 9be7bb7

Expand Claude Code-style environment references in MCP configs

commit
9be7bb76fbbbb169dda58dc753283859d2bae773
author
Maxime Hönig · · 2026-09-14 12:11 UTC
parent
c4c30ff
signature
unsigned
3 files · +103 -1
M examples/extensions/mcp-bridge/README.md +10 -0
@@ -19,6 +19,16 @@ This extension reads MCP server configurations from standard locations (same for
1919 - **Slash commands** — `/mcp` to check status, start/stop/restart servers
2020 - **Better error messages** — context-aware errors with actionable suggestions
2121
22+## Environment variables
23+
24+Like [Claude Code](https://code.claude.com/docs/en/mcp#environment-variable-expansion-in-mcp-json), the bridge expands `${VAR}` and `${VAR:-default}` in `command`, `args`, `env` values, `url`, and `headers` values. This is client configuration compatibility, not a requirement of the MCP protocol.
25+
26+```json
27+{"mcpServers":{"api":{"transport":"streamable-http","url":"${API_BASE_URL:-https://api.example.com}/mcp","headers":{"Authorization":"Bearer ${API_KEY}"}}}}
28+```
29+
30+Expansion uses the bridge process environment after global/project configurations are merged. Defaults apply to unset variables; an explicitly empty variable stays empty. Values are expanded once, without shell execution, bare `$VAR` expansion, nested defaults, or references to sibling `env` entries. Config files are not rewritten. A missing variable without a default disables that server and reports the server, field, and variable name, never the field value; other valid servers remain available.
31+
2232 ## Quick Start
2333
2434 1. **Build the extension:**
@@ -19,6 +19,16 @@ This extension reads MCP server configurations from standard locations (same for
19 - **Slash commands** — `/mcp` to check status, start/stop/restart servers19 - **Slash commands** — `/mcp` to check status, start/stop/restart servers
20 - **Better error messages** — context-aware errors with actionable suggestions20 - **Better error messages** — context-aware errors with actionable suggestions
21 21
22+## Environment variables
23+
24+Like [Claude Code](https://code.claude.com/docs/en/mcp#environment-variable-expansion-in-mcp-json), the bridge expands `${VAR}` and `${VAR:-default}` in `command`, `args`, `env` values, `url`, and `headers` values. This is client configuration compatibility, not a requirement of the MCP protocol.
25+
26+```json
27+{"mcpServers":{"api":{"transport":"streamable-http","url":"${API_BASE_URL:-https://api.example.com}/mcp","headers":{"Authorization":"Bearer ${API_KEY}"}}}}
28+```
29+
30+Expansion uses the bridge process environment after global/project configurations are merged. Defaults apply to unset variables; an explicitly empty variable stays empty. Values are expanded once, without shell execution, bare `$VAR` expansion, nested defaults, or references to sibling `env` entries. Config files are not rewritten. A missing variable without a default disables that server and reports the server, field, and variable name, never the field value; other valid servers remain available.
31+
22 ## Quick Start32 ## Quick Start
23 33
24 1. **Build the extension:**34 1. **Build the extension:**
M examples/extensions/mcp-bridge/config.go +44 -1
@@ -16,6 +16,7 @@ import (
1616 "io/fs"
1717 "os"
1818 "path/filepath"
19+ "regexp"
1920 "runtime"
2021 )
2122
@@ -81,7 +82,49 @@ func loadConfig(cwd string) (Config, error) {
8182 }
8283 }
8384
84- return cfg, nil
85+ var expansionErrors []error
86+ for name, srv := range cfg.MCPServers {
87+ if err := expandServerEnv(&srv); err != nil {
88+ delete(cfg.MCPServers, name)
89+ expansionErrors = append(expansionErrors, fmt.Errorf("server %q: %w", name, err))
90+ continue
91+ }
92+ cfg.MCPServers[name] = srv
93+ }
94+ return cfg, errors.Join(expansionErrors...)
95+}
96+
97+// Expand only Claude Code's braced syntax, not shell expressions or bare $VAR.
98+var envReference = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`)
99+
100+func expandServerEnv(srv *ServerConfig) error {
101+ var missing []error
102+ expand := func(field, value string) string {
103+ return envReference.ReplaceAllStringFunc(value, func(ref string) string {
104+ parts := envReference.FindStringSubmatch(ref)
105+ if value, ok := os.LookupEnv(parts[1]); ok {
106+ return value
107+ }
108+ if parts[2] != "" {
109+ return parts[3]
110+ }
111+ // Never include field values: they may contain credentials.
112+ missing = append(missing, fmt.Errorf("%s: environment variable %s is not set", field, parts[1]))
113+ return ref
114+ })
115+ }
116+ srv.Command = expand("command", srv.Command)
117+ for i, arg := range srv.Args {
118+ srv.Args[i] = expand(fmt.Sprintf("args[%d]", i), arg)
119+ }
120+ for k, value := range srv.Env {
121+ srv.Env[k] = expand("env", value)
122+ }
123+ srv.URL = expand("url", srv.URL)
124+ for k, value := range srv.Headers {
125+ srv.Headers[k] = expand("headers", value)
126+ }
127+ return errors.Join(missing...)
85128 }
86129
87130 // mergeConfig reads a JSON config file and merges its servers into cfg.
@@ -16,6 +16,7 @@ import (
16 "io/fs"16 "io/fs"
17 "os"17 "os"
18 "path/filepath"18 "path/filepath"
19+ "regexp"
19 "runtime"20 "runtime"
20 )21 )
21 22
@@ -81,7 +82,49 @@ func loadConfig(cwd string) (Config, error) {
81 }82 }
82 }83 }
83 84
84- return cfg, nil85+ var expansionErrors []error
86+ for name, srv := range cfg.MCPServers {
87+ if err := expandServerEnv(&srv); err != nil {
88+ delete(cfg.MCPServers, name)
89+ expansionErrors = append(expansionErrors, fmt.Errorf("server %q: %w", name, err))
90+ continue
91+ }
92+ cfg.MCPServers[name] = srv
93+ }
94+ return cfg, errors.Join(expansionErrors...)
95+}
96+
97+// Expand only Claude Code's braced syntax, not shell expressions or bare $VAR.
98+var envReference = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`)
99+
100+func expandServerEnv(srv *ServerConfig) error {
101+ var missing []error
102+ expand := func(field, value string) string {
103+ return envReference.ReplaceAllStringFunc(value, func(ref string) string {
104+ parts := envReference.FindStringSubmatch(ref)
105+ if value, ok := os.LookupEnv(parts[1]); ok {
106+ return value
107+ }
108+ if parts[2] != "" {
109+ return parts[3]
110+ }
111+ // Never include field values: they may contain credentials.
112+ missing = append(missing, fmt.Errorf("%s: environment variable %s is not set", field, parts[1]))
113+ return ref
114+ })
115+ }
116+ srv.Command = expand("command", srv.Command)
117+ for i, arg := range srv.Args {
118+ srv.Args[i] = expand(fmt.Sprintf("args[%d]", i), arg)
119+ }
120+ for k, value := range srv.Env {
121+ srv.Env[k] = expand("env", value)
122+ }
123+ srv.URL = expand("url", srv.URL)
124+ for k, value := range srv.Headers {
125+ srv.Headers[k] = expand("headers", value)
126+ }
127+ return errors.Join(missing...)
85 }128 }
86 129
87 // mergeConfig reads a JSON config file and merges its servers into cfg.130 // mergeConfig reads a JSON config file and merges its servers into cfg.
A examples/extensions/mcp-bridge/env_test.go +49 -0
@@ -0,0 +1,49 @@
1+package main
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestConfigEnvironmentExpansion(t *testing.T) {
11+ t.Setenv("ZOT_HOME", t.TempDir())
12+ t.Setenv("MCP_TEST_VALUE", "synthetic\"\\token")
13+ t.Setenv("MCP_TEST_EMPTY", "")
14+ t.Setenv("MCP_TEST_LITERAL", "${MCP_TEST_VALUE}")
15+ const absent = "MCP_TEST_UNSET"
16+ old, set := os.LookupEnv(absent)
17+ os.Unsetenv(absent)
18+ t.Cleanup(func() {
19+ if set {
20+ os.Setenv(absent, old)
21+ } else {
22+ os.Unsetenv(absent)
23+ }
24+ })
25+ project := t.TempDir()
26+ writeJSON(t, filepath.Join(os.Getenv("ZOT_HOME"), "mcp.json"), `{"mcpServers":{"override":{"command":"${MCP_TEST_UNSET}"}}}`)
27+ writeJSON(t, filepath.Join(project, ".zot", "mcp.json"), `{"mcpServers":{
28+ "override":{"command":"ok"},
29+ "valid":{"command":"${MCP_TEST_VALUE}","args":["${MCP_TEST_UNSET:-fallback}","${MCP_TEST_EMPTY:-fallback}","$HOME","${MCP_TEST_LITERAL}","${MCP_TEST_UNSET:-}"],"env":{"TOKEN":"${MCP_TEST_VALUE}"},"url":"${MCP_TEST_UNSET:-https://example.test}/mcp","headers":{"Authorization":"Bearer ${MCP_TEST_VALUE}"}},
30+ "invalid":{"headers":{"Authorization":"secret-prefix ${MCP_TEST_UNSET}"}}
31+ }}`)
32+ cfg, err := loadConfig(project)
33+ if err == nil || !strings.Contains(err.Error(), absent) || !strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "secret-prefix") {
34+ t.Fatalf("unexpected diagnostic: %v", err)
35+ }
36+ if _, ok := cfg.MCPServers["invalid"]; ok {
37+ t.Fatal("invalid server must not be started")
38+ }
39+ if cfg.MCPServers["override"].Command != "ok" {
40+ t.Fatal("expand after merging")
41+ }
42+ s := cfg.MCPServers["valid"]
43+ if s.Command != os.Getenv("MCP_TEST_VALUE") || s.Env["TOKEN"] != s.Command || s.Headers["Authorization"] != "Bearer "+s.Command {
44+ t.Fatal("command/env/header expansion failed")
45+ }
46+ if s.URL != "https://example.test/mcp" || strings.Join(s.Args, "|") != "fallback||$HOME|${MCP_TEST_VALUE}|" {
47+ t.Fatalf("unexpected URL/args: %s %v", s.URL, s.Args)
48+ }
49+}
@@ -0,0 +1,49 @@
1+package main
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestConfigEnvironmentExpansion(t *testing.T) {
11+ t.Setenv("ZOT_HOME", t.TempDir())
12+ t.Setenv("MCP_TEST_VALUE", "synthetic\"\\token")
13+ t.Setenv("MCP_TEST_EMPTY", "")
14+ t.Setenv("MCP_TEST_LITERAL", "${MCP_TEST_VALUE}")
15+ const absent = "MCP_TEST_UNSET"
16+ old, set := os.LookupEnv(absent)
17+ os.Unsetenv(absent)
18+ t.Cleanup(func() {
19+ if set {
20+ os.Setenv(absent, old)
21+ } else {
22+ os.Unsetenv(absent)
23+ }
24+ })
25+ project := t.TempDir()
26+ writeJSON(t, filepath.Join(os.Getenv("ZOT_HOME"), "mcp.json"), `{"mcpServers":{"override":{"command":"${MCP_TEST_UNSET}"}}}`)
27+ writeJSON(t, filepath.Join(project, ".zot", "mcp.json"), `{"mcpServers":{
28+ "override":{"command":"ok"},
29+ "valid":{"command":"${MCP_TEST_VALUE}","args":["${MCP_TEST_UNSET:-fallback}","${MCP_TEST_EMPTY:-fallback}","$HOME","${MCP_TEST_LITERAL}","${MCP_TEST_UNSET:-}"],"env":{"TOKEN":"${MCP_TEST_VALUE}"},"url":"${MCP_TEST_UNSET:-https://example.test}/mcp","headers":{"Authorization":"Bearer ${MCP_TEST_VALUE}"}},
30+ "invalid":{"headers":{"Authorization":"secret-prefix ${MCP_TEST_UNSET}"}}
31+ }}`)
32+ cfg, err := loadConfig(project)
33+ if err == nil || !strings.Contains(err.Error(), absent) || !strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "secret-prefix") {
34+ t.Fatalf("unexpected diagnostic: %v", err)
35+ }
36+ if _, ok := cfg.MCPServers["invalid"]; ok {
37+ t.Fatal("invalid server must not be started")
38+ }
39+ if cfg.MCPServers["override"].Command != "ok" {
40+ t.Fatal("expand after merging")
41+ }
42+ s := cfg.MCPServers["valid"]
43+ if s.Command != os.Getenv("MCP_TEST_VALUE") || s.Env["TOKEN"] != s.Command || s.Headers["Authorization"] != "Bearer "+s.Command {
44+ t.Fatal("command/env/header expansion failed")
45+ }
46+ if s.URL != "https://example.test/mcp" || strings.Join(s.Args, "|") != "fallback||$HOME|${MCP_TEST_VALUE}|" {
47+ t.Fatalf("unexpected URL/args: %s %v", s.URL, s.Args)
48+ }
49+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close