Skip to content

Commit be29968

Browse files
authored
feat: add ${VAR} environment variable expansion in mcp.json (#2)
Expands ${VAR} references in all string fields (command, args, url, headers, env values) during config loading. Fails fast if a referenced variable is not set. Bare $VAR syntax is not expanded.
1 parent 1ce1658 commit be29968

2 files changed

Lines changed: 149 additions & 0 deletions

File tree

internal/config/config.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"regexp"
78
)
89

910
type TransportType string
@@ -51,9 +52,73 @@ func Load(path string) (*Config, error) {
5152
return nil, fmt.Errorf("parsing config file: %w", err)
5253
}
5354

55+
if err := cfg.expandEnvVars(); err != nil {
56+
return nil, err
57+
}
58+
5459
return &cfg, nil
5560
}
5661

62+
// envVarPattern matches ${VAR} references in config values.
63+
var envVarPattern = regexp.MustCompile(`\$\{([^}]+)\}`)
64+
65+
// expandString replaces all ${VAR} references with their environment variable
66+
// values. Returns an error if a referenced variable is not set.
67+
func expandString(s string) (string, error) {
68+
var expandErr error
69+
result := envVarPattern.ReplaceAllStringFunc(s, func(match string) string {
70+
varName := envVarPattern.FindStringSubmatch(match)[1]
71+
val, ok := os.LookupEnv(varName)
72+
if !ok {
73+
expandErr = fmt.Errorf("environment variable %q is not set", varName)
74+
return match
75+
}
76+
return val
77+
})
78+
if expandErr != nil {
79+
return "", expandErr
80+
}
81+
return result, nil
82+
}
83+
84+
// expandStringMap expands env vars in all values of a map.
85+
func expandStringMap(m map[string]string) error {
86+
for k, v := range m {
87+
expanded, err := expandString(v)
88+
if err != nil {
89+
return err
90+
}
91+
m[k] = expanded
92+
}
93+
return nil
94+
}
95+
96+
// expandEnvVars expands ${VAR} references across all string fields in the config.
97+
func (c *Config) expandEnvVars() error {
98+
for name, srv := range c.MCPServers {
99+
var err error
100+
if srv.Command, err = expandString(srv.Command); err != nil {
101+
return fmt.Errorf("server %q command: %w", name, err)
102+
}
103+
if srv.URL, err = expandString(srv.URL); err != nil {
104+
return fmt.Errorf("server %q url: %w", name, err)
105+
}
106+
for i, arg := range srv.Args {
107+
if srv.Args[i], err = expandString(arg); err != nil {
108+
return fmt.Errorf("server %q args[%d]: %w", name, i, err)
109+
}
110+
}
111+
if err := expandStringMap(srv.Env); err != nil {
112+
return fmt.Errorf("server %q env: %w", name, err)
113+
}
114+
if err := expandStringMap(srv.Headers); err != nil {
115+
return fmt.Errorf("server %q headers: %w", name, err)
116+
}
117+
c.MCPServers[name] = srv
118+
}
119+
return nil
120+
}
121+
57122
func (c *Config) Validate() error {
58123
for name, srv := range c.MCPServers {
59124
tt, err := srv.TransportType()

internal/config/config_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,87 @@ func TestLoadedFieldValues(t *testing.T) {
221221
t.Errorf("api.TransportType() = %q, %v; want http", tt, err)
222222
}
223223
}
224+
225+
func TestEnvVarExpansion(t *testing.T) {
226+
t.Setenv("TEST_CMD", "my-server")
227+
t.Setenv("TEST_ARG", "--verbose")
228+
t.Setenv("TEST_URL", "https://api.example.com")
229+
t.Setenv("TEST_TOKEN", "secret123")
230+
t.Setenv("TEST_ENV_VAL", "debug-mode")
231+
232+
json := `{
233+
"mcpServers": {
234+
"s1": {
235+
"command": "${TEST_CMD}",
236+
"args": ["${TEST_ARG}", "literal"],
237+
"env": {"MODE": "${TEST_ENV_VAL}"}
238+
},
239+
"s2": {
240+
"type": "http",
241+
"url": "${TEST_URL}/mcp",
242+
"headers": {"Authorization": "Bearer ${TEST_TOKEN}"}
243+
}
244+
}
245+
}`
246+
path := writeTestConfig(t, json)
247+
cfg, err := Load(path)
248+
if err != nil {
249+
t.Fatal(err)
250+
}
251+
252+
s1 := cfg.MCPServers["s1"]
253+
if s1.Command != "my-server" {
254+
t.Errorf("command = %q, want 'my-server'", s1.Command)
255+
}
256+
if s1.Args[0] != "--verbose" {
257+
t.Errorf("args[0] = %q, want '--verbose'", s1.Args[0])
258+
}
259+
if s1.Args[1] != "literal" {
260+
t.Errorf("args[1] = %q, want 'literal'", s1.Args[1])
261+
}
262+
if s1.Env["MODE"] != "debug-mode" {
263+
t.Errorf("env[MODE] = %q, want 'debug-mode'", s1.Env["MODE"])
264+
}
265+
266+
s2 := cfg.MCPServers["s2"]
267+
if s2.URL != "https://api.example.com/mcp" {
268+
t.Errorf("url = %q, want 'https://api.example.com/mcp'", s2.URL)
269+
}
270+
if s2.Headers["Authorization"] != "Bearer secret123" {
271+
t.Errorf("header = %q, want 'Bearer secret123'", s2.Headers["Authorization"])
272+
}
273+
}
274+
275+
func TestEnvVarExpansionMissingVar(t *testing.T) {
276+
json := `{
277+
"mcpServers": {
278+
"s1": {
279+
"type": "http",
280+
"url": "${THIS_VAR_DOES_NOT_EXIST}/mcp"
281+
}
282+
}
283+
}`
284+
path := writeTestConfig(t, json)
285+
_, err := Load(path)
286+
if err == nil {
287+
t.Fatal("expected error for missing env var")
288+
}
289+
}
290+
291+
func TestEnvVarNoExpansionWithoutBraces(t *testing.T) {
292+
json := `{
293+
"mcpServers": {
294+
"s1": {
295+
"command": "$NOT_EXPANDED"
296+
}
297+
}
298+
}`
299+
path := writeTestConfig(t, json)
300+
cfg, err := Load(path)
301+
if err != nil {
302+
t.Fatal(err)
303+
}
304+
if cfg.MCPServers["s1"].Command != "$NOT_EXPANDED" {
305+
t.Errorf("bare $VAR should not expand, got %q", cfg.MCPServers["s1"].Command)
306+
}
307+
}

0 commit comments

Comments
 (0)