1package mcp
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "reflect"
8 "runtime"
9 "strings"
10 "testing"
11 "time"
12)
13
14func writeFile(t *testing.T, path, content string) {
15 t.Helper()
16 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
17 t.Fatal(err)
18 }
19 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
20 t.Fatal(err)
21 }
22}
23
24func TestLoadConfigMergeProjectWins(t *testing.T) {
25 home, cwd := t.TempDir(), t.TempDir()
26 writeFile(t, GlobalConfigPath(home), `{"mcpServers":{
27 "shared":{"command":"global-cmd"},
28 "only-global":{"command":"g"}}}`)
29 writeFile(t, ProjectConfigPath(cwd), `{"mcpServers":{
30 "shared":{"command":"project-cmd","args":["x"]},
31 "only-project":{"url":"http://127.0.0.1:1/mcp"}}}`)
32
33 cfg, errs := LoadConfig(home, cwd)
34 if len(errs) != 0 {
35 t.Fatalf("unexpected errors: %v", errs)
36 }
37 if got := sortedNames(cfg.Servers); !reflect.DeepEqual(got, []string{"only-global", "only-project", "shared"}) {
38 t.Fatalf("servers = %v", got)
39 }
40 shared := cfg.Servers["shared"]
41 if shared.Command != "project-cmd" || !reflect.DeepEqual(shared.Args, []string{"x"}) {
42 t.Errorf("project did not win: %+v", shared)
43 }
44 if cfg.Servers["only-project"].Kind() != "http" {
45 t.Errorf("url-only server should be http")
46 }
47}
48
49func TestLoadConfigMissingFilesAndMalformed(t *testing.T) {
50 home, cwd := t.TempDir(), t.TempDir()
51 cfg, errs := LoadConfig(home, cwd)
52 if len(errs) != 0 || len(cfg.Servers) != 0 {
53 t.Fatalf("missing files: cfg=%v errs=%v", cfg, errs)
54 }
55 writeFile(t, GlobalConfigPath(home), `{bad json`)
56 writeFile(t, ProjectConfigPath(cwd), `{"mcpServers":{"ok":{"command":"c"}}}`)
57 cfg, errs = LoadConfig(home, cwd)
58 if len(errs) != 1 || !strings.Contains(errs[0].Error(), "parse") {
59 t.Fatalf("want one parse error, got %v", errs)
60 }
61 if _, ok := cfg.Servers["ok"]; !ok {
62 t.Errorf("malformed global file must not hide the project file: %v", cfg.Servers)
63 }
64}
65
66func TestLoadConfigEnvExpansion(t *testing.T) {
67 t.Setenv("MCP_T_CMD", "/usr/bin/tool")
68 t.Setenv("MCP_T_HOST", "127.0.0.1:9")
69 t.Setenv("MCP_T_TOKEN", "tok-123")
70 t.Setenv("MCP_T_EMPTY", "")
71 os.Unsetenv("MCP_T_UNSET")
72 home := t.TempDir()
73 writeFile(t, GlobalConfigPath(home), `{"mcpServers":{
74 "s":{"command":"${MCP_T_CMD}","args":["--a=${MCP_T_UNSET:-fallback}","--b=${MCP_T_EMPTY:-notused}","$BARE","${MCP_T_CMD}/x"],
75 "cwd":"${MCP_T_UNSET:-.}","env":{"K":"v-${MCP_T_TOKEN}","D":"${MCP_T_UNSET:-}"}},
76 "h":{"url":"http://${MCP_T_HOST}/mcp","headers":{"X-Key":"${MCP_T_TOKEN}","X-D":"${MCP_T_UNSET:-dflt}"}}}}`)
77
78 cfg, errs := LoadConfig(home, "")
79 if len(errs) != 0 {
80 t.Fatalf("errors: %v", errs)
81 }
82 s := cfg.Servers["s"]
83 if s.Command != "/usr/bin/tool" {
84 t.Errorf("command = %q", s.Command)
85 }
86 wantArgs := []string{"--a=fallback", "--b=", "$BARE", "/usr/bin/tool/x"}
87 if !reflect.DeepEqual(s.Args, wantArgs) {
88 t.Errorf("args = %q, want %q", s.Args, wantArgs)
89 }
90 if s.CWD != "." || s.Env["K"] != "v-tok-123" || s.Env["D"] != "" {
91 t.Errorf("cwd/env wrong: %+v", s)
92 }
93 h := cfg.Servers["h"]
94 if h.URL != "http://127.0.0.1:9/mcp" || h.Headers["X-Key"] != "tok-123" || h.Headers["X-D"] != "dflt" {
95 t.Errorf("http expansion wrong: %+v", h)
96 }
97}
98
99func TestLoadConfigMissingVarDropsOnlyThatServerWithoutSecrets(t *testing.T) {
100 const secret = "super-secret-value-9f3"
101 t.Setenv("MCP_T_PRESENT", secret)
102 os.Unsetenv("MCP_T_ABSENT")
103 home := t.TempDir()
104 writeFile(t, GlobalConfigPath(home), `{"mcpServers":{
105 "bad":{"command":"c","env":{"TOKEN":"${MCP_T_PRESENT}-${MCP_T_ABSENT}"}},
106 "good":{"command":"c","env":{"TOKEN":"${MCP_T_PRESENT}"}}}}`)
107 cfg, errs := LoadConfig(home, "")
108 if _, ok := cfg.Servers["bad"]; ok {
109 t.Error("server with a missing variable should be dropped")
110 }
111 if cfg.Servers["good"].Env["TOKEN"] != secret {
112 t.Error("other servers must be kept")
113 }
114 if len(errs) != 1 {
115 t.Fatalf("errs = %v", errs)
116 }
117 msg := errs[0].Error()
118 if strings.Contains(msg, secret) {
119 t.Errorf("error leaks a secret value: %s", msg)
120 }
121 if !strings.Contains(msg, "MCP_T_ABSENT") || !strings.Contains(msg, `"bad"`) {
122 t.Errorf("error should name the server and variable: %s", msg)
123 }
124 var ce *ConfigError
125 if e, ok := errs[0].(*ConfigError); !ok {
126 t.Errorf("want *ConfigError, got %T", errs[0])
127 } else {
128 ce = e
129 if ce.Server != "bad" {
130 t.Errorf("ConfigError.Server = %q", ce.Server)
131 }
132 }
133}
134
135func TestLoadConfigDisabledSkipped(t *testing.T) {
136 home := t.TempDir()
137 writeFile(t, GlobalConfigPath(home), `{"mcpServers":{
138 "off":{"command":"c","enabled":false},
139 "on":{"command":"c","enabled":true},
140 "off-invalid":{"enabled":false}}}`)
141 cfg, errs := LoadConfig(home, "")
142 if len(errs) != 0 {
143 t.Fatalf("disabled servers must not produce errors: %v", errs)
144 }
145 if got := sortedNames(cfg.Servers); !reflect.DeepEqual(got, []string{"on"}) {
146 t.Fatalf("servers = %v", got)
147 }
148}
149
150func TestLoadConfigProjectDisablesGlobalServer(t *testing.T) {
151 home, cwd := t.TempDir(), t.TempDir()
152 writeFile(t, GlobalConfigPath(home), `{"mcpServers":{"s":{"command":"c"}}}`)
153 writeFile(t, ProjectConfigPath(cwd), `{"mcpServers":{"s":{"command":"c","enabled":false}}}`)
154 cfg, errs := LoadConfig(home, cwd)
155 if len(errs) != 0 || len(cfg.Servers) != 0 {
156 t.Fatalf("cfg=%v errs=%v", cfg.Servers, errs)
157 }
158}
159
160func TestLoadConfigInvalidShapes(t *testing.T) {
161 cases := map[string]struct {
162 body string
163 want string
164 }{
165 "stdio-no-command": {`{"command":" "}`, "requires command"},
166 "empty": {`{}`, "requires command"},
167 "typed-stdio-no-cmd": {`{"type":"stdio","url":"http://x"}`, "requires command"},
168 "http-no-url": {`{"type":"http"}`, "requires url"},
169 "sse": {`{"type":"sse","url":"http://127.0.0.1/sse"}`, "sse"},
170 "sse-transport": {`{"transport":"sse","url":"http://127.0.0.1/sse"}`, "sse"},
171 "unknown": {`{"type":"carrier-pigeon","command":"c"}`, "unknown transport"},
172 "bad-scheme": {`{"url":"ftp://x/y"}`, "http:// or https://"},
173 "stdio-with-url": {`{"command":"c","url":"http://x"}`, "only to http"},
174 "stdio-with-header": {`{"type":"stdio","command":"c","headers":{"A":"b"}}`, "only to http"},
175 "stdio-with-bearer": {`{"command":"c","bearerTokenEnvVar":"X"}`, "only to http"},
176 "http-with-args": {`{"type":"http","url":"http://x","args":["a"]}`, "only to stdio"},
177 "http-with-env": {`{"url":"http://x","env":{"A":"b"}}`, "only to stdio"},
178 "http-with-cwd": {`{"url":"http://x","cwd":"/tmp"}`, "only to stdio"},
179 }
180 for name, c := range cases {
181 t.Run(name, func(t *testing.T) {
182 home := t.TempDir()
183 writeFile(t, GlobalConfigPath(home), `{"mcpServers":{"bad":`+c.body+`,"fine":{"command":"c"}}}`)
184 cfg, errs := LoadConfig(home, "")
185 if _, ok := cfg.Servers["bad"]; ok {
186 t.Error("invalid server should be dropped")
187 }
188 if _, ok := cfg.Servers["fine"]; !ok {
189 t.Error("valid sibling should be kept")
190 }
191 if len(errs) != 1 || !strings.Contains(errs[0].Error(), c.want) {
192 t.Errorf("errs = %v, want substring %q", errs, c.want)
193 }
194 })
195 }
196}
197
198func TestLoadConfigTransportAliases(t *testing.T) {
199 home := t.TempDir()
200 writeFile(t, GlobalConfigPath(home), `{"mcpServers":{
201 "a":{"type":"streamable-http","url":"http://127.0.0.1:1"},
202 "b":{"transport":"http","url":"https://example.invalid/mcp"},
203 "c":{"type":"stdio","command":"x"}}}`)
204 cfg, errs := LoadConfig(home, "")
205 if len(errs) != 0 {
206 t.Fatal(errs)
207 }
208 for name, want := range map[string]string{"a": "http", "b": "http", "c": "stdio"} {
209 if got := cfg.Servers[name].Kind(); got != want {
210 t.Errorf("%s kind = %q, want %q", name, got, want)
211 }
212 }
213}
214
215func TestValidateServerAllowsUnexpandedURL(t *testing.T) {
216 if _, err := ValidateServer("s", ServerConfig{URL: "${MCP_URL}"}); err != nil {
217 t.Errorf("unexpanded url reference should validate: %v", err)
218 }
219 if _, err := ValidateServer("s", ServerConfig{Type: "http", URL: "nope"}); err == nil {
220 t.Error("non-http url should be rejected")
221 }
222 if _, err := ValidateServer(" ", ServerConfig{Command: "c"}); err == nil {
223 t.Error("empty name should be rejected")
224 }
225}
226
227func TestServerConfigHelpers(t *testing.T) {
228 var c ServerConfig
229 if c.StartupTimeout() != DefaultStartupTimeout || c.ToolTimeout() != DefaultToolTimeout {
230 t.Error("defaults wrong")
231 }
232 c = ServerConfig{StartupTimeoutSec: 1.5, ToolTimeoutSec: 0.25}
233 if c.StartupTimeout() != 1500*time.Millisecond || c.ToolTimeout() != 250*time.Millisecond {
234 t.Errorf("custom timeouts wrong: %v %v", c.StartupTimeout(), c.ToolTimeout())
235 }
236 c = ServerConfig{EnabledTools: []string{"a", "b"}, DisabledTools: []string{"b"}}
237 for name, want := range map[string]bool{"a": true, "b": false, "c": false} {
238 if got := c.ToolAllowed(name); got != want {
239 t.Errorf("ToolAllowed(%q) = %v, want %v", name, got, want)
240 }
241 }
242 if !(ServerConfig{}).ToolAllowed("anything") {
243 t.Error("no filters should allow everything")
244 }
245 if !(ServerConfig{DisabledTools: []string{"x"}}).ToolAllowed("y") {
246 t.Error("disabledTools only blocks listed names")
247 }
248}
249
250func TestWriteConfigFileRoundTripAndPerms(t *testing.T) {
251 path := filepath.Join(t.TempDir(), "nested", "dir", "mcp.json")
252 on := true
253 want := Config{Servers: map[string]ServerConfig{
254 "s": {Command: "cmd", Args: []string{"a", "b"}, Env: map[string]string{"K": "${V}"}, EnvVars: []string{"HOME"},
255 Required: true, EnabledTools: []string{"x"}, StartupTimeoutSec: 2, ToolTimeoutSec: 3, Enabled: &on},
256 "h": {Type: "http", URL: "https://example.invalid", Headers: map[string]string{"A": "b"},
257 EnvHeaders: map[string]string{"X": "Y"}, BearerTokenEnvVar: "TOK"},
258 }}
259 if err := WriteConfigFile(path, want); err != nil {
260 t.Fatal(err)
261 }
262 if runtime.GOOS != "windows" {
263 st, err := os.Stat(path)
264 if err != nil {
265 t.Fatal(err)
266 }
267 if perm := st.Mode().Perm(); perm != 0o600 {
268 t.Errorf("perm = %o, want 600", perm)
269 }
270 }
271 got, err := ReadConfigFile(path)
272 if err != nil {
273 t.Fatal(err)
274 }
275 if !reflect.DeepEqual(got, want) {
276 gj, _ := json.Marshal(got)
277 wj, _ := json.Marshal(want)
278 t.Errorf("round trip mismatch:\n got %s\nwant %s", gj, wj)
279 }
280 // Overwrite replaces atomically and leaves no temp files behind.
281 if err := WriteConfigFile(path, Config{}); err != nil {
282 t.Fatal(err)
283 }
284 got, err = ReadConfigFile(path)
285 if err != nil || len(got.Servers) != 0 || got.Servers == nil {
286 t.Errorf("empty config: %v %v", got, err)
287 }
288 entries, _ := os.ReadDir(filepath.Dir(path))
289 if len(entries) != 1 {
290 t.Errorf("leftover files in config dir: %v", entries)
291 }
292}
293
294func TestBuildStdioEnv(t *testing.T) {
295 t.Setenv("SECRET_API_KEY", "do-not-leak")
296 t.Setenv("MCP_T_EXTRA", "passed")
297 t.Setenv("PATH", "/usr/bin:/bin")
298 t.Setenv("MCP_T_OVERRIDE", "host")
299 env := buildStdioEnv(ServerConfig{
300 EnvVars: []string{"MCP_T_EXTRA", "MCP_T_NOT_SET", "MCP_T_OVERRIDE"},
301 Env: map[string]string{"EXPLICIT": "yes", "MCP_T_OVERRIDE": "cfg"},
302 })
303 joined := strings.Join(env, "\n")
304 if strings.Contains(joined, "SECRET_API_KEY") || strings.Contains(joined, "do-not-leak") {
305 t.Errorf("non-allow-listed host variable leaked: %v", env)
306 }
307 want := map[string]bool{"PATH=/usr/bin:/bin": false, "MCP_T_EXTRA=passed": false, "EXPLICIT=yes": false, "MCP_T_OVERRIDE=cfg": false}
308 for _, e := range env {
309 if _, ok := want[e]; ok {
310 want[e] = true
311 }
312 if strings.HasPrefix(e, "MCP_T_NOT_SET") {
313 t.Errorf("unset variable should not be passed: %s", e)
314 }
315 if e == "MCP_T_OVERRIDE=host" {
316 t.Errorf("cfg.Env must override host value")
317 }
318 }
319 for e, ok := range want {
320 if !ok {
321 t.Errorf("missing %q in %v", e, env)
322 }
323 }
324 // Deterministic order.
325 for i := 1; i < len(env); i++ {
326 if env[i-1] > env[i] {
327 t.Errorf("env not sorted: %v", env)
328 break
329 }
330 }
331}