返回 DeepSeek-Reasonix
project_declared_mcp_effect_test.go
根目录 / internal / boot / project_declared_mcp_effect_test.go
1 package boot
2
3 import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "testing"
14 "time"
15
16 "reasonix/internal/agent/testutil"
17 "reasonix/internal/config"
18 "reasonix/internal/event"
19 )
20
21 func countingMCPStub(t *testing.T, name string) (*httptest.Server, *atomic.Int32) {
22 t.Helper()
23 inner := mcpHostSessionStub(t, name, nil)
24 handler := inner.Config.Handler
25 inner.Close()
26 hits := &atomic.Int32{}
27 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 hits.Add(1)
29 handler.ServeHTTP(w, r)
30 }))
31 t.Cleanup(srv.Close)
32 return srv, hits
33 }
34
35 // enableProjectMCPForTest records the user's enable decision for each server
36 // root's project files declare with auto_start on.
37 func enableProjectMCPForTest(t testing.TB, root string) {
38 t.Helper()
39 cfg, err := config.LoadForRootReadOnly(root)
40 if err != nil {
41 t.Fatal(err)
42 }
43 for _, p := range cfg.Plugins {
44 if p.Source.ProjectScoped() && p.ShouldAutoStart() {
45 if err := config.DefaultMCPActivationStore().SetServerEnabled(p, root, true); err != nil {
46 t.Fatal(err)
47 }
48 }
49 }
50 }
51
52 func waitForHit(hits *atomic.Int32, within time.Duration) bool {
53 deadline := time.Now().Add(within)
54 for time.Now().Before(deadline) {
55 if hits.Load() > 0 {
56 return true
57 }
58 time.Sleep(20 * time.Millisecond)
59 }
60 return hits.Load() > 0
61 }
62
63 // A project's configuration cannot choose MCP servers the host starts: a
64 // repository-declared server stays idle until the user enables it, while a
65 // user-level server keeps starting as before.
66 func TestEffectProjectDeclaredMCPDoesNotStartThroughRealBuild(t *testing.T) {
67 isolateConfigHome(t)
68 dir := robustTempDir(t)
69 t.Chdir(dir)
70
71 userSrv, userHits := countingMCPStub(t, "user")
72 tomlSrv, tomlHits := countingMCPStub(t, "repo-toml")
73 jsonSrv, jsonHits := countingMCPStub(t, "repo-json")
74
75 userConfig := config.UserConfigPath()
76 if err := os.MkdirAll(filepath.Dir(userConfig), 0o700); err != nil {
77 t.Fatal(err)
78 }
79 if err := os.WriteFile(userConfig, []byte("[[plugins]]\nname = \"user-http\"\ntype = \"http\"\nurl = \""+userSrv.URL+"\"\n"), 0o600); err != nil {
80 t.Fatal(err)
81 }
82 marker := filepath.Join(dir, "stdio-started")
83 stdioPlugin := ""
84 if runtime.GOOS != "windows" {
85 stdioPlugin = "\n[[plugins]]\nname = \"repo-stdio\"\ncommand = \"sh\"\nargs = [\"-c\", \"echo started > " + marker + "\"]\n"
86 }
87 writeFile(t, dir, "reasonix.toml", `
88 default_model = "test-model"
89
90 [agent]
91 system_prompt = "BASE"
92
93 [[providers]]
94 name = "test-model"
95 kind = "openai"
96 base_url = "https://example.invalid"
97 model = "x"
98 api_key_env = "REASONIX_TEST_KEY_UNSET"
99
100 [[plugins]]
101 name = "repo-toml-http"
102 type = "http"
103 url = "`+tomlSrv.URL+`"
104 auto_start = true
105 `+stdioPlugin)
106 approveWorkspace(t, dir)
107 writeFile(t, dir, ".mcp.json", `{"mcpServers":{"repo-json-http":{"type":"http","url":"`+jsonSrv.URL+`"}}}`)
108
109 build := func() {
110 t.Helper()
111 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
112 t.Cleanup(cancel)
113 ctrl, err := Build(ctx, Options{})
114 if err != nil {
115 t.Fatalf("Build: %v", err)
116 }
117 t.Cleanup(ctrl.Close)
118 }
119
120 build()
121 if !waitForHit(userHits, 5*time.Second) {
122 t.Fatal("user-level MCP server was never contacted; the probe cannot tell idle from broken")
123 }
124 time.Sleep(500 * time.Millisecond)
125 if n := tomlHits.Load(); n != 0 {
126 t.Fatalf("project reasonix.toml MCP server received %d requests with no user decision", n)
127 }
128 if n := jsonHits.Load(); n != 0 {
129 t.Fatalf("project .mcp.json MCP server received %d requests with no user decision", n)
130 }
131 if _, err := os.Stat(marker); err == nil {
132 t.Fatal("project stdio MCP command ran with no user decision")
133 }
134
135 cfg, err := config.LoadForRootReadOnly(dir)
136 if err != nil {
137 t.Fatal(err)
138 }
139 for _, p := range cfg.Plugins {
140 if p.Name == "repo-toml-http" {
141 if err := config.DefaultMCPActivationStore().SetServerEnabled(p, dir, true); err != nil {
142 t.Fatal(err)
143 }
144 }
145 }
146 build()
147 if !waitForHit(tomlHits, 5*time.Second) {
148 t.Fatal("project MCP server stayed idle after the user enabled it")
149 }
150 if n := jsonHits.Load(); n != 0 {
151 t.Fatalf("enabling one project server started another: .mcp.json server got %d requests", n)
152 }
153 }
154
155 // An enable decision holds for the declaration the user approved; rewriting
156 // the command afterwards leaves the server off until they approve again.
157 func TestEffectRewrittenProjectMCPCommandDoesNotRunThroughRealBuild(t *testing.T) {
158 if runtime.GOOS == "windows" {
159 t.Skip("marker command is POSIX shell")
160 }
161 isolateConfigHome(t)
162 dir := robustTempDir(t)
163 t.Chdir(dir)
164 base := `
165 default_model = "test-model"
166 [[providers]]
167 name = "test-model"
168 kind = "openai"
169 base_url = "https://example.invalid"
170 model = "x"
171 api_key_env = "REASONIX_TEST_KEY_UNSET"
172 `
173 approved := filepath.Join(dir, "approved-ran")
174 rewritten := filepath.Join(dir, "rewritten-ran")
175 writeFile(t, dir, "reasonix.toml", base+"\n[[plugins]]\nname = \"repo-stdio\"\ncommand = \"sh\"\nargs = [\"-c\", \"echo ok > "+approved+"\"]\n")
176 approveWorkspace(t, dir)
177 enableProjectMCPForTest(t, dir)
178 writeFile(t, dir, "reasonix.toml", base+"\n[[plugins]]\nname = \"repo-stdio\"\ncommand = \"sh\"\nargs = [\"-c\", \"echo changed > "+rewritten+"\"]\n")
179 approveWorkspace(t, dir)
180
181 var notices []event.Event
182 var mu sync.Mutex
183 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
184 t.Cleanup(cancel)
185 ctrl, err := Build(ctx, Options{Sink: event.FuncSink(func(e event.Event) {
186 if e.Kind == event.Notice {
187 mu.Lock()
188 notices = append(notices, e)
189 mu.Unlock()
190 }
191 })})
192 if err != nil {
193 t.Fatalf("Build: %v", err)
194 }
195 t.Cleanup(ctrl.Close)
196 deadline := time.Now().Add(2 * time.Second)
197 for time.Now().Before(deadline) {
198 if _, err := os.Stat(rewritten); err == nil {
199 t.Fatal("rewritten command of an enabled project server ran with no new decision")
200 }
201 time.Sleep(50 * time.Millisecond)
202 }
203 mu.Lock()
204 defer mu.Unlock()
205 for _, n := range notices {
206 if strings.Contains(n.Detail, "repo-stdio [changed_since_enabled]") && strings.Contains(n.Detail, "echo changed > "+rewritten) {
207 return
208 }
209 }
210 t.Fatalf("no notice named the changed server and its new command; notices=%+v", notices)
211 }
212
213 // The model learns why a project server is off, as a typed cause it can report,
214 // rather than a generic disabled state it might try to route around.
215 func TestEffectModelSeesProjectMCPAwaitingUserDecision(t *testing.T) {
216 isolateConfigHome(t)
217 dir := robustTempDir(t)
218 t.Chdir(dir)
219 srv, hits := countingMCPStub(t, "repo")
220 writeFile(t, dir, "reasonix.toml", mcpCapabilityTestProviderConfig+`
221 [[plugins]]
222 name = "repo-http"
223 type = "http"
224 url = "`+srv.URL+`"
225 `)
226 approveWorkspace(t, dir)
227 out := runUseCapabilityCalls(t, Options{Sink: event.Discard}, "mcp-server:repo-http", "mcp-tool:repo-http/ping")
228 if !strings.Contains(out, "[awaiting_user_decision]") {
229 t.Fatalf("model-visible refusal lacks the typed cause:\n%s", out)
230 }
231 if n := hits.Load(); n != 0 {
232 t.Fatalf("pending project server received %d requests during the turn", n)
233 }
234 }
235
236 // Connecting a pending project server by hand is the user's approval, so the
237 // next session starts it without asking again.
238 func TestConnectingPendingProjectMCPRecordsTheDecision(t *testing.T) {
239 isolateConfigHome(t)
240 dir := robustTempDir(t)
241 t.Chdir(dir)
242 srv, hits := countingMCPStub(t, "repo")
243 writeFile(t, dir, "reasonix.toml", mcpCapabilityTestProviderConfig+`
244 [[plugins]]
245 name = "repo-http"
246 type = "http"
247 url = "`+srv.URL+`"
248 `)
249 approveWorkspace(t, dir)
250 registerBootTokenProfileTestProvider()
251 setBootTokenProfileTestProvider(t, testutil.NewMock("connect", testutil.Turn{Text: "done"}))
252 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
253 t.Cleanup(cancel)
254 ctrl, err := Build(ctx, Options{Sink: event.Discard})
255 if err != nil {
256 t.Fatalf("Build: %v", err)
257 }
258 t.Cleanup(ctrl.Close)
259 if _, err := ctrl.ConnectConfiguredMCPServer("repo-http"); err != nil {
260 t.Fatalf("connect: %v", err)
261 }
262 if hits.Load() == 0 {
263 t.Fatal("explicit connect never reached the server")
264 }
265 cfg, err := config.LoadForRootReadOnly(dir)
266 if err != nil {
267 t.Fatal(err)
268 }
269 for _, p := range cfg.Plugins {
270 if p.Name == "repo-http" {
271 if d := config.MCPServerDecision(p, ctrl.WorkspaceRoot()); d != config.MCPDecisionOn {
272 t.Fatalf("decision after explicit connect = %s, want enabled", d.Code())
273 }
274 return
275 }
276 }
277 t.Fatal("repo-http not loaded")
278 }
279
279 lines GO