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 | |||
| 19 | 19 | - **Slash commands** — `/mcp` to check status, start/stop/restart servers | |
| 20 | 20 | - **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 | 32 | ## Quick Start | |
| 23 | 33 | ||
| 24 | 34 | 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 servers | 19 | - **Slash commands** — `/mcp` to check status, start/stop/restart servers | ||
| 20 | - **Better error messages** — context-aware errors with actionable suggestions | 20 | - **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 Start | 32 | ## 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 ( | |||
| 16 | 16 | "io/fs" | |
| 17 | 17 | "os" | |
| 18 | 18 | "path/filepath" | |
| 19 | + | "regexp" | |
| 19 | 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, 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...) | |
| 85 | 128 | } | |
| 86 | 129 | ||
| 87 | 130 | // 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, 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...) | |||
| 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 | + | } | |||