detached 0f485af

Merge pull request #175 from mi-skam/fix/mcp-env-expansion

fix(mcp): expand Claude Code-style environment references
commit
0f485af0b123604f3317bfa9e430b15e51010713
author
Patric Eckhart · · 2026-09-14 14:39 UTC
committer
GitHub · · 2026-09-14 14:39 UTC
merge of
c4c30ff + fd16616 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.74
3 files · +153 -2
M examples/extensions/mcp-bridge/README.md +12 -0
@@ -19,6 +19,18 @@ 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+
32+If the project config cannot be read or parsed, the bridge reports that error and still expands and validates retained global servers. Servers with missing required variables are excluded in this case too.
33+
2234 ## Quick Start
2335
2436 1. **Build the extension:**
@@ -19,6 +19,18 @@ 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+
32+If the project config cannot be read or parsed, the bridge reports that error and still expands and validates retained global servers. Servers with missing required variables are excluded in this case too.
33+
22 ## Quick Start34 ## Quick Start
23 35
24 1. **Build the extension:**36 1. **Build the extension:**
M examples/extensions/mcp-bridge/config.go +47 -2
@@ -16,6 +16,7 @@ import (
1616 "io/fs"
1717 "os"
1818 "path/filepath"
19+ "regexp"
1920 "runtime"
2021 )
2122
@@ -73,15 +74,59 @@ func loadConfig(cwd string) (Config, error) {
7374 return cfg, fmt.Errorf("global config %s: %w", globalPath, err)
7475 }
7576
77+ var configErrors []error
78+
7679 // 2. Project config (overrides global per-server)
7780 if cwd != "" {
7881 projectPath := filepath.Join(cwd, ".zot", "mcp.json")
7982 if err := mergeConfig(&cfg, projectPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
80- return cfg, fmt.Errorf("project config %s: %w", projectPath, err)
83+ configErrors = append(configErrors, fmt.Errorf("project config %s: %w", projectPath, err))
84+ }
85+ }
86+
87+ // Retained global servers must be expanded even if the project config failed.
88+ for name, srv := range cfg.MCPServers {
89+ if err := expandServerEnv(&srv); err != nil {
90+ delete(cfg.MCPServers, name)
91+ configErrors = append(configErrors, fmt.Errorf("server %q: %w", name, err))
92+ continue
8193 }
94+ cfg.MCPServers[name] = srv
8295 }
96+ return cfg, errors.Join(configErrors...)
97+}
8398
84- return cfg, nil
99+// Expand only Claude Code's braced syntax, not shell expressions or bare $VAR.
100+var envReference = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`)
101+
102+func expandServerEnv(srv *ServerConfig) error {
103+ var missing []error
104+ expand := func(field, value string) string {
105+ return envReference.ReplaceAllStringFunc(value, func(ref string) string {
106+ parts := envReference.FindStringSubmatch(ref)
107+ if value, ok := os.LookupEnv(parts[1]); ok {
108+ return value
109+ }
110+ if parts[2] != "" {
111+ return parts[3]
112+ }
113+ // Never include field values: they may contain credentials.
114+ missing = append(missing, fmt.Errorf("%s: environment variable %s is not set", field, parts[1]))
115+ return ref
116+ })
117+ }
118+ srv.Command = expand("command", srv.Command)
119+ for i, arg := range srv.Args {
120+ srv.Args[i] = expand(fmt.Sprintf("args[%d]", i), arg)
121+ }
122+ for k, value := range srv.Env {
123+ srv.Env[k] = expand("env", value)
124+ }
125+ srv.URL = expand("url", srv.URL)
126+ for k, value := range srv.Headers {
127+ srv.Headers[k] = expand("headers", value)
128+ }
129+ return errors.Join(missing...)
85130 }
86131
87132 // 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
@@ -73,15 +74,59 @@ func loadConfig(cwd string) (Config, error) {
73 return cfg, fmt.Errorf("global config %s: %w", globalPath, err)74 return cfg, fmt.Errorf("global config %s: %w", globalPath, err)
74 }75 }
75 76
77+ var configErrors []error
78+
76 // 2. Project config (overrides global per-server)79 // 2. Project config (overrides global per-server)
77 if cwd != "" {80 if cwd != "" {
78 projectPath := filepath.Join(cwd, ".zot", "mcp.json")81 projectPath := filepath.Join(cwd, ".zot", "mcp.json")
79 if err := mergeConfig(&cfg, projectPath); err != nil && !errors.Is(err, fs.ErrNotExist) {82 if err := mergeConfig(&cfg, projectPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
80- return cfg, fmt.Errorf("project config %s: %w", projectPath, err)83+ configErrors = append(configErrors, fmt.Errorf("project config %s: %w", projectPath, err))
84+ }
85+ }
86+
87+ // Retained global servers must be expanded even if the project config failed.
88+ for name, srv := range cfg.MCPServers {
89+ if err := expandServerEnv(&srv); err != nil {
90+ delete(cfg.MCPServers, name)
91+ configErrors = append(configErrors, fmt.Errorf("server %q: %w", name, err))
92+ continue
81 }93 }
94+ cfg.MCPServers[name] = srv
82 }95 }
96+ return cfg, errors.Join(configErrors...)
97+}
83 98
84- return cfg, nil99+// Expand only Claude Code's braced syntax, not shell expressions or bare $VAR.
100+var envReference = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`)
101+
102+func expandServerEnv(srv *ServerConfig) error {
103+ var missing []error
104+ expand := func(field, value string) string {
105+ return envReference.ReplaceAllStringFunc(value, func(ref string) string {
106+ parts := envReference.FindStringSubmatch(ref)
107+ if value, ok := os.LookupEnv(parts[1]); ok {
108+ return value
109+ }
110+ if parts[2] != "" {
111+ return parts[3]
112+ }
113+ // Never include field values: they may contain credentials.
114+ missing = append(missing, fmt.Errorf("%s: environment variable %s is not set", field, parts[1]))
115+ return ref
116+ })
117+ }
118+ srv.Command = expand("command", srv.Command)
119+ for i, arg := range srv.Args {
120+ srv.Args[i] = expand(fmt.Sprintf("args[%d]", i), arg)
121+ }
122+ for k, value := range srv.Env {
123+ srv.Env[k] = expand("env", value)
124+ }
125+ srv.URL = expand("url", srv.URL)
126+ for k, value := range srv.Headers {
127+ srv.Headers[k] = expand("headers", value)
128+ }
129+ return errors.Join(missing...)
85 }130 }
86 131
87 // mergeConfig reads a JSON config file and merges its servers into cfg.132 // mergeConfig reads a JSON config file and merges its servers into cfg.
A examples/extensions/mcp-bridge/env_test.go +94 -0
@@ -0,0 +1,94 @@
1+package main
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestConfigEnvironmentExpansionWithProjectError(t *testing.T) {
11+ for _, kind := range []string{"malformed", "unreadable"} {
12+ t.Run(kind, func(t *testing.T) {
13+ t.Setenv("ZOT_HOME", t.TempDir())
14+ t.Setenv("MCP_TEST_VALUE", "synthetic-command")
15+ const absent = "MCP_TEST_PROJECT_ERROR_UNSET"
16+ // Setenv restores the original value after the test, including absence.
17+ t.Setenv(absent, "")
18+ if err := os.Unsetenv(absent); err != nil {
19+ t.Fatal(err)
20+ }
21+ writeJSON(t, filepath.Join(os.Getenv("ZOT_HOME"), "mcp.json"), `{"mcpServers":{
22+ "valid":{"command":"${MCP_TEST_VALUE}"},
23+ "invalid":{"headers":{"Authorization":"secret-prefix ${MCP_TEST_PROJECT_ERROR_UNSET}"}}
24+ }}`)
25+ project := t.TempDir()
26+ path := filepath.Join(project, ".zot", "mcp.json")
27+ if kind == "malformed" {
28+ writeJSON(t, path, `{`)
29+ } else if err := os.MkdirAll(path, 0o755); err != nil {
30+ // A directory produces a read error even when tests run as root.
31+ t.Fatal(err)
32+ }
33+ cfg, err := loadConfig(project)
34+ if err == nil {
35+ t.Fatal("expected config error")
36+ }
37+ for _, want := range []string{"project config", path, "invalid", absent} {
38+ if !strings.Contains(err.Error(), want) {
39+ t.Errorf("diagnostic missing %q: %v", want, err)
40+ }
41+ }
42+ if strings.Contains(err.Error(), "secret-prefix") {
43+ t.Error("diagnostic exposed field value")
44+ }
45+ if got := cfg.MCPServers["valid"].Command; got != "synthetic-command" {
46+ t.Errorf("global command = %q, want synthetic-command", got)
47+ }
48+ if _, ok := cfg.MCPServers["invalid"]; ok {
49+ t.Error("server with missing variable must not be retained")
50+ }
51+ })
52+ }
53+}
54+
55+func TestConfigEnvironmentExpansion(t *testing.T) {
56+ t.Setenv("ZOT_HOME", t.TempDir())
57+ t.Setenv("MCP_TEST_VALUE", "synthetic\"\\token")
58+ t.Setenv("MCP_TEST_EMPTY", "")
59+ t.Setenv("MCP_TEST_LITERAL", "${MCP_TEST_VALUE}")
60+ const absent = "MCP_TEST_UNSET"
61+ old, set := os.LookupEnv(absent)
62+ os.Unsetenv(absent)
63+ t.Cleanup(func() {
64+ if set {
65+ os.Setenv(absent, old)
66+ } else {
67+ os.Unsetenv(absent)
68+ }
69+ })
70+ project := t.TempDir()
71+ writeJSON(t, filepath.Join(os.Getenv("ZOT_HOME"), "mcp.json"), `{"mcpServers":{"override":{"command":"${MCP_TEST_UNSET}"}}}`)
72+ writeJSON(t, filepath.Join(project, ".zot", "mcp.json"), `{"mcpServers":{
73+ "override":{"command":"ok"},
74+ "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}"}},
75+ "invalid":{"headers":{"Authorization":"secret-prefix ${MCP_TEST_UNSET}"}}
76+ }}`)
77+ cfg, err := loadConfig(project)
78+ if err == nil || !strings.Contains(err.Error(), absent) || !strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "secret-prefix") {
79+ t.Fatalf("unexpected diagnostic: %v", err)
80+ }
81+ if _, ok := cfg.MCPServers["invalid"]; ok {
82+ t.Fatal("invalid server must not be started")
83+ }
84+ if cfg.MCPServers["override"].Command != "ok" {
85+ t.Fatal("expand after merging")
86+ }
87+ s := cfg.MCPServers["valid"]
88+ if s.Command != os.Getenv("MCP_TEST_VALUE") || s.Env["TOKEN"] != s.Command || s.Headers["Authorization"] != "Bearer "+s.Command {
89+ t.Fatal("command/env/header expansion failed")
90+ }
91+ if s.URL != "https://example.test/mcp" || strings.Join(s.Args, "|") != "fallback||$HOME|${MCP_TEST_VALUE}|" {
92+ t.Fatalf("unexpected URL/args: %s %v", s.URL, s.Args)
93+ }
94+}
@@ -0,0 +1,94 @@
1+package main
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestConfigEnvironmentExpansionWithProjectError(t *testing.T) {
11+ for _, kind := range []string{"malformed", "unreadable"} {
12+ t.Run(kind, func(t *testing.T) {
13+ t.Setenv("ZOT_HOME", t.TempDir())
14+ t.Setenv("MCP_TEST_VALUE", "synthetic-command")
15+ const absent = "MCP_TEST_PROJECT_ERROR_UNSET"
16+ // Setenv restores the original value after the test, including absence.
17+ t.Setenv(absent, "")
18+ if err := os.Unsetenv(absent); err != nil {
19+ t.Fatal(err)
20+ }
21+ writeJSON(t, filepath.Join(os.Getenv("ZOT_HOME"), "mcp.json"), `{"mcpServers":{
22+ "valid":{"command":"${MCP_TEST_VALUE}"},
23+ "invalid":{"headers":{"Authorization":"secret-prefix ${MCP_TEST_PROJECT_ERROR_UNSET}"}}
24+ }}`)
25+ project := t.TempDir()
26+ path := filepath.Join(project, ".zot", "mcp.json")
27+ if kind == "malformed" {
28+ writeJSON(t, path, `{`)
29+ } else if err := os.MkdirAll(path, 0o755); err != nil {
30+ // A directory produces a read error even when tests run as root.
31+ t.Fatal(err)
32+ }
33+ cfg, err := loadConfig(project)
34+ if err == nil {
35+ t.Fatal("expected config error")
36+ }
37+ for _, want := range []string{"project config", path, "invalid", absent} {
38+ if !strings.Contains(err.Error(), want) {
39+ t.Errorf("diagnostic missing %q: %v", want, err)
40+ }
41+ }
42+ if strings.Contains(err.Error(), "secret-prefix") {
43+ t.Error("diagnostic exposed field value")
44+ }
45+ if got := cfg.MCPServers["valid"].Command; got != "synthetic-command" {
46+ t.Errorf("global command = %q, want synthetic-command", got)
47+ }
48+ if _, ok := cfg.MCPServers["invalid"]; ok {
49+ t.Error("server with missing variable must not be retained")
50+ }
51+ })
52+ }
53+}
54+
55+func TestConfigEnvironmentExpansion(t *testing.T) {
56+ t.Setenv("ZOT_HOME", t.TempDir())
57+ t.Setenv("MCP_TEST_VALUE", "synthetic\"\\token")
58+ t.Setenv("MCP_TEST_EMPTY", "")
59+ t.Setenv("MCP_TEST_LITERAL", "${MCP_TEST_VALUE}")
60+ const absent = "MCP_TEST_UNSET"
61+ old, set := os.LookupEnv(absent)
62+ os.Unsetenv(absent)
63+ t.Cleanup(func() {
64+ if set {
65+ os.Setenv(absent, old)
66+ } else {
67+ os.Unsetenv(absent)
68+ }
69+ })
70+ project := t.TempDir()
71+ writeJSON(t, filepath.Join(os.Getenv("ZOT_HOME"), "mcp.json"), `{"mcpServers":{"override":{"command":"${MCP_TEST_UNSET}"}}}`)
72+ writeJSON(t, filepath.Join(project, ".zot", "mcp.json"), `{"mcpServers":{
73+ "override":{"command":"ok"},
74+ "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}"}},
75+ "invalid":{"headers":{"Authorization":"secret-prefix ${MCP_TEST_UNSET}"}}
76+ }}`)
77+ cfg, err := loadConfig(project)
78+ if err == nil || !strings.Contains(err.Error(), absent) || !strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "secret-prefix") {
79+ t.Fatalf("unexpected diagnostic: %v", err)
80+ }
81+ if _, ok := cfg.MCPServers["invalid"]; ok {
82+ t.Fatal("invalid server must not be started")
83+ }
84+ if cfg.MCPServers["override"].Command != "ok" {
85+ t.Fatal("expand after merging")
86+ }
87+ s := cfg.MCPServers["valid"]
88+ if s.Command != os.Getenv("MCP_TEST_VALUE") || s.Env["TOKEN"] != s.Command || s.Headers["Authorization"] != "Bearer "+s.Command {
89+ t.Fatal("command/env/header expansion failed")
90+ }
91+ if s.URL != "https://example.test/mcp" || strings.Join(s.Args, "|") != "fallback||$HOME|${MCP_TEST_VALUE}|" {
92+ t.Fatalf("unexpected URL/args: %s %v", s.URL, s.Args)
93+ }
94+}

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