| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | fileencoding "reasonix/internal/fileutil/encoding" |
| 14 | ) |
| 15 | |
| 16 | func TestLoadMCPJSON(t *testing.T) { |
| 17 | dir := t.TempDir() |
| 18 | path := filepath.Join(dir, mcpJSONFile) |
| 19 | doc := `{ |
| 20 | "mcpServers": { |
| 21 | "stripe": { |
| 22 | "type": "http", |
| 23 | "url": "https://mcp.stripe.com", |
| 24 | "headers": { "Authorization": "Bearer ${STRIPE_KEY}" } |
| 25 | }, |
| 26 | "filesystem": { |
| 27 | "command": "npx", |
| 28 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], |
| 29 | "env": { "FOO": "bar" } |
| 30 | } |
| 31 | } |
| 32 | }` |
| 33 | if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | |
| 37 | got, err := loadMCPJSON(path) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | // Sorted by name: filesystem before stripe. |
| 42 | if len(got) != 2 || got[0].Name != "filesystem" || got[1].Name != "stripe" { |
| 43 | t.Fatalf("entries = %+v, want [filesystem stripe] sorted", got) |
| 44 | } |
| 45 | fs := got[0] |
| 46 | if fs.Command != "npx" || len(fs.Args) != 3 || fs.Env["FOO"] != "bar" { |
| 47 | t.Errorf("filesystem decoded wrong: %+v", fs) |
| 48 | } |
| 49 | if fs.Source != MCPSourceProjectMCPJSON { |
| 50 | t.Errorf("filesystem source = %q, want project .mcp.json", fs.Source) |
| 51 | } |
| 52 | st := got[1] |
| 53 | if st.Type != "http" || st.URL != "https://mcp.stripe.com" || |
| 54 | st.Headers["Authorization"] != "Bearer ${STRIPE_KEY}" { |
| 55 | t.Errorf("stripe decoded wrong: %+v", st) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestLoadMCPJSONDecodesGB18030(t *testing.T) { |
| 60 | dir := t.TempDir() |
| 61 | path := filepath.Join(dir, mcpJSONFile) |
| 62 | doc := `{"mcpServers":{"local":{"command":"工具.exe","env":{"LABEL":"中文"}}}}` |
| 63 | if err := os.WriteFile(path, fileencoding.Encode(doc, fileencoding.GB18030), 0o644); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | |
| 67 | got, err := loadMCPJSON(path) |
| 68 | if err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | if len(got) != 1 || got[0].Command != "工具.exe" || got[0].Env["LABEL"] != "中文" { |
| 72 | t.Fatalf("decoded .mcp.json entries = %+v", got) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestMCPJSONDropsRemovedTrustedReadOnlyToolsSetting(t *testing.T) { |
| 77 | dir := t.TempDir() |
| 78 | path := filepath.Join(dir, mcpJSONFile) |
| 79 | if err := os.WriteFile(path, []byte(`{"mcpServers":{"github":{"command":"old","trusted_read_only_tools":["issue_read"]}}}`), 0o644); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if _, err := UpsertMCPJSONPlugin(path, PluginEntry{ |
| 83 | Name: "github", |
| 84 | Command: "npx", |
| 85 | Args: []string{"-y", "@modelcontextprotocol/server-github"}, |
| 86 | }); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | body, err := os.ReadFile(path) |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | if strings.Contains(string(body), "trusted_read_only_tools") { |
| 94 | t.Fatalf("updated .mcp.json retained removed reader setting:\n%s", body) |
| 95 | } |
| 96 | got, err := loadMCPJSON(path) |
| 97 | if err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | if len(got) != 1 { |
| 101 | t.Fatalf("entries = %+v, want one github entry", got) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestMCPJSONCallTimeoutsRoundTrip(t *testing.T) { |
| 106 | dir := t.TempDir() |
| 107 | path := filepath.Join(dir, mcpJSONFile) |
| 108 | if err := os.WriteFile(path, []byte(`{ |
| 109 | "mcpServers": { |
| 110 | "maker": { |
| 111 | "command": "old-maker", |
| 112 | "unknown_field": true |
| 113 | } |
| 114 | } |
| 115 | }`), 0o644); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | if _, err := UpsertMCPJSONPlugin(path, PluginEntry{ |
| 119 | Name: "maker", |
| 120 | Command: "maker-mcp", |
| 121 | StartupTimeoutSeconds: 60, |
| 122 | CallTimeoutSeconds: 600, |
| 123 | ToolTimeoutSeconds: map[string]int{ |
| 124 | "generate/video": 1800, |
| 125 | "search": 120, |
| 126 | "ignored_zero": 0, |
| 127 | }, |
| 128 | }); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | got, err := loadMCPJSON(path) |
| 132 | if err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | if len(got) != 1 { |
| 136 | t.Fatalf("entries = %+v, want one maker entry", got) |
| 137 | } |
| 138 | if got[0].CallTimeoutSeconds != 600 { |
| 139 | t.Fatalf("call_timeout_seconds = %d, want 600", got[0].CallTimeoutSeconds) |
| 140 | } |
| 141 | if got[0].StartupTimeoutSeconds != 60 { |
| 142 | t.Fatalf("startup_timeout_seconds = %d, want 60", got[0].StartupTimeoutSeconds) |
| 143 | } |
| 144 | if got[0].ToolTimeoutSeconds["generate/video"] != 1800 || got[0].ToolTimeoutSeconds["search"] != 120 { |
| 145 | t.Fatalf("tool_timeout_seconds = %+v, want generate/video=1800 search=120", got[0].ToolTimeoutSeconds) |
| 146 | } |
| 147 | if _, ok := got[0].ToolTimeoutSeconds["ignored_zero"]; ok { |
| 148 | t.Fatalf("zero timeout should not be written: %+v", got[0].ToolTimeoutSeconds) |
| 149 | } |
| 150 | |
| 151 | root, servers, err := readMCPJSONRaw(path) |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | if len(root) == 0 || len(servers) != 1 { |
| 156 | t.Fatalf("raw root/servers = %+v/%+v", root, servers) |
| 157 | } |
| 158 | var server map[string]any |
| 159 | if err := json.Unmarshal(servers["maker"], &server); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | if server["unknown_field"] != true { |
| 163 | t.Fatalf("unknown per-server field was not preserved: %+v", server) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func TestMCPJSONUpdateRemovesRetiredApprovalFieldsAndPreservesUnknownFields(t *testing.T) { |
| 168 | path := filepath.Join(t.TempDir(), mcpJSONFile) |
| 169 | if err := os.WriteFile(path, []byte(`{ |
| 170 | "mcpServers": { |
| 171 | "admin": { |
| 172 | "command": "old-admin-mcp", |
| 173 | "future_server_field": {"version": 2}, |
| 174 | "tools": { |
| 175 | "wipe": {"approval_mode": "prompt", "enabled": false, "future": {"audit": true}}, |
| 176 | "external_only": {"enabled": false}, |
| 177 | "remove_keep": {"approval_mode": "writes", "enabled": true}, |
| 178 | "remove_entirely": {"approval_mode": "approve"} |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | }`), 0o644); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | |
| 186 | if _, err := UpsertMCPJSONPlugin(path, PluginEntry{Name: "admin", Command: "admin-mcp"}); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | |
| 190 | root, servers, err := readMCPJSONRaw(path) |
| 191 | if err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | if len(root) == 0 { |
| 195 | t.Fatal("raw root is empty") |
| 196 | } |
| 197 | var server map[string]json.RawMessage |
| 198 | if err := json.Unmarshal(servers["admin"], &server); err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | if _, ok := server["future_server_field"]; !ok { |
| 202 | t.Fatal("unknown per-server field was removed") |
| 203 | } |
| 204 | var tools map[string]map[string]json.RawMessage |
| 205 | if err := json.Unmarshal(server["tools"], &tools); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | if len(tools) != 3 { |
| 209 | t.Fatalf("raw tools = %+v, want wipe, external_only, and remove_keep", tools) |
| 210 | } |
| 211 | if _, ok := tools["wipe"]["enabled"]; !ok { |
| 212 | t.Fatal("known tool lost external enabled field") |
| 213 | } |
| 214 | if _, ok := tools["wipe"]["future"]; !ok { |
| 215 | t.Fatal("known tool lost future nested field") |
| 216 | } |
| 217 | if _, ok := tools["external_only"]; !ok { |
| 218 | t.Fatal("unknown-only tool entry was removed") |
| 219 | } |
| 220 | if _, ok := tools["remove_keep"]["approval_mode"]; ok { |
| 221 | t.Fatal("removed Reasonix approval mode survived") |
| 222 | } |
| 223 | if _, ok := tools["remove_keep"]["enabled"]; !ok { |
| 224 | t.Fatal("removing approval mode removed external fields") |
| 225 | } |
| 226 | if _, ok := tools["remove_entirely"]; ok { |
| 227 | t.Fatal("approval-only entry should be removed when its policy is cleared") |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | func TestNormalizePluginCommandLine(t *testing.T) { |
| 232 | cases := []struct { |
| 233 | name string |
| 234 | in PluginEntry |
| 235 | wantCommand string |
| 236 | wantArgs []string |
| 237 | wantChanged bool |
| 238 | }{ |
| 239 | { |
| 240 | name: "npx pasted with args", |
| 241 | in: PluginEntry{Name: "playwright", Command: "npx -y @playwright/mcp"}, |
| 242 | wantCommand: "npx", |
| 243 | wantArgs: []string{"-y", "@playwright/mcp"}, |
| 244 | wantChanged: true, |
| 245 | }, |
| 246 | { |
| 247 | name: "custom command pasted with args", |
| 248 | in: PluginEntry{Name: "custom", Command: "custom-mcp --stdio"}, |
| 249 | wantCommand: "custom-mcp", |
| 250 | wantArgs: []string{"--stdio"}, |
| 251 | wantChanged: true, |
| 252 | }, |
| 253 | { |
| 254 | name: "quoted command path", |
| 255 | in: PluginEntry{Name: "quoted", Command: `"C:\Program Files\nodejs\npx.cmd" -y @example/mcp`}, |
| 256 | wantCommand: `C:\Program Files\nodejs\npx.cmd`, |
| 257 | wantArgs: []string{"-y", "@example/mcp"}, |
| 258 | wantChanged: true, |
| 259 | }, |
| 260 | { |
| 261 | name: "empty quoted arg preserved", |
| 262 | in: PluginEntry{Name: "empty", Command: `npx --token "" @example/mcp`}, |
| 263 | wantCommand: "npx", |
| 264 | wantArgs: []string{"--token", "", "@example/mcp"}, |
| 265 | wantChanged: true, |
| 266 | }, |
| 267 | { |
| 268 | name: "quoted arg with spaces preserved", |
| 269 | in: PluginEntry{Name: "quoted-arg", Command: `npx --label "My Server" @example/mcp`}, |
| 270 | wantCommand: "npx", |
| 271 | wantArgs: []string{"--label", "My Server", "@example/mcp"}, |
| 272 | wantChanged: true, |
| 273 | }, |
| 274 | { |
| 275 | name: "shell control syntax untouched", |
| 276 | in: PluginEntry{Name: "control", Command: `npx @example/mcp && rm -rf tmp`}, |
| 277 | wantCommand: "npx @example/mcp && rm -rf tmp", |
| 278 | wantChanged: false, |
| 279 | }, |
| 280 | { |
| 281 | name: "unquoted command path with spaces stays literal", |
| 282 | in: PluginEntry{Name: "literal", Command: `C:\Program Files\nodejs\npx.cmd`}, |
| 283 | wantCommand: `C:\Program Files\nodejs\npx.cmd`, |
| 284 | wantChanged: false, |
| 285 | }, |
| 286 | { |
| 287 | name: "remote entry untouched", |
| 288 | in: PluginEntry{Name: "remote", Type: "http", URL: "https://mcp.example.com/mcp", Command: "npx -y nope"}, |
| 289 | wantCommand: "npx -y nope", |
| 290 | wantChanged: false, |
| 291 | }, |
| 292 | } |
| 293 | for _, tc := range cases { |
| 294 | t.Run(tc.name, func(t *testing.T) { |
| 295 | got, changed := NormalizePluginCommandLine(tc.in) |
| 296 | if changed != tc.wantChanged { |
| 297 | t.Fatalf("changed = %v, want %v", changed, tc.wantChanged) |
| 298 | } |
| 299 | if got.Command != tc.wantCommand { |
| 300 | t.Fatalf("command = %q, want %q", got.Command, tc.wantCommand) |
| 301 | } |
| 302 | if strings.Join(got.Args, "\x00") != strings.Join(tc.wantArgs, "\x00") { |
| 303 | t.Fatalf("args = %v, want %v", got.Args, tc.wantArgs) |
| 304 | } |
| 305 | }) |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | func TestParseLegacyMCPSpecSplitsCustomCommandArgs(t *testing.T) { |
| 310 | got, ok := parseLegacyMCPSpec("fs=custom-mcp --stdio") |
| 311 | if !ok { |
| 312 | t.Fatal("parseLegacyMCPSpec returned false") |
| 313 | } |
| 314 | if got.Name != "fs" || got.Command != "custom-mcp" || strings.Join(got.Args, "\x00") != "--stdio" { |
| 315 | t.Fatalf("legacy custom MCP spec = %+v, want name fs command custom-mcp args [--stdio]", got) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | func TestUpsertPluginNormalizesPastedCommandLine(t *testing.T) { |
| 320 | cfg := &Config{} |
| 321 | if err := cfg.UpsertPlugin(PluginEntry{Name: "playwright", Command: "npx -y @playwright/mcp"}); err != nil { |
| 322 | t.Fatal(err) |
| 323 | } |
| 324 | if got := cfg.Plugins[0].Command; got != "npx" { |
| 325 | t.Fatalf("command = %q, want npx", got) |
| 326 | } |
| 327 | if got := cfg.Plugins[0].Args; len(got) != 2 || got[0] != "-y" || got[1] != "@playwright/mcp" { |
| 328 | t.Fatalf("args = %v, want [-y @playwright/mcp]", got) |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | func TestLoadMCPJSONAbsentAndMalformed(t *testing.T) { |
| 333 | dir := t.TempDir() |
| 334 | |
| 335 | // Absent file: not an error, no entries. |
| 336 | got, err := loadMCPJSON(filepath.Join(dir, "missing.json")) |
| 337 | if err != nil || got != nil { |
| 338 | t.Errorf("absent file: got (%v, %v), want (nil, nil)", got, err) |
| 339 | } |
| 340 | |
| 341 | // Malformed file: an error so a typo surfaces instead of dropping servers. |
| 342 | bad := filepath.Join(dir, mcpJSONFile) |
| 343 | if err := os.WriteFile(bad, []byte("{not json"), 0o644); err != nil { |
| 344 | t.Fatal(err) |
| 345 | } |
| 346 | if _, err := loadMCPJSON(bad); err == nil { |
| 347 | t.Error("malformed .mcp.json: want error, got nil") |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | func TestLoadMergesMCPJSON(t *testing.T) { |
| 352 | // Point the user-config and home dirs at an empty temp dir so Load picks up |
| 353 | // no global config, then chdir into a project dir holding both files. |
| 354 | empty := t.TempDir() |
| 355 | t.Setenv("HOME", empty) |
| 356 | t.Setenv("XDG_CONFIG_HOME", empty) |
| 357 | t.Chdir(t.TempDir()) |
| 358 | |
| 359 | toml := `[[plugins]] |
| 360 | name = "shared" |
| 361 | command = "local-bin" |
| 362 | ` |
| 363 | if err := os.WriteFile("reasonix.toml", []byte(toml), 0o644); err != nil { |
| 364 | t.Fatal(err) |
| 365 | } |
| 366 | mcp := `{ "mcpServers": { |
| 367 | "shared": { "type": "http", "url": "https://override.example" }, |
| 368 | "extra": { "command": "extra-bin", "auto_start": false } |
| 369 | } }` |
| 370 | if err := os.WriteFile(mcpJSONFile, []byte(mcp), 0o644); err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | |
| 374 | cfg, err := Load() |
| 375 | if err != nil { |
| 376 | t.Fatal(err) |
| 377 | } |
| 378 | byName := map[string]PluginEntry{} |
| 379 | for _, p := range cfg.Plugins { |
| 380 | byName[p.Name] = p |
| 381 | } |
| 382 | if len(byName) != 2 { |
| 383 | t.Fatalf("plugins = %+v, want shared + extra", cfg.Plugins) |
| 384 | } |
| 385 | if byName["shared"].Command != "local-bin" || byName["shared"].URL != "" { |
| 386 | t.Errorf("reasonix.toml should win the collision, got %+v", byName["shared"]) |
| 387 | } |
| 388 | if byName["extra"].Command != "extra-bin" { |
| 389 | t.Errorf("extra not merged from .mcp.json, got %+v", byName["extra"]) |
| 390 | } |
| 391 | if byName["extra"].AutoStart == nil || *byName["extra"].AutoStart { |
| 392 | t.Errorf("extra auto_start=false not preserved, got %+v", byName["extra"].AutoStart) |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | func TestLoadMergesPluginsAcrossTOMLSources(t *testing.T) { |
| 397 | root := t.TempDir() |
| 398 | t.Setenv("HOME", root) |
| 399 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg")) |
| 400 | t.Setenv("AppData", filepath.Join(root, "AppData")) // os.UserConfigDir reads AppData on Windows |
| 401 | t.Chdir(t.TempDir()) |
| 402 | |
| 403 | gpath := UserConfigPath() |
| 404 | if gpath == "" { |
| 405 | t.Fatal("UserConfigPath empty under isolated env") |
| 406 | } |
| 407 | if err := os.MkdirAll(filepath.Dir(gpath), 0o755); err != nil { |
| 408 | t.Fatal(err) |
| 409 | } |
| 410 | if err := os.WriteFile(gpath, []byte("[[plugins]]\nname = \"globalmcp\"\ncommand = \"global-bin\"\n"), 0o644); err != nil { |
| 411 | t.Fatal(err) |
| 412 | } |
| 413 | if err := os.WriteFile("reasonix.toml", []byte("[[plugins]]\nname = \"projectmcp\"\ncommand = \"project-bin\"\n"), 0o644); err != nil { |
| 414 | t.Fatal(err) |
| 415 | } |
| 416 | |
| 417 | cfg, err := Load() |
| 418 | if err != nil { |
| 419 | t.Fatal(err) |
| 420 | } |
| 421 | names := map[string]bool{} |
| 422 | sources := map[string]MCPConfigSource{} |
| 423 | for _, p := range cfg.Plugins { |
| 424 | names[p.Name] = true |
| 425 | sources[p.Name] = p.Source |
| 426 | } |
| 427 | if !names["globalmcp"] || !names["projectmcp"] { |
| 428 | t.Fatalf("a project reasonix.toml [[plugins]] dropped the global config's server; got %+v", cfg.Plugins) |
| 429 | } |
| 430 | if sources["globalmcp"] != MCPSourceUserConfig || sources["projectmcp"] != MCPSourceProjectConfig { |
| 431 | t.Fatalf("plugin provenance = %+v", sources) |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | func TestLoadProjectMCPPriorityIsReasonixThenMCPJSONThenGlobal(t *testing.T) { |
| 436 | _, userConfig, _ := legacyHome(t) |
| 437 | root := t.TempDir() |
| 438 | if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil { |
| 439 | t.Fatal(err) |
| 440 | } |
| 441 | if err := os.WriteFile(userConfig, []byte(` |
| 442 | [[plugins]] |
| 443 | name = "shared" |
| 444 | command = "global-mcp" |
| 445 | `), 0o600); err != nil { |
| 446 | t.Fatal(err) |
| 447 | } |
| 448 | if err := os.WriteFile(filepath.Join(root, mcpJSONFile), []byte(`{ |
| 449 | "mcpServers": { |
| 450 | "shared": { "command": "project-json-mcp" } |
| 451 | } |
| 452 | }`), 0o644); err != nil { |
| 453 | t.Fatal(err) |
| 454 | } |
| 455 | |
| 456 | cfg, err := LoadForRoot(root) |
| 457 | if err != nil { |
| 458 | t.Fatal(err) |
| 459 | } |
| 460 | entry, ok := pluginEntryByName(cfg.Plugins, "shared") |
| 461 | if !ok || entry.Command != "project-json-mcp" || entry.Source != MCPSourceProjectMCPJSON { |
| 462 | t.Fatalf("global + .mcp.json effective entry = %+v, want project .mcp.json", entry) |
| 463 | } |
| 464 | |
| 465 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(` |
| 466 | [[plugins]] |
| 467 | name = "shared" |
| 468 | command = "project-reasonix-mcp" |
| 469 | `), 0o644); err != nil { |
| 470 | t.Fatal(err) |
| 471 | } |
| 472 | cfg, err = LoadForRoot(root) |
| 473 | if err != nil { |
| 474 | t.Fatal(err) |
| 475 | } |
| 476 | entry, ok = pluginEntryByName(cfg.Plugins, "shared") |
| 477 | if !ok || entry.Command != "project-reasonix-mcp" || entry.Source != MCPSourceProjectConfig { |
| 478 | t.Fatalf("reasonix.toml + .mcp.json + global effective entry = %+v, want project reasonix.toml", entry) |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | func TestUpsertPluginInSourcePreservesGlobalAndProjectBoundaries(t *testing.T) { |
| 483 | _, userConfig, _ := legacyHome(t) |
| 484 | root := t.TempDir() |
| 485 | if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil { |
| 486 | t.Fatal(err) |
| 487 | } |
| 488 | if err := os.WriteFile(userConfig, []byte(` |
| 489 | [[plugins]] |
| 490 | name = "global" |
| 491 | command = "global-old" |
| 492 | `), 0o600); err != nil { |
| 493 | t.Fatal(err) |
| 494 | } |
| 495 | projectPath := filepath.Join(root, "reasonix.toml") |
| 496 | if err := os.WriteFile(projectPath, []byte(` |
| 497 | [[plugins]] |
| 498 | name = "project" |
| 499 | command = "project-old" |
| 500 | `), 0o644); err != nil { |
| 501 | t.Fatal(err) |
| 502 | } |
| 503 | |
| 504 | if path, err := UpsertPluginInSourceForRoot(root, PluginEntry{ |
| 505 | Name: "global", Command: "global-new", Source: MCPSourceUserConfig, |
| 506 | }); err != nil || !samePath(path, userConfig) { |
| 507 | t.Fatalf("upsert global path=%q err=%v, want %q", path, err, userConfig) |
| 508 | } |
| 509 | if path, err := UpsertPluginInSourceForRoot(root, PluginEntry{ |
| 510 | Name: "project", Command: "project-new", Source: MCPSourceProjectConfig, |
| 511 | }); err != nil || !samePath(path, projectPath) { |
| 512 | t.Fatalf("upsert project path=%q err=%v, want %q", path, err, projectPath) |
| 513 | } |
| 514 | |
| 515 | globalCfg := LoadForEdit(userConfig) |
| 516 | if entry, ok := pluginEntryByName(globalCfg.Plugins, "global"); !ok || entry.Command != "global-new" { |
| 517 | t.Fatalf("global config entry = %+v, found=%v", entry, ok) |
| 518 | } |
| 519 | if _, ok := pluginEntryByName(globalCfg.Plugins, "project"); ok { |
| 520 | t.Fatalf("project MCP leaked into global config: %+v", globalCfg.Plugins) |
| 521 | } |
| 522 | projectCfg := LoadForEdit(projectPath) |
| 523 | if entry, ok := pluginEntryByName(projectCfg.Plugins, "project"); !ok || entry.Command != "project-new" { |
| 524 | t.Fatalf("project config entry = %+v, found=%v", entry, ok) |
| 525 | } |
| 526 | if _, ok := pluginEntryByName(projectCfg.Plugins, "global"); ok { |
| 527 | t.Fatalf("global MCP leaked into project config: %+v", projectCfg.Plugins) |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | func TestRemoveEffectivePluginRevealsLowerPriorityDeclaration(t *testing.T) { |
| 532 | _, userConfig, _ := legacyHome(t) |
| 533 | root := t.TempDir() |
| 534 | if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil { |
| 535 | t.Fatal(err) |
| 536 | } |
| 537 | if err := os.WriteFile(userConfig, []byte(` |
| 538 | [[plugins]] |
| 539 | name = "shared" |
| 540 | command = "global-mcp" |
| 541 | `), 0o600); err != nil { |
| 542 | t.Fatal(err) |
| 543 | } |
| 544 | projectPath := filepath.Join(root, "reasonix.toml") |
| 545 | if err := os.WriteFile(projectPath, []byte(` |
| 546 | [[plugins]] |
| 547 | name = "shared" |
| 548 | command = "project-reasonix-mcp" |
| 549 | `), 0o644); err != nil { |
| 550 | t.Fatal(err) |
| 551 | } |
| 552 | mcpPath := filepath.Join(root, mcpJSONFile) |
| 553 | if err := os.WriteFile(mcpPath, []byte(`{ |
| 554 | "mcpServers": { |
| 555 | "shared": { "command": "project-json-mcp" } |
| 556 | } |
| 557 | }`), 0o644); err != nil { |
| 558 | t.Fatal(err) |
| 559 | } |
| 560 | |
| 561 | removed, ok, path, err := RemovePluginFromEffectiveSourceForRoot(root, "shared") |
| 562 | if err != nil || !ok || removed.Source != MCPSourceProjectConfig || !samePath(path, projectPath) { |
| 563 | t.Fatalf("remove project TOML = entry:%+v removed:%v path:%q err:%v", removed, ok, path, err) |
| 564 | } |
| 565 | cfg, err := LoadForRoot(root) |
| 566 | if err != nil { |
| 567 | t.Fatal(err) |
| 568 | } |
| 569 | entry, found := pluginEntryByName(cfg.Plugins, "shared") |
| 570 | if !found || entry.Source != MCPSourceProjectMCPJSON || entry.Command != "project-json-mcp" { |
| 571 | t.Fatalf("after removing project TOML effective entry = %+v, found=%v", entry, found) |
| 572 | } |
| 573 | |
| 574 | removed, ok, path, err = RemovePluginFromEffectiveSourceForRoot(root, "shared") |
| 575 | if err != nil || !ok || removed.Source != MCPSourceProjectMCPJSON || !samePath(path, mcpPath) { |
| 576 | t.Fatalf("remove project .mcp.json = entry:%+v removed:%v path:%q err:%v", removed, ok, path, err) |
| 577 | } |
| 578 | cfg, err = LoadForRoot(root) |
| 579 | if err != nil { |
| 580 | t.Fatal(err) |
| 581 | } |
| 582 | entry, found = pluginEntryByName(cfg.Plugins, "shared") |
| 583 | if !found || entry.Source != MCPSourceUserConfig || entry.Command != "global-mcp" { |
| 584 | t.Fatalf("after removing project sources effective entry = %+v, found=%v", entry, found) |
| 585 | } |
| 586 | } |
| 587 | |
| 588 | func TestLoadNormalizesTOMLPastedCommandLine(t *testing.T) { |
| 589 | home := t.TempDir() |
| 590 | t.Setenv("HOME", home) |
| 591 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) |
| 592 | t.Setenv("AppData", filepath.Join(home, "AppData")) |
| 593 | t.Chdir(t.TempDir()) |
| 594 | |
| 595 | if err := os.WriteFile("reasonix.toml", []byte("[[plugins]]\nname = \"playwright\"\ncommand = \"npx -y @playwright/mcp\"\n"), 0o644); err != nil { |
| 596 | t.Fatal(err) |
| 597 | } |
| 598 | cfg, err := Load() |
| 599 | if err != nil { |
| 600 | t.Fatal(err) |
| 601 | } |
| 602 | if len(cfg.Plugins) != 1 { |
| 603 | t.Fatalf("plugins = %+v", cfg.Plugins) |
| 604 | } |
| 605 | if cfg.Plugins[0].Command != "npx" { |
| 606 | t.Fatalf("command = %q, want npx", cfg.Plugins[0].Command) |
| 607 | } |
| 608 | if got := cfg.Plugins[0].Args; len(got) != 2 || got[0] != "-y" || got[1] != "@playwright/mcp" { |
| 609 | t.Fatalf("args = %v, want [-y @playwright/mcp]", got) |
| 610 | } |
| 611 | } |
| 612 | |
| 613 | func TestMergeMCPJSONPrecedence(t *testing.T) { |
| 614 | // reasonix.toml already declares "shared" (stdio); .mcp.json offers a colliding |
| 615 | // "shared" (http) plus a fresh "extra". reasonix.toml must win on the collision; |
| 616 | // "extra" gets appended. |
| 617 | cfg := &Config{Plugins: []PluginEntry{ |
| 618 | {Name: "shared", Command: "local-bin"}, |
| 619 | }} |
| 620 | cfg.mergeMCPJSON([]PluginEntry{ |
| 621 | {Name: "shared", Type: "http", URL: "https://override.example"}, |
| 622 | {Name: "extra", Command: "extra-bin"}, |
| 623 | }) |
| 624 | |
| 625 | if len(cfg.Plugins) != 2 { |
| 626 | t.Fatalf("plugins = %+v, want 2 (shared kept, extra added)", cfg.Plugins) |
| 627 | } |
| 628 | if cfg.Plugins[0].Name != "shared" || cfg.Plugins[0].Command != "local-bin" || cfg.Plugins[0].URL != "" { |
| 629 | t.Errorf("collision not won by reasonix.toml: %+v", cfg.Plugins[0]) |
| 630 | } |
| 631 | if cfg.Plugins[1].Name != "extra" || cfg.Plugins[1].Command != "extra-bin" { |
| 632 | t.Errorf("non-colliding entry not appended: %+v", cfg.Plugins[1]) |
| 633 | } |
| 634 | } |
| 635 | |
| 636 | func TestClearPluginAuthenticationInSourceUsesMCPJSON(t *testing.T) { |
| 637 | root := t.TempDir() |
| 638 | t.Setenv("HOME", root) |
| 639 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg")) |
| 640 | t.Setenv("AppData", filepath.Join(root, "AppData")) |
| 641 | t.Chdir(t.TempDir()) |
| 642 | |
| 643 | userPath := UserConfigPath() |
| 644 | if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { |
| 645 | t.Fatal(err) |
| 646 | } |
| 647 | if err := os.WriteFile(userPath, []byte("[[plugins]]\nname = \"global\"\ncommand = \"global-bin\"\n"), 0o644); err != nil { |
| 648 | t.Fatal(err) |
| 649 | } |
| 650 | mcp := `{ |
| 651 | "mcpServers": { |
| 652 | "dida": { |
| 653 | "type": "http", |
| 654 | "url": "https://mcp.dida365.com/mcp?access_token=abc&workspace=main", |
| 655 | "headers": { "Authorization": "Bearer ${DIDA_TOKEN}", "X-Org": "team" }, |
| 656 | "env": { "DIDA_TOKEN": "${DIDA_TOKEN}", "DEBUG": "1" } |
| 657 | } |
| 658 | } |
| 659 | }` |
| 660 | if err := os.WriteFile(mcpJSONFile, []byte(mcp), 0o644); err != nil { |
| 661 | t.Fatal(err) |
| 662 | } |
| 663 | |
| 664 | updated, changed, source, err := ClearPluginAuthenticationInSource("dida") |
| 665 | if err != nil { |
| 666 | t.Fatalf("ClearPluginAuthenticationInSource: %v", err) |
| 667 | } |
| 668 | if !changed { |
| 669 | t.Fatal("ClearPluginAuthenticationInSource should report changed") |
| 670 | } |
| 671 | if source != mcpJSONFile { |
| 672 | t.Fatalf("source = %q, want %q", source, mcpJSONFile) |
| 673 | } |
| 674 | if updated.URL != "https://mcp.dida365.com/mcp?workspace=main" { |
| 675 | t.Fatalf("updated URL = %q", updated.URL) |
| 676 | } |
| 677 | |
| 678 | userRaw, err := os.ReadFile(userPath) |
| 679 | if err != nil { |
| 680 | t.Fatal(err) |
| 681 | } |
| 682 | if strings.Contains(string(userRaw), "dida") { |
| 683 | t.Fatalf("user config should not receive .mcp.json server:\n%s", userRaw) |
| 684 | } |
| 685 | entries, err := loadMCPJSON(mcpJSONFile) |
| 686 | if err != nil { |
| 687 | t.Fatal(err) |
| 688 | } |
| 689 | if len(entries) != 1 { |
| 690 | t.Fatalf("entries = %+v, want one dida entry", entries) |
| 691 | } |
| 692 | got := entries[0] |
| 693 | if got.URL != "https://mcp.dida365.com/mcp?workspace=main" { |
| 694 | t.Fatalf(".mcp.json URL = %q", got.URL) |
| 695 | } |
| 696 | if _, ok := got.Headers["Authorization"]; ok { |
| 697 | t.Fatalf("auth header should be removed: %+v", got.Headers) |
| 698 | } |
| 699 | if got.Headers["X-Org"] != "team" { |
| 700 | t.Fatalf("ordinary header should be preserved: %+v", got.Headers) |
| 701 | } |
| 702 | if _, ok := got.Env["DIDA_TOKEN"]; ok { |
| 703 | t.Fatalf("auth env should be removed: %+v", got.Env) |
| 704 | } |
| 705 | if got.Env["DEBUG"] != "1" { |
| 706 | t.Fatalf("ordinary env should be preserved: %+v", got.Env) |
| 707 | } |
| 708 | } |
| 709 | |
| 710 | func TestClearPluginAuthenticationInSourcePrefersTOML(t *testing.T) { |
| 711 | root := t.TempDir() |
| 712 | t.Setenv("HOME", root) |
| 713 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg")) |
| 714 | t.Setenv("AppData", filepath.Join(root, "AppData")) |
| 715 | t.Chdir(t.TempDir()) |
| 716 | |
| 717 | if err := os.WriteFile("reasonix.toml", []byte(`[[plugins]] |
| 718 | name = "dida" |
| 719 | type = "http" |
| 720 | url = "https://reasonix.example/mcp?access_token=toml" |
| 721 | [plugins.headers] |
| 722 | Authorization = "Bearer ${TOML_TOKEN}" |
| 723 | `), 0o644); err != nil { |
| 724 | t.Fatal(err) |
| 725 | } |
| 726 | mcp := `{ "mcpServers": { |
| 727 | "dida": { |
| 728 | "type": "http", |
| 729 | "url": "https://mcp-json.example/mcp?access_token=json", |
| 730 | "headers": { "Authorization": "Bearer ${JSON_TOKEN}" } |
| 731 | } |
| 732 | } }` |
| 733 | if err := os.WriteFile(mcpJSONFile, []byte(mcp), 0o644); err != nil { |
| 734 | t.Fatal(err) |
| 735 | } |
| 736 | |
| 737 | updated, changed, source, err := ClearPluginAuthenticationInSource("dida") |
| 738 | if err != nil { |
| 739 | t.Fatalf("ClearPluginAuthenticationInSource: %v", err) |
| 740 | } |
| 741 | if !changed { |
| 742 | t.Fatal("ClearPluginAuthenticationInSource should report changed") |
| 743 | } |
| 744 | if source != "reasonix.toml" { |
| 745 | t.Fatalf("source = %q, want reasonix.toml", source) |
| 746 | } |
| 747 | if updated.URL != "https://reasonix.example/mcp" { |
| 748 | t.Fatalf("updated URL = %q", updated.URL) |
| 749 | } |
| 750 | |
| 751 | projectRaw, err := os.ReadFile("reasonix.toml") |
| 752 | if err != nil { |
| 753 | t.Fatal(err) |
| 754 | } |
| 755 | if strings.Contains(string(projectRaw), "access_token=toml") || strings.Contains(string(projectRaw), "Authorization") { |
| 756 | t.Fatalf("reasonix.toml auth material should be removed:\n%s", projectRaw) |
| 757 | } |
| 758 | mcpRaw, err := os.ReadFile(mcpJSONFile) |
| 759 | if err != nil { |
| 760 | t.Fatal(err) |
| 761 | } |
| 762 | if !strings.Contains(string(mcpRaw), "access_token=json") { |
| 763 | t.Fatalf(".mcp.json collision entry should be left untouched:\n%s", mcpRaw) |
| 764 | } |
| 765 | } |
| 766 | |
| 767 | func TestClearPluginAuthenticationInSourceForRootDoesNotFollowWorkingDirectory(t *testing.T) { |
| 768 | home := t.TempDir() |
| 769 | t.Setenv("HOME", home) |
| 770 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) |
| 771 | t.Setenv("AppData", filepath.Join(home, "AppData")) |
| 772 | rootA := t.TempDir() |
| 773 | rootB := t.TempDir() |
| 774 | write := func(root, token string) { |
| 775 | t.Helper() |
| 776 | raw := fmt.Sprintf(`[[plugins]] |
| 777 | name = "dida" |
| 778 | type = "http" |
| 779 | url = "https://example.test/mcp?access_token=%s&workspace=main" |
| 780 | `, token) |
| 781 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(raw), 0o644); err != nil { |
| 782 | t.Fatal(err) |
| 783 | } |
| 784 | } |
| 785 | write(rootA, "root-a") |
| 786 | write(rootB, "root-b") |
| 787 | t.Chdir(rootB) |
| 788 | |
| 789 | updated, changed, source, err := ClearPluginAuthenticationInSourceForRoot(rootA, "dida") |
| 790 | if err != nil { |
| 791 | t.Fatalf("ClearPluginAuthenticationInSourceForRoot: %v", err) |
| 792 | } |
| 793 | if !changed || updated.URL != "https://example.test/mcp?workspace=main" { |
| 794 | t.Fatalf("updated = %+v, changed = %v", updated, changed) |
| 795 | } |
| 796 | if want := filepath.Join(rootA, "reasonix.toml"); !samePath(source, want) { |
| 797 | t.Fatalf("source = %q, want %q", source, want) |
| 798 | } |
| 799 | rootBRaw, err := os.ReadFile(filepath.Join(rootB, "reasonix.toml")) |
| 800 | if err != nil { |
| 801 | t.Fatal(err) |
| 802 | } |
| 803 | if !strings.Contains(string(rootBRaw), "access_token=root-b") { |
| 804 | t.Fatalf("non-target workspace was modified:\n%s", rootBRaw) |
| 805 | } |
| 806 | } |
| 807 | |
| 808 | func TestLoadLegacyMCP(t *testing.T) { |
| 809 | dir := t.TempDir() |
| 810 | path := filepath.Join(dir, "config.json") |
| 811 | doc := `{ |
| 812 | "mcpServers": { |
| 813 | "github": { "command": "npx", "args": ["-y", "server-github"], "env": { "TOKEN": "x" } }, |
| 814 | "old": { "command": "foo" }, |
| 815 | "remote": { "type": "sse", "url": "https://x/sse", "headers": { "Authorization": "Bearer y" } } |
| 816 | }, |
| 817 | "mcpDisabled": ["old"], |
| 818 | "projects": { "/some/root": { "shellAllowed": [] } } |
| 819 | }` |
| 820 | if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { |
| 821 | t.Fatal(err) |
| 822 | } |
| 823 | |
| 824 | got := loadLegacyMCP(path) |
| 825 | // "old" is in mcpDisabled and dropped; github + remote remain, name-sorted. |
| 826 | if len(got) != 2 { |
| 827 | t.Fatalf("got %d entries, want 2: %+v", len(got), got) |
| 828 | } |
| 829 | if got[0].Name != "github" || got[1].Name != "remote" { |
| 830 | t.Fatalf("names = %q, %q; want github, remote", got[0].Name, got[1].Name) |
| 831 | } |
| 832 | if got[0].Command != "npx" || got[0].Env["TOKEN"] != "x" { |
| 833 | t.Errorf("github mapped wrong: %+v", got[0]) |
| 834 | } |
| 835 | if got[1].Type != "sse" || got[1].URL != "https://x/sse" || got[1].Headers["Authorization"] != "Bearer y" { |
| 836 | t.Errorf("remote mapped wrong: %+v", got[1]) |
| 837 | } |
| 838 | |
| 839 | doc = `{ |
| 840 | "mcp": [ |
| 841 | "memory=npx -y @modelcontextprotocol/server-memory", |
| 842 | "remote=https://x/sse", |
| 843 | "stream=streamable+https://x/http", |
| 844 | "github=node dupe.js", |
| 845 | "off=npx server-off", |
| 846 | "uvx run anonymous-server" |
| 847 | ], |
| 848 | "mcpServers": { "github": { "command": "npx" } }, |
| 849 | "mcpEnv": { "memory": { "MEMORY_PATH": "/tmp/mem" } }, |
| 850 | "mcpDisabled": ["off"] |
| 851 | }` |
| 852 | if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { |
| 853 | t.Fatal(err) |
| 854 | } |
| 855 | got = loadLegacyMCP(path) |
| 856 | byName := map[string]PluginEntry{} |
| 857 | for _, e := range got { |
| 858 | byName[e.Name] = e |
| 859 | } |
| 860 | if m := byName["memory"]; m.Command != "npx" || m.Env["MEMORY_PATH"] != "/tmp/mem" { |
| 861 | t.Errorf("legacy mcp string entry mapped wrong: %+v", m) |
| 862 | } |
| 863 | if r := byName["remote"]; r.Type != "sse" || r.URL != "https://x/sse" { |
| 864 | t.Errorf("plain URL should map to SSE: %+v", r) |
| 865 | } |
| 866 | if s := byName["stream"]; s.Type != "http" || s.URL != "https://x/http" { |
| 867 | t.Errorf("streamable+ URL should map to http: %+v", s) |
| 868 | } |
| 869 | if g := byName["github"]; g.Command != "npx" || len(g.Args) != 0 { |
| 870 | t.Errorf("mcpServers should win the github name collision: %+v", g) |
| 871 | } |
| 872 | if a := byName["mcp-6"]; a.Command != "uvx" || len(a.Args) != 2 { |
| 873 | t.Errorf("anonymous spec should get a synthesized name: %+v", a) |
| 874 | } |
| 875 | if _, hasOff := byName["off"]; hasOff || len(got) != 5 { |
| 876 | t.Errorf("disabled entry should be skipped, got %d: %+v", len(got), got) |
| 877 | } |
| 878 | |
| 879 | // Absent, malformed, and empty paths must not error — just yield nil, so a |
| 880 | // stale legacy file can never block startup. |
| 881 | if got := loadLegacyMCP(filepath.Join(dir, "nope.json")); got != nil { |
| 882 | t.Errorf("absent file: got %+v, want nil", got) |
| 883 | } |
| 884 | if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { |
| 885 | t.Fatal(err) |
| 886 | } |
| 887 | if got := loadLegacyMCP(path); got != nil { |
| 888 | t.Errorf("malformed file: got %+v, want nil", got) |
| 889 | } |
| 890 | if got := loadLegacyMCP(""); got != nil { |
| 891 | t.Errorf("empty path: got %+v, want nil", got) |
| 892 | } |
| 893 | } |
| 894 | |
| 895 | func TestRemovePluginFromSourcesForRootRemovesEveryWritableDeclaration(t *testing.T) { |
| 896 | _, userConfig, _ := legacyHome(t) |
| 897 | root := t.TempDir() |
| 898 | if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil { |
| 899 | t.Fatal(err) |
| 900 | } |
| 901 | for _, path := range []string{userConfig, filepath.Join(root, "reasonix.toml")} { |
| 902 | if err := os.WriteFile(path, []byte(` |
| 903 | [[plugins]] |
| 904 | name = "duplicate" |
| 905 | command = "duplicate-mcp" |
| 906 | `), 0o644); err != nil { |
| 907 | t.Fatal(err) |
| 908 | } |
| 909 | } |
| 910 | mcpPath := filepath.Join(root, mcpJSONFile) |
| 911 | if err := os.WriteFile(mcpPath, []byte(`{ |
| 912 | "mcpServers": { |
| 913 | "duplicate": { "command": "duplicate-json" }, |
| 914 | "keep": { "command": "keep-json" } |
| 915 | } |
| 916 | }`), 0o644); err != nil { |
| 917 | t.Fatal(err) |
| 918 | } |
| 919 | |
| 920 | removed, err := RemovePluginFromSourcesForRoot(root, "duplicate") |
| 921 | if err != nil { |
| 922 | t.Fatalf("RemovePluginFromSourcesForRoot: %v", err) |
| 923 | } |
| 924 | if !removed { |
| 925 | t.Fatal("RemovePluginFromSourcesForRoot reported no removal") |
| 926 | } |
| 927 | for _, path := range []string{userConfig, filepath.Join(root, "reasonix.toml")} { |
| 928 | for _, p := range LoadForEdit(path).Plugins { |
| 929 | if p.Name == "duplicate" { |
| 930 | t.Fatalf("duplicate MCP survived in %s: %+v", path, p) |
| 931 | } |
| 932 | } |
| 933 | } |
| 934 | if _, found, err := LoadMCPJSONPlugin(mcpPath, "duplicate"); err != nil || found { |
| 935 | t.Fatalf("duplicate .mcp.json entry survived: found=%v err=%v", found, err) |
| 936 | } |
| 937 | if _, found, err := LoadMCPJSONPlugin(mcpPath, "keep"); err != nil || !found { |
| 938 | t.Fatalf("unrelated .mcp.json entry was lost: found=%v err=%v", found, err) |
| 939 | } |
| 940 | } |
| 941 | |
| 942 | func TestRemovePluginFromSourcesForRootPreflightsEverySource(t *testing.T) { |
| 943 | _, userConfig, _ := legacyHome(t) |
| 944 | root := t.TempDir() |
| 945 | if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil { |
| 946 | t.Fatal(err) |
| 947 | } |
| 948 | const original = `[[plugins]] |
| 949 | name = "duplicate" |
| 950 | command = "duplicate-mcp" |
| 951 | ` |
| 952 | if err := os.WriteFile(userConfig, []byte(original), 0o600); err != nil { |
| 953 | t.Fatal(err) |
| 954 | } |
| 955 | if err := os.WriteFile(filepath.Join(root, mcpJSONFile), []byte(`{"mcpServers":`), 0o644); err != nil { |
| 956 | t.Fatal(err) |
| 957 | } |
| 958 | |
| 959 | if removed, err := RemovePluginFromSourcesForRoot(root, "duplicate"); err == nil || removed { |
| 960 | t.Fatalf("RemovePluginFromSourcesForRoot = (%v, %v), want false and malformed .mcp.json error", removed, err) |
| 961 | } |
| 962 | got, err := os.ReadFile(userConfig) |
| 963 | if err != nil { |
| 964 | t.Fatal(err) |
| 965 | } |
| 966 | if string(got) != original { |
| 967 | t.Fatalf("user config changed before every source was validated:\n%s", got) |
| 968 | } |
| 969 | } |
| 970 | |
| 971 | func TestApplyConfigSourceEditsRollsBackEarlierWrites(t *testing.T) { |
| 972 | dir := t.TempDir() |
| 973 | first := filepath.Join(dir, "first.toml") |
| 974 | second := filepath.Join(dir, "second.toml") |
| 975 | for _, path := range []string{first, second} { |
| 976 | if err := os.WriteFile(path, []byte("before\n"), 0o600); err != nil { |
| 977 | t.Fatal(err) |
| 978 | } |
| 979 | } |
| 980 | firstEdit, err := newConfigSourceEdit(first, func() error { |
| 981 | return os.WriteFile(first, []byte("after\n"), 0o600) |
| 982 | }) |
| 983 | if err != nil { |
| 984 | t.Fatal(err) |
| 985 | } |
| 986 | secondEdit, err := newConfigSourceEdit(second, func() error { |
| 987 | return errors.New("publish failed") |
| 988 | }) |
| 989 | if err != nil { |
| 990 | t.Fatal(err) |
| 991 | } |
| 992 | if err := applyConfigSourceEdits([]configSourceEdit{firstEdit, secondEdit}); err == nil { |
| 993 | t.Fatal("applyConfigSourceEdits unexpectedly succeeded") |
| 994 | } |
| 995 | for _, path := range []string{first, second} { |
| 996 | got, err := os.ReadFile(path) |
| 997 | if err != nil { |
| 998 | t.Fatal(err) |
| 999 | } |
| 1000 | if string(got) != "before\n" { |
| 1001 | t.Fatalf("%s was not rolled back: %q", path, got) |
| 1002 | } |
| 1003 | } |
| 1004 | } |
| 1005 | |
| 1006 | func TestApplyConfigSourceEditsRollbackPreservesSymlink(t *testing.T) { |
| 1007 | dir := t.TempDir() |
| 1008 | target := filepath.Join(dir, "target.toml") |
| 1009 | link := filepath.Join(dir, "config.toml") |
| 1010 | second := filepath.Join(dir, "second.toml") |
| 1011 | for _, path := range []string{target, second} { |
| 1012 | if err := os.WriteFile(path, []byte("before\n"), 0o600); err != nil { |
| 1013 | t.Fatal(err) |
| 1014 | } |
| 1015 | } |
| 1016 | if err := os.Symlink(target, link); err != nil { |
| 1017 | t.Skipf("symlinks are unavailable: %v", err) |
| 1018 | } |
| 1019 | |
| 1020 | firstEdit, err := newConfigSourceEdit(link, func() error { |
| 1021 | return atomicWriteToConfigFile(link, "after\n", 0o600) |
| 1022 | }) |
| 1023 | if err != nil { |
| 1024 | t.Fatal(err) |
| 1025 | } |
| 1026 | secondEdit, err := newConfigSourceEdit(second, func() error { |
| 1027 | return errors.New("publish failed") |
| 1028 | }) |
| 1029 | if err != nil { |
| 1030 | t.Fatal(err) |
| 1031 | } |
| 1032 | if err := applyConfigSourceEdits([]configSourceEdit{firstEdit, secondEdit}); err == nil { |
| 1033 | t.Fatal("applyConfigSourceEdits unexpectedly succeeded") |
| 1034 | } |
| 1035 | |
| 1036 | info, err := os.Lstat(link) |
| 1037 | if err != nil { |
| 1038 | t.Fatal(err) |
| 1039 | } |
| 1040 | if info.Mode()&os.ModeSymlink == 0 { |
| 1041 | t.Fatal("rollback replaced the config symlink") |
| 1042 | } |
| 1043 | got, err := os.ReadFile(target) |
| 1044 | if err != nil { |
| 1045 | t.Fatal(err) |
| 1046 | } |
| 1047 | if string(got) != "before\n" { |
| 1048 | t.Fatalf("rollback target = %q, want original content", got) |
| 1049 | } |
| 1050 | } |
| 1051 | |
| 1052 | func TestMCPJSONInternalSymlinkIsPreserved(t *testing.T) { |
| 1053 | root := t.TempDir() |
| 1054 | target := filepath.Join(root, "shared-mcp.json") |
| 1055 | link := filepath.Join(root, mcpJSONFile) |
| 1056 | if err := os.WriteFile(target, []byte("{\"mcpServers\":{}}\n"), 0o644); err != nil { |
| 1057 | t.Fatal(err) |
| 1058 | } |
| 1059 | if err := os.Symlink(target, link); err != nil { |
| 1060 | t.Skipf("symlinks are unavailable: %v", err) |
| 1061 | } |
| 1062 | |
| 1063 | if _, err := UpsertMCPJSONPlugin(link, PluginEntry{Name: "internal", Command: "internal-mcp"}); err != nil { |
| 1064 | t.Fatal(err) |
| 1065 | } |
| 1066 | info, err := os.Lstat(link) |
| 1067 | if err != nil { |
| 1068 | t.Fatal(err) |
| 1069 | } |
| 1070 | if info.Mode()&os.ModeSymlink == 0 { |
| 1071 | t.Fatal("UpsertMCPJSONPlugin replaced the project symlink") |
| 1072 | } |
| 1073 | entry, found, err := LoadMCPJSONPlugin(link, "internal") |
| 1074 | if err != nil || !found || entry.Command != "internal-mcp" { |
| 1075 | t.Fatalf("LoadMCPJSONPlugin = (%+v, %v, %v)", entry, found, err) |
| 1076 | } |
| 1077 | } |
| 1078 | |
| 1079 | func TestMCPJSONRejectsExternalAndBrokenSymlinks(t *testing.T) { |
| 1080 | for _, tt := range []struct { |
| 1081 | name string |
| 1082 | target func(root string) string |
| 1083 | }{ |
| 1084 | { |
| 1085 | name: "external", |
| 1086 | target: func(root string) string { |
| 1087 | external := filepath.Join(t.TempDir(), "external.json") |
| 1088 | if err := os.WriteFile(external, []byte("{\"mcpServers\":{}}\n"), 0o600); err != nil { |
| 1089 | t.Fatal(err) |
| 1090 | } |
| 1091 | return external |
| 1092 | }, |
| 1093 | }, |
| 1094 | { |
| 1095 | name: "broken", |
| 1096 | target: func(root string) string { |
| 1097 | return filepath.Join(root, "missing.json") |
| 1098 | }, |
| 1099 | }, |
| 1100 | } { |
| 1101 | t.Run(tt.name, func(t *testing.T) { |
| 1102 | root := t.TempDir() |
| 1103 | link := filepath.Join(root, mcpJSONFile) |
| 1104 | target := tt.target(root) |
| 1105 | if err := os.Symlink(target, link); err != nil { |
| 1106 | t.Skipf("symlinks are unavailable: %v", err) |
| 1107 | } |
| 1108 | if _, err := loadMCPJSON(link); err == nil { |
| 1109 | t.Fatal("loadMCPJSON accepted unsafe project symlink") |
| 1110 | } |
| 1111 | if _, err := UpsertMCPJSONPlugin(link, PluginEntry{Name: "unsafe", Command: "unsafe-mcp"}); err == nil { |
| 1112 | t.Fatal("UpsertMCPJSONPlugin accepted unsafe project symlink") |
| 1113 | } |
| 1114 | if _, err := RemoveMCPJSONPlugin(link, "unsafe"); err == nil { |
| 1115 | t.Fatal("RemoveMCPJSONPlugin accepted unsafe project symlink") |
| 1116 | } |
| 1117 | info, err := os.Lstat(link) |
| 1118 | if err != nil { |
| 1119 | t.Fatal(err) |
| 1120 | } |
| 1121 | if info.Mode()&os.ModeSymlink == 0 { |
| 1122 | t.Fatal("failed MCP operation replaced unsafe symlink") |
| 1123 | } |
| 1124 | }) |
| 1125 | } |
| 1126 | } |
| 1127 | |
| 1128 | func TestClearPluginAuthenticationHonorsMCPJSONFileLock(t *testing.T) { |
| 1129 | root := t.TempDir() |
| 1130 | t.Setenv("REASONIX_HOME", filepath.Join(root, "home")) |
| 1131 | mcpPath := filepath.Join(root, mcpJSONFile) |
| 1132 | if err := os.WriteFile(mcpPath, []byte(`{ |
| 1133 | "mcpServers": { |
| 1134 | "remote": { |
| 1135 | "type": "http", |
| 1136 | "url": "https://example.com/mcp?token=secret", |
| 1137 | "headers": {"Authorization": "Bearer secret"} |
| 1138 | } |
| 1139 | } |
| 1140 | } |
| 1141 | `), 0o644); err != nil { |
| 1142 | t.Fatal(err) |
| 1143 | } |
| 1144 | release, err := acquireConfigFileEditLockWithTimeout(mcpPath, time.Second) |
| 1145 | if err != nil { |
| 1146 | t.Fatal(err) |
| 1147 | } |
| 1148 | defer release() |
| 1149 | |
| 1150 | previousTimeout := configEditLockTimeout |
| 1151 | configEditLockTimeout = 30 * time.Millisecond |
| 1152 | t.Cleanup(func() { configEditLockTimeout = previousTimeout }) |
| 1153 | if _, _, _, err := ClearPluginAuthenticationInSourceForRoot(root, "remote"); err == nil { |
| 1154 | t.Fatal("clear authentication ignored the project MCP file lock") |
| 1155 | } |
| 1156 | raw, err := os.ReadFile(mcpPath) |
| 1157 | if err != nil { |
| 1158 | t.Fatal(err) |
| 1159 | } |
| 1160 | if !strings.Contains(string(raw), "Bearer secret") { |
| 1161 | t.Fatal("authentication changed after lock acquisition failed") |
| 1162 | } |
| 1163 | } |
| 1164 | |
| 1165 | func TestInstallUserPluginForRootRestoresConfigWhenActivationFails(t *testing.T) { |
| 1166 | home := t.TempDir() |
| 1167 | t.Setenv("REASONIX_HOME", home) |
| 1168 | workspace := t.TempDir() |
| 1169 | |
| 1170 | cfg := Default() |
| 1171 | cfg.Agent.Temperature = 0.42 |
| 1172 | if err := cfg.UpsertPlugin(PluginEntry{ |
| 1173 | Name: "docs", |
| 1174 | Command: "existing-docs", |
| 1175 | Source: MCPSourceUserConfig, |
| 1176 | }); err != nil { |
| 1177 | t.Fatal(err) |
| 1178 | } |
| 1179 | if err := cfg.SaveTo(UserConfigPath()); err != nil { |
| 1180 | t.Fatal(err) |
| 1181 | } |
| 1182 | if err := os.MkdirAll(MCPActivationPath(home), 0o700); err != nil { |
| 1183 | t.Fatal(err) |
| 1184 | } |
| 1185 | |
| 1186 | _, err := InstallUserPluginForRoot(workspace, PluginEntry{ |
| 1187 | Name: "docs", |
| 1188 | Command: "replacement-docs", |
| 1189 | }, true) |
| 1190 | if err == nil { |
| 1191 | t.Fatal("install succeeded with an unreadable activation path") |
| 1192 | } |
| 1193 | |
| 1194 | got, loadErr := LoadForEditReadOnlyStrict(UserConfigPath()) |
| 1195 | if loadErr != nil { |
| 1196 | t.Fatal(loadErr) |
| 1197 | } |
| 1198 | entry, found := pluginEntryByName(got.Plugins, "docs") |
| 1199 | if !found || entry.Command != "existing-docs" { |
| 1200 | t.Fatalf("rolled-back plugin = %+v, found=%v", entry, found) |
| 1201 | } |
| 1202 | if got.Agent.Temperature != 0.42 { |
| 1203 | t.Fatalf("rollback lost unrelated config: temperature = %v", got.Agent.Temperature) |
| 1204 | } |
| 1205 | } |
| 1206 | |
| 1207 | func TestRemoveEffectivePluginLocksAllCompetingSources(t *testing.T) { |
| 1208 | root := t.TempDir() |
| 1209 | t.Setenv("REASONIX_HOME", filepath.Join(root, "home")) |
| 1210 | userPath := UserConfigPath() |
| 1211 | cfg := Default() |
| 1212 | if err := cfg.UpsertPlugin(PluginEntry{Name: "shared", Command: "user-mcp"}); err != nil { |
| 1213 | t.Fatal(err) |
| 1214 | } |
| 1215 | if err := cfg.SaveTo(userPath); err != nil { |
| 1216 | t.Fatal(err) |
| 1217 | } |
| 1218 | |
| 1219 | // The project file does not currently define "shared", but it can become the |
| 1220 | // higher-priority owner at any time. Holding its cross-process lock must stop |
| 1221 | // effective-source selection before the user declaration is removed. |
| 1222 | projectPath := filepath.Join(root, "reasonix.toml") |
| 1223 | if err := os.WriteFile(projectPath, []byte("# project config\n"), 0o644); err != nil { |
| 1224 | t.Fatal(err) |
| 1225 | } |
| 1226 | release, err := acquireConfigFileEditLockWithTimeout(projectPath, time.Second) |
| 1227 | if err != nil { |
| 1228 | t.Fatalf("hold project config lock: %v", err) |
| 1229 | } |
| 1230 | defer release() |
| 1231 | |
| 1232 | previousTimeout := configEditLockTimeout |
| 1233 | configEditLockTimeout = 30 * time.Millisecond |
| 1234 | t.Cleanup(func() { configEditLockTimeout = previousTimeout }) |
| 1235 | if _, _, _, err := RemovePluginFromEffectiveSourceForRoot(root, "shared"); err == nil { |
| 1236 | t.Fatal("effective-source removal ignored a competing project config lock") |
| 1237 | } |
| 1238 | |
| 1239 | after, err := LoadForEditReadOnlyStrict(userPath) |
| 1240 | if err != nil { |
| 1241 | t.Fatal(err) |
| 1242 | } |
| 1243 | if _, ok := pluginEntryByName(after.Plugins, "shared"); !ok { |
| 1244 | t.Fatal("effective-source removal changed user config after lock acquisition failed") |
| 1245 | } |
| 1246 | } |
| 1247 | |
| 1248 | func TestRemovePluginFromSourcesRejectsBrokenConfigSymlink(t *testing.T) { |
| 1249 | root := t.TempDir() |
| 1250 | t.Setenv("REASONIX_HOME", filepath.Join(root, "home")) |
| 1251 | link := filepath.Join(root, "reasonix.toml") |
| 1252 | if err := os.Symlink(filepath.Join(root, "missing.toml"), link); err != nil { |
| 1253 | t.Skipf("symlinks are unavailable: %v", err) |
| 1254 | } |
| 1255 | if _, err := RemovePluginFromSourcesForRoot(root, "missing"); err == nil { |
| 1256 | t.Fatal("multi-source removal silently skipped a broken config symlink") |
| 1257 | } |
| 1258 | info, err := os.Lstat(link) |
| 1259 | if err != nil { |
| 1260 | t.Fatal(err) |
| 1261 | } |
| 1262 | if info.Mode()&os.ModeSymlink == 0 { |
| 1263 | t.Fatal("multi-source removal replaced the broken config symlink") |
| 1264 | } |
| 1265 | } |
| 1266 | |
| 1267 | func TestUpsertPluginInProjectSourceRequiresProjectFileLock(t *testing.T) { |
| 1268 | root := t.TempDir() |
| 1269 | path := filepath.Join(root, "reasonix.toml") |
| 1270 | const original = "# project config\n" |
| 1271 | if err := os.WriteFile(path, []byte(original), 0o644); err != nil { |
| 1272 | t.Fatal(err) |
| 1273 | } |
| 1274 | release, err := acquireConfigFileEditLockWithTimeout(path, time.Second) |
| 1275 | if err != nil { |
| 1276 | t.Fatalf("hold project config lock: %v", err) |
| 1277 | } |
| 1278 | defer release() |
| 1279 | |
| 1280 | previousTimeout := configEditLockTimeout |
| 1281 | configEditLockTimeout = 30 * time.Millisecond |
| 1282 | t.Cleanup(func() { configEditLockTimeout = previousTimeout }) |
| 1283 | |
| 1284 | _, err = UpsertPluginInSourceForRoot(root, PluginEntry{ |
| 1285 | Name: "locked", |
| 1286 | Command: "locked-mcp", |
| 1287 | Source: MCPSourceProjectConfig, |
| 1288 | }) |
| 1289 | if err == nil { |
| 1290 | t.Fatal("project MCP update ignored the project config file lock") |
| 1291 | } |
| 1292 | got, readErr := os.ReadFile(path) |
| 1293 | if readErr != nil { |
| 1294 | t.Fatal(readErr) |
| 1295 | } |
| 1296 | if string(got) != original { |
| 1297 | t.Fatalf("failed locked update changed project config:\n%s", got) |
| 1298 | } |
| 1299 | } |
| 1300 |