返回 DeepSeek-Reasonix
project_declared_mcp_test.go
根目录 / internal / config / project_declared_mcp_test.go
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
277 lines GO