detached fd16616

fix(mcp): validate global servers after project config errors

commit
fd166165d88408123d0e0ec6c5f6ffeb5031b2c3
author
patriceckhart · · 2026-09-14 14:37 UTC
parent
9be7bb7
signature
unsigned
3 files · +53 -4
M examples/extensions/mcp-bridge/README.md +2 -0
@@ -29,6 +29,8 @@ Like [Claude Code](https://code.claude.com/docs/en/mcp#environment-variable-expa
2929
3030 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.
3131
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+
3234 ## Quick Start
3335
3436 1. **Build the extension:**
@@ -29,6 +29,8 @@ Like [Claude Code](https://code.claude.com/docs/en/mcp#environment-variable-expa
29 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.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 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+
32 ## Quick Start34 ## Quick Start
33 35
34 1. **Build the extension:**36 1. **Build the extension:**
M examples/extensions/mcp-bridge/config.go +6 -4
@@ -74,24 +74,26 @@ func loadConfig(cwd string) (Config, error) {
7474 return cfg, fmt.Errorf("global config %s: %w", globalPath, err)
7575 }
7676
77+ var configErrors []error
78+
7779 // 2. Project config (overrides global per-server)
7880 if cwd != "" {
7981 projectPath := filepath.Join(cwd, ".zot", "mcp.json")
8082 if err := mergeConfig(&cfg, projectPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
81- return cfg, fmt.Errorf("project config %s: %w", projectPath, err)
83+ configErrors = append(configErrors, fmt.Errorf("project config %s: %w", projectPath, err))
8284 }
8385 }
8486
85- var expansionErrors []error
87+ // Retained global servers must be expanded even if the project config failed.
8688 for name, srv := range cfg.MCPServers {
8789 if err := expandServerEnv(&srv); err != nil {
8890 delete(cfg.MCPServers, name)
89- expansionErrors = append(expansionErrors, fmt.Errorf("server %q: %w", name, err))
91+ configErrors = append(configErrors, fmt.Errorf("server %q: %w", name, err))
9092 continue
9193 }
9294 cfg.MCPServers[name] = srv
9395 }
94- return cfg, errors.Join(expansionErrors...)
96+ return cfg, errors.Join(configErrors...)
9597 }
9698
9799 // Expand only Claude Code's braced syntax, not shell expressions or bare $VAR.
@@ -74,24 +74,26 @@ func loadConfig(cwd string) (Config, error) {
74 return cfg, fmt.Errorf("global config %s: %w", globalPath, err)74 return cfg, fmt.Errorf("global config %s: %w", globalPath, err)
75 }75 }
76 76
77+ var configErrors []error
78+
77 // 2. Project config (overrides global per-server)79 // 2. Project config (overrides global per-server)
78 if cwd != "" {80 if cwd != "" {
79 projectPath := filepath.Join(cwd, ".zot", "mcp.json")81 projectPath := filepath.Join(cwd, ".zot", "mcp.json")
80 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) {
81- return cfg, fmt.Errorf("project config %s: %w", projectPath, err)83+ configErrors = append(configErrors, fmt.Errorf("project config %s: %w", projectPath, err))
82 }84 }
83 }85 }
84 86
85- var expansionErrors []error87+ // Retained global servers must be expanded even if the project config failed.
86 for name, srv := range cfg.MCPServers {88 for name, srv := range cfg.MCPServers {
87 if err := expandServerEnv(&srv); err != nil {89 if err := expandServerEnv(&srv); err != nil {
88 delete(cfg.MCPServers, name)90 delete(cfg.MCPServers, name)
89- expansionErrors = append(expansionErrors, fmt.Errorf("server %q: %w", name, err))91+ configErrors = append(configErrors, fmt.Errorf("server %q: %w", name, err))
90 continue92 continue
91 }93 }
92 cfg.MCPServers[name] = srv94 cfg.MCPServers[name] = srv
93 }95 }
94- return cfg, errors.Join(expansionErrors...)96+ return cfg, errors.Join(configErrors...)
95 }97 }
96 98
97 // Expand only Claude Code's braced syntax, not shell expressions or bare $VAR.99 // Expand only Claude Code's braced syntax, not shell expressions or bare $VAR.
M examples/extensions/mcp-bridge/env_test.go +45 -0
@@ -7,6 +7,51 @@ import (
77 "testing"
88 )
99
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+
1055 func TestConfigEnvironmentExpansion(t *testing.T) {
1156 t.Setenv("ZOT_HOME", t.TempDir())
1257 t.Setenv("MCP_TEST_VALUE", "synthetic\"\\token")
@@ -7,6 +7,51 @@ import (
7 "testing"7 "testing"
8 )8 )
9 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+
10 func TestConfigEnvironmentExpansion(t *testing.T) {55 func TestConfigEnvironmentExpansion(t *testing.T) {
11 t.Setenv("ZOT_HOME", t.TempDir())56 t.Setenv("ZOT_HOME", t.TempDir())
12 t.Setenv("MCP_TEST_VALUE", "synthetic\"\\token")57 t.Setenv("MCP_TEST_VALUE", "synthetic\"\\token")

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