Merge pull request #175 from mi-skam/fix/mcp-env-expansion
fix(mcp): expand Claude Code-style environment references
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 | |||
| 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 | + | ||
| 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 | 34 | ## Quick Start | |
| 23 | 35 | ||
| 24 | 36 | 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 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 | + | ||||
| 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 Start | 34 | ## 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 ( | |||
| 16 | 16 | "io/fs" | |
| 17 | 17 | "os" | |
| 18 | 18 | "path/filepath" | |
| 19 | + | "regexp" | |
| 19 | 20 | "runtime" | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| @@ -73,15 +74,59 @@ func loadConfig(cwd string) (Config, error) { | |||
| 73 | 74 | return cfg, fmt.Errorf("global config %s: %w", globalPath, err) | |
| 74 | 75 | } | |
| 75 | 76 | ||
| 77 | + | var configErrors []error | |
| 78 | + | ||
| 76 | 79 | // 2. Project config (overrides global per-server) | |
| 77 | 80 | if cwd != "" { | |
| 78 | 81 | projectPath := filepath.Join(cwd, ".zot", "mcp.json") | |
| 79 | 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, 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...) | |
| 85 | 130 | } | |
| 86 | 131 | ||
| 87 | 132 | // 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, 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...) | |||
| 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 | + | } | |||