| 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 |