| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "slices" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | func enabledNames(entries []PluginEntry) []string { |
| 11 | names := make([]string, 0, len(entries)) |
| 12 | for _, e := range entries { |
| 13 | names = append(names, e.Name) |
| 14 | } |
| 15 | slices.Sort(names) |
| 16 | return names |
| 17 | } |
| 18 | |
| 19 | func writeProjectDeclaredServers(t *testing.T) (home, root string) { |
| 20 | t.Helper() |
| 21 | home, root = t.TempDir(), t.TempDir() |
| 22 | t.Setenv("REASONIX_HOME", home) |
| 23 | t.Setenv("HOME", home) |
| 24 | t.Setenv("USERPROFILE", home) |
| 25 | t.Setenv("XDG_CONFIG_HOME", home) |
| 26 | mustWrite := func(path, body string) { |
| 27 | t.Helper() |
| 28 | if err := os.WriteFile(path, []byte(body), 0o600); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | } |
| 32 | mustWrite(filepath.Join(home, "config.toml"), ` |
| 33 | [[plugins]] |
| 34 | name = "user-stdio" |
| 35 | command = "user-server" |
| 36 | `) |
| 37 | mustWrite(filepath.Join(root, "reasonix.toml"), ` |
| 38 | [[plugins]] |
| 39 | name = "toml-stdio" |
| 40 | command = "project-server" |
| 41 | |
| 42 | [[plugins]] |
| 43 | name = "toml-http" |
| 44 | type = "http" |
| 45 | url = "http://127.0.0.1:9/mcp" |
| 46 | auto_start = true |
| 47 | `) |
| 48 | mustWrite(filepath.Join(root, ".mcp.json"), `{"mcpServers":{ |
| 49 | "json-stdio": {"command": "project-server"}, |
| 50 | "json-http": {"type": "http", "url": "http://127.0.0.1:9/mcp"} |
| 51 | }}`) |
| 52 | return home, root |
| 53 | } |
| 54 | |
| 55 | func TestProjectDeclaredServersStayOffUntilTheUserEnablesThem(t *testing.T) { |
| 56 | home, root := writeProjectDeclaredServers(t) |
| 57 | cfg, err := LoadForRootReadOnly(root) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | store := NewMCPActivationStore(home) |
| 62 | if got := enabledNames(cfg.EnabledPlugins(root, store)); !slices.Equal(got, []string{"user-stdio"}) { |
| 63 | t.Fatalf("enabled with no decision = %v, want only the user-level server", got) |
| 64 | } |
| 65 | |
| 66 | for _, p := range cfg.Plugins { |
| 67 | if p.Name == "toml-http" || p.Name == "json-stdio" { |
| 68 | if err := store.SetServerEnabled(p, root, true); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | } |
| 72 | } |
| 73 | if got := enabledNames(cfg.EnabledPlugins(root, store)); !slices.Equal(got, []string{"json-stdio", "toml-http", "user-stdio"}) { |
| 74 | t.Fatalf("enabled after explicit approval = %v", got) |
| 75 | } |
| 76 | if got := enabledNames(cfg.EnabledPlugins(t.TempDir(), store)); !slices.Equal(got, []string{"user-stdio"}) { |
| 77 | t.Fatalf("approval leaked into another workspace: %v", got) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | func TestUnreadableActivationStoreKeepsProjectServersOff(t *testing.T) { |
| 82 | home, root := writeProjectDeclaredServers(t) |
| 83 | if err := os.WriteFile(MCPActivationPath(home), []byte("{not json"), 0o600); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | cfg, err := LoadForRootReadOnly(root) |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | if got := enabledNames(cfg.EnabledPlugins(root, NewMCPActivationStore(home))); !slices.Equal(got, []string{"user-stdio"}) { |
| 91 | t.Fatalf("enabled with an unreadable store = %v, want only the user-level server", got) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestServerOfUnknownProvenanceCountsAsProjectDeclared(t *testing.T) { |
| 96 | store := NewMCPActivationStore(t.TempDir()) |
| 97 | enabled, err := store.IsEnabled(PluginEntry{Name: "anon", Command: "x"}, t.TempDir()) |
| 98 | if err != nil || enabled { |
| 99 | t.Fatalf("unknown-source server enabled=%v err=%v, want off", enabled, err) |
| 100 | } |
| 101 | if DeclaredDefaultOn(PluginEntry{Name: "anon"}) { |
| 102 | t.Fatal("DeclaredDefaultOn(unknown source) = true, want false") |
| 103 | } |
| 104 | if !DeclaredDefaultOn(PluginEntry{Name: "u", Source: MCPSourceUserConfig}) { |
| 105 | t.Fatal("DeclaredDefaultOn(user source) = false, want true") |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func loadProjectServer(t *testing.T, root, name string) PluginEntry { |
| 110 | t.Helper() |
| 111 | cfg, err := LoadForRootReadOnly(root) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | for _, p := range cfg.Plugins { |
| 116 | if p.Name == name { |
| 117 | return p |
| 118 | } |
| 119 | } |
| 120 | t.Fatalf("server %q not loaded", name) |
| 121 | return PluginEntry{} |
| 122 | } |
| 123 | |
| 124 | func TestProjectDecisionHoldsOnlyForTheApprovedDeclaration(t *testing.T) { |
| 125 | for _, tc := range []struct { |
| 126 | name string |
| 127 | change func(root string) error |
| 128 | }{ |
| 129 | {"command args", func(root string) error { |
| 130 | return os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[[plugins]]\nname = \"srv\"\ncommand = \"./server.sh\"\nargs = [\"--other\"]\n"), 0o600) |
| 131 | }}, |
| 132 | {"workspace executable content", func(root string) error { |
| 133 | return os.WriteFile(filepath.Join(root, "server.sh"), []byte("#!/bin/sh\necho changed\n"), 0o700) |
| 134 | }}, |
| 135 | {"project .env value it expands", func(root string) error { |
| 136 | return os.WriteFile(filepath.Join(root, ".env"), []byte("SRV_FLAG=--changed\n"), 0o600) |
| 137 | }}, |
| 138 | } { |
| 139 | t.Run(tc.name, func(t *testing.T) { |
| 140 | home, root := t.TempDir(), t.TempDir() |
| 141 | t.Setenv("REASONIX_HOME", home) |
| 142 | t.Setenv("HOME", home) |
| 143 | t.Setenv("XDG_CONFIG_HOME", home) |
| 144 | t.Setenv("SRV_FLAG", "") |
| 145 | os.Unsetenv("SRV_FLAG") |
| 146 | for name, body := range map[string]string{ |
| 147 | "reasonix.toml": "[[plugins]]\nname = \"srv\"\ncommand = \"./server.sh\"\nargs = [\"${SRV_FLAG}\"]\n", |
| 148 | "server.sh": "#!/bin/sh\necho ok\n", |
| 149 | ".env": "SRV_FLAG=--ok\n", |
| 150 | } { |
| 151 | if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o700); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | } |
| 155 | store := NewMCPActivationStore(home) |
| 156 | if err := store.SetServerEnabled(loadProjectServer(t, root, "srv"), root, true); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | if d, err := store.Decision(loadProjectServer(t, root, "srv"), root); err != nil || d != MCPDecisionOn { |
| 160 | t.Fatalf("decision right after enabling = %v, %v", d.Code(), err) |
| 161 | } |
| 162 | if err := tc.change(root); err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | d, err := store.Decision(loadProjectServer(t, root, "srv"), root) |
| 166 | if err != nil || d != MCPDecisionChanged { |
| 167 | t.Fatalf("decision after the declaration changed = %s, %v; want %s", d.Code(), err, MCPDecisionChanged.Code()) |
| 168 | } |
| 169 | }) |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | func TestProjectDotEnvDoesNotExpandUserLevelServers(t *testing.T) { |
| 174 | home, root := t.TempDir(), t.TempDir() |
| 175 | t.Setenv("REASONIX_HOME", home) |
| 176 | t.Setenv("HOME", home) |
| 177 | t.Setenv("XDG_CONFIG_HOME", home) |
| 178 | t.Setenv("USER_SRV_OPTS", "") |
| 179 | os.Unsetenv("USER_SRV_OPTS") |
| 180 | if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[[plugins]]\nname = \"user-stdio\"\ncommand = \"node\"\nenv = { NODE_OPTIONS = \"${USER_SRV_OPTS}\" }\n"), 0o600); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | if err := os.WriteFile(filepath.Join(root, ".env"), []byte("USER_SRV_OPTS=--require ./x.js\n"), 0o600); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | p := loadProjectServer(t, root, "user-stdio") |
| 187 | if got := p.ExpandedPlugin().Env["NODE_OPTIONS"]; got != "" { |
| 188 | t.Fatalf("project .env expanded a user-level server: NODE_OPTIONS=%q", got) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // Carrying a decision across a Reasonix edit covers only the declaration that |
| 193 | // edit wrote; a different one found afterwards stays unapproved. |
| 194 | func TestKeptDecisionDoesNotCoverAnotherWritersDeclaration(t *testing.T) { |
| 195 | home, root := t.TempDir(), t.TempDir() |
| 196 | t.Setenv("REASONIX_HOME", home) |
| 197 | t.Setenv("HOME", home) |
| 198 | t.Setenv("XDG_CONFIG_HOME", home) |
| 199 | toml := func(arg string) error { |
| 200 | return os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[[plugins]]\nname = \"srv\"\ncommand = \"server\"\nargs = [\""+arg+"\"]\n"), 0o600) |
| 201 | } |
| 202 | if err := toml("--approved"); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | if err := DefaultMCPActivationStore().SetServerEnabled(loadProjectServer(t, root, "srv"), root, true); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | userEdit := loadProjectServer(t, root, "srv") |
| 209 | userEdit.Args = []string{"--edited"} |
| 210 | err := KeepMCPDecisionAcross(root, "srv", func() (PluginEntry, error) { |
| 211 | return userEdit, toml("--someone-else") |
| 212 | }) |
| 213 | if err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | if d := MCPServerDecision(loadProjectServer(t, root, "srv"), root); d != MCPDecisionChanged { |
| 217 | t.Fatalf("decision after a foreign rewrite = %s, want %s", d.Code(), MCPDecisionChanged.Code()) |
| 218 | } |
| 219 | err = KeepMCPDecisionAcross(root, "srv", func() (PluginEntry, error) { |
| 220 | return userEdit, toml("--edited") |
| 221 | }) |
| 222 | if err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | if d := MCPServerDecision(loadProjectServer(t, root, "srv"), root); d != MCPDecisionChanged { |
| 226 | t.Fatalf("an unapproved server was re-approved by an edit: %s", d.Code()) |
| 227 | } |
| 228 | if err := DefaultMCPActivationStore().SetServerEnabled(loadProjectServer(t, root, "srv"), root, true); err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | userEdit.Args = []string{"--mine"} |
| 232 | if err := KeepMCPDecisionAcross(root, "srv", func() (PluginEntry, error) { return userEdit, toml("--mine") }); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | if d := MCPServerDecision(loadProjectServer(t, root, "srv"), root); d != MCPDecisionOn { |
| 236 | t.Fatalf("decision after the user's own edit = %s, want enabled", d.Code()) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // Inputs the declaration draws from the workspace that change while Reasonix |
| 241 | // writes an edit are not covered by the decision the edit carries. |
| 242 | func TestKeptDecisionRejectsInputsChangedDuringTheEdit(t *testing.T) { |
| 243 | for _, tc := range []struct { |
| 244 | name, toml, file, before, after string |
| 245 | }{ |
| 246 | {"named file", "[[plugins]]\nname = \"srv\"\ncommand = \"node\"\nargs = [\"server.js\"]\n", "server.js", "good", "changed"}, |
| 247 | {"project .env", "[[plugins]]\nname = \"srv\"\ncommand = \"node\"\nargs = [\"${ENTRY}\"]\n", ".env", "ENTRY=a.js\n", "ENTRY=b.js\n"}, |
| 248 | } { |
| 249 | t.Run(tc.name, func(t *testing.T) { |
| 250 | home, root := t.TempDir(), t.TempDir() |
| 251 | t.Setenv("REASONIX_HOME", home) |
| 252 | t.Setenv("HOME", home) |
| 253 | t.Setenv("XDG_CONFIG_HOME", home) |
| 254 | t.Setenv("ENTRY", "") |
| 255 | os.Unsetenv("ENTRY") |
| 256 | for name, body := range map[string]string{"reasonix.toml": tc.toml, tc.file: tc.before} { |
| 257 | if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o600); err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | } |
| 261 | if err := DefaultMCPActivationStore().SetServerEnabled(loadProjectServer(t, root, "srv"), root, true); err != nil { |
| 262 | t.Fatal(err) |
| 263 | } |
| 264 | err := KeepMCPDecisionAcross(root, "srv", func() (PluginEntry, error) { |
| 265 | written := loadProjectServer(t, root, "srv") |
| 266 | return written, os.WriteFile(filepath.Join(root, tc.file), []byte(tc.after), 0o600) |
| 267 | }) |
| 268 | if err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | if d := MCPServerDecision(loadProjectServer(t, root, "srv"), root); d == MCPDecisionOn { |
| 272 | t.Fatalf("%s changed during the edit and the decision carried onto it", tc.file) |
| 273 | } |
| 274 | }) |
| 275 | } |
| 276 | } |
| 277 |