返回 DeepSeek-Reasonix
usecapability_test.go
根目录 / internal / agent / usecapability_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "path/filepath"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "testing"
14 "time"
15
16 "reasonix/internal/capability"
17 "reasonix/internal/config"
18 "reasonix/internal/event"
19 "reasonix/internal/evidence"
20 "reasonix/internal/mcplaunch"
21 "reasonix/internal/permission"
22 "reasonix/internal/plugin"
23 "reasonix/internal/provider"
24 "reasonix/internal/skill"
25
26 "reasonix/internal/tool"
27 )
28
29 type denyAllGate struct{}
30
31 func (denyAllGate) Check(_ context.Context, name string, _ json.RawMessage, _ bool) (bool, string, error) {
32 return false, "denied " + name, nil
33 }
34
35 type completedProxyCallTool struct{}
36
37 func (completedProxyCallTool) Name() string { return "use_capability" }
38 func (completedProxyCallTool) Description() string { return "" }
39 func (completedProxyCallTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
40 func (completedProxyCallTool) ReadOnly() bool { return true }
41 func (completedProxyCallTool) Execute(context.Context, json.RawMessage) (string, error) {
42 return "", nil
43 }
44
45 type readOnlyBoundaryTarget struct {
46 name string
47 readOnly bool
48 hostStart bool
49 calls *int
50 }
51
52 func (t readOnlyBoundaryTarget) Name() string { return t.name }
53 func (readOnlyBoundaryTarget) Description() string { return "" }
54 func (readOnlyBoundaryTarget) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
55 func (t readOnlyBoundaryTarget) ReadOnly() bool { return t.readOnly }
56 func (t readOnlyBoundaryTarget) ReadOnlyExecutionHostMutation() bool { return t.hostStart }
57 func (t readOnlyBoundaryTarget) Execute(context.Context, json.RawMessage) (string, error) {
58 if t.calls != nil {
59 (*t.calls)++
60 }
61 return "target executed", nil
62 }
63
64 type readOnlyBoundaryProxy struct {
65 resolved tool.ResolvedCall
66 }
67
68 func (readOnlyBoundaryProxy) Name() string { return "use_capability" }
69 func (readOnlyBoundaryProxy) Description() string { return "" }
70 func (readOnlyBoundaryProxy) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
71 func (readOnlyBoundaryProxy) ReadOnly() bool { return true }
72 func (readOnlyBoundaryProxy) Execute(context.Context, json.RawMessage) (string, error) {
73 return "proxy executed", nil
74 }
75 func (p readOnlyBoundaryProxy) ResolveCall(context.Context, json.RawMessage) (tool.ResolvedCall, error) {
76 return p.resolved, nil
77 }
78
79 type layeredReadOnlyMCPBoundaryTarget struct {
80 readOnlyBoundaryTarget
81 destructive bool
82 serverAuthorized bool
83 }
84
85 func (layeredReadOnlyMCPBoundaryTarget) MCPServerName() string { return "test" }
86 func (layeredReadOnlyMCPBoundaryTarget) MCPRawToolName() string { return "read" }
87 func (t layeredReadOnlyMCPBoundaryTarget) MCPDestructiveHint() bool { return t.destructive }
88
89 func (t layeredReadOnlyMCPBoundaryTarget) MCPServerAuthorized() bool { return t.serverAuthorized }
90
91 func executeReadOnlyBoundaryCall(t *testing.T, resolved tool.ResolvedCall) toolOutcome {
92 t.Helper()
93 reg := tool.NewRegistry()
94 reg.Add(readOnlyBoundaryProxy{resolved: resolved})
95 a := New(nil, reg, NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
96 return a.executeOne(context.Background(), &a.turn, provider.ToolCall{
97 ID: "ro-1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:test/tool"}`,
98 })
99 }
100
101 func TestReadOnlyExecutionBlocksResolvedWriterAndHostStartup(t *testing.T) {
102 for _, tc := range []struct {
103 name string
104 readOnly bool
105 hostStart bool
106 }{
107 {name: "writer"},
108 {name: "host startup", readOnly: true, hostStart: true},
109 } {
110 t.Run(tc.name, func(t *testing.T) {
111 calls := 0
112 target := readOnlyBoundaryTarget{name: "mcp__test__tool", readOnly: tc.readOnly, hostStart: tc.hostStart, calls: &calls}
113 out := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
114 ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: tc.readOnly, Args: json.RawMessage(`{}`),
115 })
116 if !out.blocked || !strings.Contains(out.output, "read-only agent") {
117 t.Fatalf("resolved call outcome = %+v, want host block", out)
118 }
119 if calls != 0 {
120 t.Fatalf("target Execute calls = %d, want 0", calls)
121 }
122 })
123 }
124 }
125
126 func TestReadOnlyExecutionAllowsInspectAndOrdinaryReadOnlyCall(t *testing.T) {
127 inspect := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
128 ProxyAction: "inspect", SkipExecute: true, ReadOnly: true, Result: "metadata",
129 })
130 if inspect.blocked || inspect.errMsg != "" || inspect.output != "metadata" {
131 t.Fatalf("inspect outcome = %+v", inspect)
132 }
133
134 calls := 0
135 target := readOnlyBoundaryTarget{name: "mcp__test__read", readOnly: true, calls: &calls}
136 call := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
137 ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: true, Args: json.RawMessage(`{}`),
138 })
139 if call.blocked || call.errMsg != "" || !strings.Contains(call.output, "target executed") {
140 t.Fatalf("read-only call outcome = %+v", call)
141 }
142 if calls != 1 {
143 t.Fatalf("target Execute calls = %d, want 1", calls)
144 }
145 }
146
147 func TestReadOnlyExecutionAllowsOnlyAuthorizedReadOnlyMCPStartup(t *testing.T) {
148 for _, tc := range []struct {
149 name string
150 authorized bool
151 destructive bool
152 wantBlocked bool
153 }{
154 {name: "authorized reader", authorized: true},
155 {name: "unauthorized server", wantBlocked: true},
156 {name: "destructive reader", authorized: true, destructive: true, wantBlocked: true},
157 } {
158 t.Run(tc.name, func(t *testing.T) {
159 calls := 0
160 target := layeredReadOnlyMCPBoundaryTarget{
161 readOnlyBoundaryTarget: readOnlyBoundaryTarget{
162 name: "mcp__test__read", readOnly: true, hostStart: true, calls: &calls,
163 },
164 destructive: tc.destructive, serverAuthorized: tc.authorized,
165 }
166 out := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
167 ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: true, Args: json.RawMessage(`{}`),
168 })
169 if out.blocked != tc.wantBlocked {
170 t.Fatalf("layered MCP outcome = %+v, want blocked=%v", out, tc.wantBlocked)
171 }
172 wantCalls := 1
173 if tc.wantBlocked {
174 wantCalls = 0
175 }
176 if calls != wantCalls {
177 t.Fatalf("target Execute calls = %d, want %d", calls, wantCalls)
178 }
179 })
180 }
181 }
182
183 func TestStrictReadOnlyExecutionRegistryFailsClosed(t *testing.T) {
184 reg := tool.NewRegistry()
185 reg.Add(fakeTool{name: "writer", readOnly: false})
186 reg.Add(readOnlyBoundaryTarget{name: "ordinary_read", readOnly: true})
187 reg.Add(layeredReadOnlyMCPBoundaryTarget{
188 readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__test__trusted", readOnly: true, hostStart: true},
189 serverAuthorized: true,
190 })
191 reg.Add(layeredReadOnlyMCPBoundaryTarget{
192 readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__test__destructive", readOnly: true, hostStart: true},
193 destructive: true,
194 })
195
196 filtered := strictReadOnlyExecutionRegistry(reg)
197 if got, want := strings.Join(filtered.Names(), ","), "ordinary_read,mcp__test__trusted"; got != want {
198 t.Fatalf("strict registry = %q, want %q", got, want)
199 }
200 }
201
202 func TestReadOnlyExecutionBlocksUnauthorizedMCPAndDecline(t *testing.T) {
203 calls := 0
204 target := layeredReadOnlyMCPBoundaryTarget{readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__test__hint", readOnly: true, calls: &calls}}
205 out := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
206 ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: true, Args: json.RawMessage(`{}`),
207 })
208 if !out.blocked || calls != 0 {
209 t.Fatalf("unauthorized read-only outcome = %+v calls=%d", out, calls)
210 }
211
212 ledger := capability.NewLedger()
213 ledger.SeedCandidates(capability.RouteDecision{Candidates: []capability.RouteCandidate{
214 {Entry: capability.Entry{ID: "skill:review"}, Policy: capability.AutoUsePrefer},
215 }})
216 proxy := NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), ledger, nil, nil)
217 reg := tool.NewRegistry()
218 reg.Add(proxy)
219 readOnlyAgent := New(nil, reg, NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
220 declineArgs := `{"action":"decline","capability_id":"skill:review","reason":"not needed"}`
221 decline := readOnlyAgent.executeOne(context.Background(), &readOnlyAgent.turn, provider.ToolCall{ID: "decline-1", Name: "use_capability", Arguments: declineArgs})
222 if !decline.blocked {
223 t.Fatalf("decline outcome = %+v, want block", decline)
224 }
225 if gate := ledger.CheckFinalGate(); gate.Reason == "" {
226 t.Fatal("read-only decline mutated the capability ledger")
227 }
228
229 ordinary := New(nil, reg, NewSession("sys"), Options{}, event.Discard)
230 allowed := ordinary.executeOne(context.Background(), &ordinary.turn, provider.ToolCall{ID: "decline-2", Name: "use_capability", Arguments: declineArgs})
231 if allowed.blocked || allowed.errMsg != "" {
232 t.Fatalf("ordinary executor decline outcome = %+v", allowed)
233 }
234 if gate := ledger.CheckFinalGate(); gate.Reason != "" {
235 t.Fatalf("ordinary decline did not update ledger: %+v", gate)
236 }
237 }
238
239 func TestReadOnlyExecutionDoesNotStartUnauthorizedUnconnectedMCP(t *testing.T) {
240 host := plugin.NewHost()
241 defer host.Close()
242 proxy := NewUseCapabilityTool(context.Background(), host, []plugin.Spec{{
243 Name: "lazy", Type: "stdio", Command: "reasonix-test-definitely-missing-binary",
244 }}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
245 reg := tool.NewRegistry()
246 reg.Add(proxy)
247 a := New(nil, reg, NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
248 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
249 ID: "lazy-1", Name: "use_capability",
250 Arguments: `{"action":"call","capability_id":"mcp-tool:lazy/read_thing","arguments":{}}`,
251 })
252 if !out.blocked {
253 t.Fatalf("lazy MCP outcome = %+v, want block", out)
254 }
255 if host.HasClient("lazy") {
256 t.Fatal("read-only Agent started an unconnected MCP server")
257 }
258 }
259
260 func explicitReaderMCPServer(t *testing.T, schemaDrift *atomic.Bool, toolCalls *atomic.Int32) *httptest.Server {
261 t.Helper()
262 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
263 var request struct {
264 ID *int `json:"id"`
265 Method string `json:"method"`
266 }
267 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
268 http.Error(w, "bad request", http.StatusBadRequest)
269 return
270 }
271 if request.ID == nil {
272 w.WriteHeader(http.StatusAccepted)
273 return
274 }
275 var result any
276 switch request.Method {
277 case "initialize":
278 result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "explicit-reader", "version": "1"}}
279 case "tools/list":
280 schemaType := "string"
281 if schemaDrift != nil && schemaDrift.Load() {
282 schemaType = "number"
283 }
284 result = map[string]any{"tools": []map[string]any{{
285 "name": "search", "description": "search",
286 "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": schemaType}}},
287 "annotations": map[string]any{"readOnlyHint": true},
288 }}}
289 case "tools/call":
290 toolCalls.Add(1)
291 result = map[string]any{"content": []map[string]any{{"type": "text", "text": "reader result"}}}
292 }
293 w.Header().Set("Content-Type", "application/json")
294 _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
295 }))
296 }
297
298 func imageMCPServer(t *testing.T, toolCalls *atomic.Int32, payload string) *httptest.Server {
299 t.Helper()
300 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
301 var request struct {
302 ID *int `json:"id"`
303 Method string `json:"method"`
304 }
305 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
306 http.Error(w, "bad request", http.StatusBadRequest)
307 return
308 }
309 if request.ID == nil {
310 w.WriteHeader(http.StatusAccepted)
311 return
312 }
313 var result any
314 switch request.Method {
315 case "initialize":
316 result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "image", "version": "1"}}
317 case "tools/list":
318 result = map[string]any{"tools": []map[string]any{{
319 "name": "screenshot", "description": "capture screenshot",
320 "inputSchema": map[string]any{"type": "object"},
321 }}}
322 case "tools/call":
323 toolCalls.Add(1)
324 result = map[string]any{"content": []map[string]any{
325 {"type": "text", "text": "captured "},
326 {"type": "image", "mimeType": "image/png", "data": payload},
327 }}
328 }
329 w.Header().Set("Content-Type", "application/json")
330 _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
331 }))
332 }
333
334 func blockingReaderMCPServer(t *testing.T, callStarted chan<- struct{}, releaseCall <-chan struct{}, toolCalls *atomic.Int32) *httptest.Server {
335 t.Helper()
336 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
337 var request struct {
338 ID *int `json:"id"`
339 Method string `json:"method"`
340 }
341 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
342 http.Error(w, "bad request", http.StatusBadRequest)
343 return
344 }
345 if request.ID == nil {
346 w.WriteHeader(http.StatusAccepted)
347 return
348 }
349 var result any
350 switch request.Method {
351 case "initialize":
352 result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "blocking-reader", "version": "1"}}
353 case "tools/list":
354 result = map[string]any{"tools": []map[string]any{{
355 "name": "search", "description": "search",
356 "inputSchema": map[string]any{"type": "object"},
357 "annotations": map[string]any{"readOnlyHint": true},
358 }}}
359 case "tools/call":
360 toolCalls.Add(1)
361 select {
362 case callStarted <- struct{}{}:
363 default:
364 }
365 select {
366 case <-releaseCall:
367 case <-r.Context().Done():
368 return
369 }
370 result = map[string]any{"content": []map[string]any{{"type": "text", "text": "reader result"}}}
371 }
372 w.Header().Set("Content-Type", "application/json")
373 _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
374 }))
375 }
376
377 func opaqueMCPServer(t *testing.T, toolCalls *atomic.Int32) *httptest.Server {
378 t.Helper()
379 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
380 var request struct {
381 ID *int `json:"id"`
382 Method string `json:"method"`
383 }
384 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
385 http.Error(w, "bad request", http.StatusBadRequest)
386 return
387 }
388 if request.ID == nil {
389 w.WriteHeader(http.StatusAccepted)
390 return
391 }
392 var result any
393 switch request.Method {
394 case "initialize":
395 result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "opaque", "version": "1"}}
396 case "tools/list":
397 result = map[string]any{"tools": []map[string]any{{
398 "name": "query", "description": "query without MCP safety hints",
399 "inputSchema": map[string]any{"type": "object"},
400 }}}
401 case "tools/call":
402 toolCalls.Add(1)
403 result = map[string]any{"content": []map[string]any{{"type": "text", "text": "opaque result"}}}
404 }
405 w.Header().Set("Content-Type", "application/json")
406 _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
407 }))
408 }
409
410 func cacheExplicitReaderSchema(t *testing.T, spec plugin.Spec) {
411 t.Helper()
412 err := plugin.SaveCachedSchema(spec.Name, plugin.CachedSchema{
413 CacheKey: plugin.SchemaCacheKey(spec),
414 Tools: []plugin.CachedTool{{
415 Name: "search", Description: "search",
416 Schema: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`),
417 ReadOnly: true,
418 }},
419 })
420 if err != nil {
421 t.Fatal(err)
422 }
423 }
424
425 func TestReadOnlyExecutionStartsInstalledUnconnectedMCPReader(t *testing.T) {
426 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
427 var toolCalls atomic.Int32
428 server := explicitReaderMCPServer(t, nil, &toolCalls)
429 defer server.Close()
430
431 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir())
432 spec := plugin.Spec{
433 Name: "explicit-reader", Type: "http", URL: server.URL,
434 LaunchManager: manager, ConfigSource: "workspace_config",
435 Authorized: true,
436 }
437 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
438 defer cancel()
439 cacheExplicitReaderSchema(t, spec)
440
441 host := plugin.NewHost()
442 defer host.Close()
443 proxy := NewUseCapabilityTool(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
444 reg := tool.NewRegistry()
445 reg.Add(proxy)
446 a := New(nil, reg, NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
447 out := a.executeOne(ctx, &a.turn, provider.ToolCall{
448 ID: "installed-reader-1", Name: "use_capability",
449 Arguments: `{"action":"call","capability_id":"mcp-tool:explicit-reader/search","arguments":{}}`,
450 })
451 if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "reader result") {
452 t.Fatalf("installed lazy reader outcome = %+v", out)
453 }
454 if got := toolCalls.Load(); got != 1 {
455 t.Fatalf("reader tools/call count = %d, want 1", got)
456 }
457 }
458
459 func TestReadOnlyExecutionStartsPreviouslyAuthorizedProjectMCPReaderOnDemand(t *testing.T) {
460 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
461 var toolCalls atomic.Int32
462 server := explicitReaderMCPServer(t, nil, &toolCalls)
463 defer server.Close()
464
465 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir())
466 spec := plugin.Spec{
467 Name: "project-reader", Type: "http", URL: server.URL,
468 LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
469 }
470 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
471 defer cancel()
472 if err := plugin.AuthorizeSpecLaunch(ctx, spec); err != nil {
473 t.Fatalf("AuthorizeSpecLaunch: %v", err)
474 }
475 cacheExplicitReaderSchema(t, spec)
476
477 host := plugin.NewHost()
478 defer host.Close()
479 proxy := NewUseCapabilityTool(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
480 reg := tool.NewRegistry()
481 reg.Add(proxy)
482 a := New(nil, reg, NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
483 out := a.executeOne(ctx, &a.turn, provider.ToolCall{
484 ID: "authorized-project-1", Name: "use_capability",
485 Arguments: `{"action":"call","capability_id":"mcp-tool:project-reader/search","arguments":{}}`,
486 })
487 if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "reader result") {
488 t.Fatalf("authorized project reader outcome = %+v", out)
489 }
490 if got := toolCalls.Load(); got != 1 {
491 t.Fatalf("project reader tools/call count = %d, want 1", got)
492 }
493 }
494
495 func TestReadOnlyExecutionAllowsSchemaOnlyDriftForAuthorizedReader(t *testing.T) {
496 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
497 var schemaDrift atomic.Bool
498 var toolCalls atomic.Int32
499 server := explicitReaderMCPServer(t, &schemaDrift, &toolCalls)
500 defer server.Close()
501
502 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir())
503 spec := plugin.Spec{
504 Name: "explicit-reader", Type: "http", URL: server.URL,
505 LaunchManager: manager, ConfigSource: "workspace_config",
506 Authorized: true,
507 }
508 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
509 defer cancel()
510 cacheExplicitReaderSchema(t, spec)
511 schemaDrift.Store(true)
512
513 host := plugin.NewHost()
514 defer host.Close()
515 proxy := NewUseCapabilityTool(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
516 reg := tool.NewRegistry()
517 reg.Add(proxy)
518 a := New(nil, reg, NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
519 out := a.executeOne(ctx, &a.turn, provider.ToolCall{
520 ID: "drifted-lazy-1", Name: "use_capability",
521 Arguments: `{"action":"call","capability_id":"mcp-tool:explicit-reader/search","arguments":{}}`,
522 })
523 if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "reader result") {
524 t.Fatalf("schema-only drifted reader outcome = %+v", out)
525 }
526 if got := toolCalls.Load(); got != 1 {
527 t.Fatalf("schema-only drifted reader tools/call count = %d, want 1", got)
528 }
529 }
530
531 func TestReadOnlyExecutionDoesNotMarkUnknownCapabilityUnavailable(t *testing.T) {
532 ledger := capability.NewLedger()
533 proxy := NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), ledger, nil, nil)
534 reg := tool.NewRegistry()
535 reg.Add(proxy)
536 a := New(nil, reg, NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
537 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
538 ID: "missing-1", Name: "use_capability",
539 Arguments: `{"action":"call","capability_id":"mcp-tool:missing/read","arguments":{}}`,
540 })
541 if !out.blocked {
542 t.Fatalf("unknown capability outcome = %+v, want block", out)
543 }
544 if _, ok := ledger.Get("mcp-tool:missing/read"); ok {
545 t.Fatal("read-only Agent mutated the ledger for an unknown capability")
546 }
547 }
548 func (completedProxyCallTool) ResolveCall(context.Context, json.RawMessage) (tool.ResolvedCall, error) {
549 return tool.ResolvedCall{
550 DisplayName: "use_capability",
551 ProxyAction: "call",
552 CapabilityID: "mcp-server:mock",
553 SkipExecute: true,
554 ReadOnly: true,
555 Result: "mcp-tool:mock/echo",
556 }, nil
557 }
558
559 func TestUseCapabilityDeclineAndInspect(t *testing.T) {
560 ledger := capability.NewLedger()
561 ledger.SeedCandidates(capability.RouteDecision{Candidates: []capability.RouteCandidate{
562 {Entry: capability.Entry{ID: "skill:review"}, Policy: capability.AutoUsePrefer},
563 }})
564 audit := &capability.Audit{}
565 tl := NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), ledger, audit, func() capability.Catalog {
566 return capability.Catalog{Entries: []capability.Entry{{
567 ID: "skill:review", Kind: capability.KindSkill, Name: "review", Description: "review code", Status: capability.StatusReady,
568 }}}
569 })
570
571 out, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"skill:review"}`))
572 if err != nil || !strings.Contains(out, "skill:review") {
573 t.Fatalf("inspect: out=%q err=%v", out, err)
574 }
575 if _, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"decline","capability_id":"skill:review","reason":"not needed"}`)); err != nil {
576 t.Fatal(err)
577 }
578 if gate := ledger.CheckFinalGate(); gate.Reason != "" {
579 t.Fatalf("after decline gate = %+v", gate)
580 }
581 if got := audit.Snapshot().Declines; got != 1 {
582 t.Fatalf("decline audit = %d, want 1", got)
583 }
584 // Cannot decline require.
585 ledger.SeedCandidates(capability.RouteDecision{Candidates: []capability.RouteCandidate{
586 {Entry: capability.Entry{ID: "skill:must"}, Policy: capability.AutoUseRequire},
587 }})
588 if _, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"decline","capability_id":"skill:must","reason":"no"}`)); err == nil {
589 t.Fatal("expected decline of require to fail")
590 }
591 }
592
593 func TestUseCapabilityInspectMCPToolDoesNotListSiblingSchemas(t *testing.T) {
594 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
595 spec := plugin.Spec{Name: "db", Authorized: true}
596 if err := plugin.SaveCachedSchema(spec.Name, plugin.CachedSchema{
597 CacheKey: plugin.SchemaCacheKey(spec),
598 Tools: []plugin.CachedTool{
599 {Name: "read", Description: "allowed reader", Schema: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string"}}}`), ReadOnly: true},
600 {Name: "drop", Description: "secret destructive sibling", Schema: json.RawMessage(`{"type":"object","properties":{"table":{"type":"string"}}}`), Destructive: true},
601 },
602 }); err != nil {
603 t.Fatal(err)
604 }
605 target := capability.Entry{
606 ID: "mcp-tool:db/read", Kind: capability.KindMCPTool, Name: "read",
607 Description: "allowed reader", Source: "db", Status: capability.StatusConfigured,
608 }
609 tl := NewUseCapabilityTool(context.Background(), nil, []plugin.Spec{spec}, tool.NewRegistry(), nil, nil, func() capability.Catalog {
610 return capability.Catalog{Entries: []capability.Entry{target}}
611 })
612
613 out, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"mcp-tool:db/read"}`))
614 if err != nil {
615 t.Fatal(err)
616 }
617 if !strings.Contains(out, "mcp-tool:db/read") || !strings.Contains(out, "allowed reader") {
618 t.Fatalf("inspect omitted allowed tool metadata:\n%s", out)
619 }
620 if strings.Contains(out, "mcp-tool:db/drop") || strings.Contains(out, "secret destructive sibling") || strings.Contains(out, `"table"`) {
621 t.Fatalf("tool inspection leaked sibling metadata:\n%s", out)
622 }
623 }
624
625 func TestDedicatedSecurityReviewUsesCanonicalSkillCapabilityID(t *testing.T) {
626 got := capabilityIDFromToolCall("security_review", json.RawMessage(`{"task":"audit auth"}`))
627 if got != "skill:security-review" {
628 t.Fatalf("capability ID = %q, want skill:security-review", got)
629 }
630 }
631
632 func TestSkillInvocationUnavailableIsAudited(t *testing.T) {
633 audit := &capability.Audit{}
634 a := New(&scriptedProvider{name: "p"}, tool.NewRegistry(), NewSession("sys"), Options{
635 CapabilityLedger: capability.NewLedger(),
636 CapabilityAudit: audit,
637 }, event.Discard)
638 a.noteCapabilityInvocation("run_skill", json.RawMessage(`{"name":"delivery-only"}`), fmt.Errorf("run_skill: %w", skill.ErrInvocationUnavailable))
639 snap := audit.Snapshot()
640 if snap.SkillInvocations != 1 || snap.SkillFailures != 1 || snap.SkillUnavailable != 1 {
641 t.Fatalf("skill unavailable audit: invocations=%d failures=%d unavailable=%d",
642 snap.SkillInvocations, snap.SkillFailures, snap.SkillUnavailable)
643 }
644 }
645
646 func TestUseCapabilityProxyHonorsRealMCPPermissionDeny(t *testing.T) {
647 // Register a fake MCP tool in the registry so resolve uses it without host.
648 reg := tool.NewRegistry()
649 reg.Add(fakeTool{name: "mcp__github__search_issues", readOnly: true})
650 tl := NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil, nil)
651
652 resolved, err := tl.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:github/search_issues","arguments":{}}`))
653 if err != nil {
654 t.Fatal(err)
655 }
656 if resolved.TargetName != "mcp__github__search_issues" {
657 t.Fatalf("target = %q", resolved.TargetName)
658 }
659 if resolved.Target == nil {
660 t.Fatal("expected resolved target tool")
661 }
662 gate := denyAllGate{}
663 allow, reason, _ := gate.Check(context.Background(), resolved.TargetName, resolved.Args, resolved.ReadOnly)
664 if allow || !strings.Contains(reason, "mcp__github__search_issues") {
665 t.Fatalf("gate allow=%v reason=%q", allow, reason)
666 }
667 }
668
669 func TestUseCapabilityServerConnectHonorsPermissionInPlanMode(t *testing.T) {
670 host := plugin.NewHost()
671 defer host.Close()
672 specs := []plugin.Spec{{Name: "lazy", Type: "stdio", Command: "reasonix-test-definitely-missing-binary", Authorized: true}}
673 reg := tool.NewRegistry()
674 uc := NewUseCapabilityTool(context.Background(), host, specs, reg, capability.NewLedger(), nil, nil)
675 reg.Add(uc)
676
677 resolved, err := uc.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-server:lazy"}`))
678 if err != nil {
679 t.Fatal(err)
680 }
681 if resolved.Target == nil || resolved.SkipExecute {
682 t.Fatalf("expected deferred connect target, got %+v", resolved)
683 }
684 if resolved.TargetName != plugin.MCPConnectPermissionName("lazy") || resolved.ReadOnly {
685 t.Fatalf("connect gating identity wrong: name=%q readOnly=%v", resolved.TargetName, resolved.ReadOnly)
686 }
687 policyGate := permission.NewGate(permission.New("ask", nil, nil, []string{plugin.MCPConnectPermissionName("lazy")}), nil)
688 allow, _, err := policyGate.Check(context.Background(), resolved.TargetName, resolved.Args, resolved.ReadOnly)
689 if err != nil || allow {
690 t.Fatalf("exact MCP connect deny must block before spawn: allow=%v err=%v", allow, err)
691 }
692 deniedAgent := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"), Options{Gate: policyGate}, event.Discard)
693 deniedAgent.SetPlanMode(true)
694 denied := deniedAgent.executeOne(context.Background(), &deniedAgent.turn, provider.ToolCall{
695 ID: "deny", Name: "use_capability",
696 Arguments: `{"action":"call","capability_id":"mcp-server:lazy"}`,
697 })
698 if !denied.blocked || host.HasClient("lazy") {
699 t.Fatalf("exact connect deny must block before process start: outcome=%+v connected=%v", denied, host.HasClient("lazy"))
700 }
701 if host.HasClient("lazy") {
702 t.Fatal("server-level resolution must not start the server")
703 }
704 }
705
706 func TestOnDemandModelNameMatchesPluginCanonicalName(t *testing.T) {
707 host := plugin.NewHost()
708 defer host.Close()
709 specs := []plugin.Spec{{Name: "lazy", Type: "stdio", Command: "reasonix-test-definitely-missing-binary"}}
710 tl := NewUseCapabilityTool(context.Background(), host, specs, tool.NewRegistry(), capability.NewLedger(), nil, nil)
711 for _, raw := range []string{"@model/tool", "search/issues", "with space", "plain_ok"} {
712 resolved, err := tl.ResolveCall(context.Background(),
713 json.RawMessage(`{"action":"call","capability_id":"mcp-tool:lazy/`+raw+`"}`))
714 if err != nil {
715 t.Fatalf("%q: %v", raw, err)
716 }
717 want := plugin.ModelToolName("lazy", raw)
718 if resolved.TargetName != want {
719 t.Fatalf("raw %q: permission-checked name %q differs from executed canonical name %q — deny/ask rules would miss", raw, resolved.TargetName, want)
720 }
721 }
722 }
723
724 func TestProxyCallAuditCountsOnAgentPath(t *testing.T) {
725 reg := tool.NewRegistry()
726 reg.Add(fakeTool{name: "mcp__github__search_issues", readOnly: true})
727 audit := &capability.Audit{}
728 uc := NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), audit, nil)
729 reg.Add(uc)
730 a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"),
731 Options{CapabilityLedger: capability.NewLedger(), CapabilityAudit: audit}, event.Discard)
732 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
733 ID: "1", Name: "use_capability",
734 Arguments: `{"action":"call","capability_id":"mcp-tool:github/search_issues","arguments":{}}`,
735 })
736 if out.blocked || out.errMsg != "" {
737 t.Fatalf("call failed: %+v", out)
738 }
739 if snap := audit.Snapshot(); snap.MCPCall != 1 || snap.MCPCallFailures != 0 {
740 t.Fatalf("MCPCall=%d failures=%d, want 1/0", snap.MCPCall, snap.MCPCallFailures)
741 }
742 }
743
744 func TestCompletedProxyCallCountsOnAgentSkipExecutePath(t *testing.T) {
745 reg := tool.NewRegistry()
746 reg.Add(completedProxyCallTool{})
747 ledger := capability.NewLedger()
748 audit := &capability.Audit{}
749 a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"),
750 Options{CapabilityLedger: ledger, CapabilityAudit: audit}, event.Discard)
751 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
752 ID: "1", Name: "use_capability",
753 Arguments: `{"action":"call","capability_id":"mcp-server:mock"}`,
754 })
755 if out.blocked || out.errMsg != "" {
756 t.Fatalf("completed call failed: %+v", out)
757 }
758 if entry, ok := ledger.Get("mcp-server:mock"); !ok || entry.Outcome != capability.OutcomeSucceeded {
759 t.Fatalf("completed call ledger = %+v, found=%v", entry, ok)
760 }
761 if snap := audit.Snapshot(); snap.MCPCall != 1 || snap.MCPCallFailures != 0 {
762 t.Fatalf("completed call audit = %d/%d, want 1/0", snap.MCPCall, snap.MCPCallFailures)
763 }
764 }
765 func TestCapabilityGateRecoveryIsAudited(t *testing.T) {
766 reg := tool.NewRegistry()
767 audit := &capability.Audit{}
768 a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"),
769 Options{CapabilityLedger: capability.NewLedger(), CapabilityAudit: audit}, event.Discard)
770 a.turn = turnRuntime{deliveryScopeActive: true}
771 a.SeedCapabilityRoute(capability.RouteDecision{Candidates: []capability.RouteCandidate{
772 {Entry: capability.Entry{ID: "skill:review"}, Policy: capability.AutoUseRequire},
773 }})
774 a.task.ledger.Record(evidence.ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"a.go"}`), true, true))
775 if check := a.ReadinessResult(); check.Reason != "" {
776 t.Fatal("capability preference became a quality gate")
777 }
778 a.capabilityLedger.MarkInvoked("skill:review")
779 a.capabilityLedger.MarkSucceeded("skill:review")
780 if check := a.ReadinessResult(); strings.Contains(check.Reason, "required capabilities") {
781 t.Fatalf("gate should be clean after success, reason=%q", check.Reason)
782 }
783 if snap := audit.Snapshot(); snap.RequireRecovered != 0 {
784 t.Fatalf("RequireRecovered=%d, want 1", snap.RequireRecovered)
785 }
786 }
787
788 func TestRunSubAgentRequiresReviewReport(t *testing.T) {
789 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
790 {{Type: provider.ChunkText, Text: "looks fine"}, {Type: provider.ChunkDone}},
791 }}
792 answer, err := RunSubAgentWithSession(context.Background(), prov, tool.NewRegistry(), NewSession("sys"), "review it",
793 Options{RequireReviewReportKind: evidence.ReviewKindReview}, event.Discard)
794 if err != nil || answer != "looks fine" || prov.call != 1 {
795 t.Fatalf("review prose should finish directly: answer=%q calls=%d err=%v", answer, prov.call, err)
796 }
797 }
798
799 func TestUseCapabilityResolveCallIsSideEffectFree(t *testing.T) {
800 host := plugin.NewHost()
801 defer host.Close()
802 specs := []plugin.Spec{{
803 Name: "lazy",
804 Type: "stdio",
805 Command: "reasonix-test-definitely-missing-binary",
806 }}
807 tl := NewUseCapabilityTool(context.Background(), host, specs, tool.NewRegistry(), capability.NewLedger(), nil, nil)
808
809 resolved, err := tl.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:lazy/do_write","arguments":{}}`))
810 if err != nil {
811 t.Fatal(err)
812 }
813 if resolved.SkipExecute || resolved.Target == nil {
814 t.Fatalf("expected a deferred target, got %+v", resolved)
815 }
816 if resolved.ReadOnly {
817 t.Fatal("unstarted tool without read-only metadata must resolve as a writer")
818 }
819 if host.HasClient("lazy") {
820 t.Fatal("ResolveCall must not start the MCP server")
821 }
822 // Execution is where the connect finally happens — and fails for the
823 // missing binary, marking the capability unavailable.
824 ledger := capability.NewLedger()
825 tl.ledger = ledger
826 if _, err := resolved.Target.Execute(context.Background(), resolved.Args); err == nil {
827 t.Fatal("expected connect failure for missing binary")
828 }
829 if e, ok := ledger.Get("mcp-tool:lazy/do_write"); !ok || e.Outcome != capability.OutcomeUnavailable {
830 t.Fatalf("expected unavailable outcome, got %+v ok=%v", e, ok)
831 }
832 }
833
834 func TestUseCapabilityInspectDoesNotStartServer(t *testing.T) {
835 host := plugin.NewHost()
836 defer host.Close()
837 specs := []plugin.Spec{{Name: "lazy", Type: "stdio", Command: "reasonix-test-definitely-missing-binary"}}
838 tl := NewUseCapabilityTool(context.Background(), host, specs, tool.NewRegistry(), capability.NewLedger(), nil, func() capability.Catalog {
839 return capability.Catalog{Entries: []capability.Entry{{
840 ID: "mcp-server:lazy", Kind: capability.KindMCPServer, Name: "lazy", Source: "lazy", Status: capability.StatusConfigured,
841 }}}
842 })
843 out, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"mcp-server:lazy"}`))
844 if err != nil {
845 t.Fatal(err)
846 }
847 if host.HasClient("lazy") {
848 t.Fatal("inspect must not start the MCP server")
849 }
850 if !strings.Contains(out, "not connected") {
851 t.Fatalf("inspect output should say the server is not connected: %q", out)
852 }
853 }
854
855 func TestPlanModeBlocksInstalledWriteMCPResolvedThroughUseCapability(t *testing.T) {
856 reg := tool.NewRegistry()
857 reg.Add(annotatedMCPTool{fakeTool: fakeTool{name: "mcp__github__create_issue", readOnly: false}, server: "github", raw: "create_issue"})
858 reg.Add(annotatedMCPTool{fakeTool: fakeTool{name: "mcp__github__search_issues", readOnly: true}, server: "github", raw: "search_issues", serverAuthorized: true})
859 uc := NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil, nil)
860 reg.Add(uc)
861 gate := &mcpPermissionRecordingGate{allowNormal: true}
862 a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"), Options{Gate: gate}, event.Discard)
863 a.planMode.Store(true)
864
865 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
866 ID: "1", Name: "use_capability",
867 Arguments: `{"action":"call","capability_id":"mcp-tool:github/create_issue","arguments":{}}`,
868 })
869 if !out.blocked || gate.normalCalls != 0 {
870 t.Fatalf("installed MCP writer should be blocked before permission, outcome=%+v calls=%d", out, gate.normalCalls)
871 }
872 // A read-only target still passes through the proxy in plan mode.
873 out = a.executeOne(context.Background(), &a.turn, provider.ToolCall{
874 ID: "2", Name: "use_capability",
875 Arguments: `{"action":"call","capability_id":"mcp-tool:github/search_issues","arguments":{}}`,
876 })
877 if out.blocked {
878 t.Fatalf("read-only proxy call should pass in plan mode, got %+v", out)
879 }
880 }
881
882 func TestPlanModeMCPStyleNameWithoutMetadataStillUsesPermission(t *testing.T) {
883 reg := tool.NewRegistry()
884 reg.Add(fakeTool{name: "mcp__github__create_issue", readOnly: false})
885 uc := NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil, nil)
886 reg.Add(uc)
887 gate := &recordingPermissionGate{reason: "denied by ordinary permission"}
888 a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"), Options{Gate: gate}, event.Discard)
889 a.planMode.Store(true)
890
891 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
892 ID: "1", Name: "use_capability",
893 Arguments: `{"action":"call","capability_id":"mcp-tool:github/create_issue","arguments":{}}`,
894 })
895 if !out.blocked || !strings.Contains(out.output, "Plan mode") || len(gate.calls) != 0 {
896 t.Fatalf("MCP-style name must be hard-blocked in Plan: outcome=%+v calls=%+v", out, gate.calls)
897 }
898 }
899
900 func TestPlanModeBlocksAuthorizedDestructiveMCPThroughUseCapability(t *testing.T) {
901 reg := tool.NewRegistry()
902 reg.Add(annotatedMCPTool{
903 fakeTool: fakeTool{name: "mcp__github__delete_issue", readOnly: false},
904 server: "github",
905 raw: "delete_issue",
906 destructive: true,
907 serverAuthorized: true,
908 })
909 uc := NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil, nil)
910 reg.Add(uc)
911 gate := &mcpPermissionRecordingGate{allowNormal: true}
912 a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"), Options{Gate: gate}, event.Discard)
913 a.planMode.Store(true)
914
915 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
916 ID: "1", Name: "use_capability",
917 Arguments: `{"action":"call","capability_id":"mcp-tool:github/delete_issue","arguments":{"number":1}}`,
918 })
919 if !out.blocked || gate.normalCalls != 0 {
920 t.Fatalf("destructive proxy should be blocked before permission, outcome=%+v normal=%d", out, gate.normalCalls)
921 }
922 }
923
924 func TestCapabilityGateAppliesToReadOnlyTasks(t *testing.T) {
925 reg := tool.NewRegistry()
926 a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"),
927 Options{CapabilityLedger: capability.NewLedger()}, event.Discard)
928 a.turn = turnRuntime{deliveryScopeActive: true}
929 a.SeedCapabilityRoute(capability.RouteDecision{Candidates: []capability.RouteCandidate{
930 {Entry: capability.Entry{ID: "skill:review"}, Policy: capability.AutoUseRequire},
931 }})
932 a.task.ledger.Record(evidence.ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"a.go"}`), true, true))
933 if check := a.ReadinessResult(); strings.Contains(check.Reason, "required capabilities") {
934 t.Fatalf("read-only answer must not skip the require gate; reason = %q", check.Reason)
935 }
936 }
937
938 func TestUseCapabilityListActionNoSideEffects(t *testing.T) {
939 host := plugin.NewHost()
940 defer host.Close()
941 proxy := NewUseCapabilityTool(context.Background(), host, []plugin.Spec{
942 {Name: "zeta", Authorized: true},
943 {Name: "alpha", Authorized: true},
944 }, tool.NewRegistry(), nil, nil, nil)
945 resolved, err := proxy.ResolveCall(context.Background(), json.RawMessage(`{"action":"list"}`))
946 if err != nil {
947 t.Fatal(err)
948 }
949 if !resolved.SkipExecute || !resolved.ReadOnly || resolved.Target != nil {
950 t.Fatalf("list resolve = %+v", resolved)
951 }
952 if !strings.Contains(resolved.Result, `"name": "alpha"`) || !strings.Contains(resolved.Result, `"name": "zeta"`) {
953 t.Fatalf("list result missing sorted servers:\n%s", resolved.Result)
954 }
955 // alpha must appear before zeta in the JSON array for stable ordering.
956 if idxA, idxZ := strings.Index(resolved.Result, `"name": "alpha"`), strings.Index(resolved.Result, `"name": "zeta"`); idxA < 0 || idxZ < 0 || idxA > idxZ {
957 t.Fatalf("list servers not sorted: alpha@%d zeta@%d\n%s", idxA, idxZ, resolved.Result)
958 }
959 if host.HasClient("alpha") || host.HasClient("zeta") {
960 t.Fatal("list must not start servers")
961 }
962 }
963
964 func TestPlannerAllowsAuthorizedNonReadOnlyNonDestructiveMCP(t *testing.T) {
965 calls := 0
966 target := layeredReadOnlyMCPBoundaryTarget{
967 readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__db__query", readOnly: false, calls: &calls},
968 serverAuthorized: true,
969 }
970 reg := tool.NewRegistry()
971 reg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
972 ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: false, Args: json.RawMessage(`{}`),
973 }})
974 // Ordinary strict read-only still blocks non-readOnly MCP.
975 strict := New(nil, reg, NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
976 strictOut := strict.executeOne(context.Background(), &strict.turn, provider.ToolCall{
977 ID: "s1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:db/query","arguments":{}}`,
978 })
979 if !strictOut.blocked || calls != 0 {
980 t.Fatalf("strict read-only outcome = %+v calls=%d", strictOut, calls)
981 }
982
983 // Planner trusts authorized non-destructive MCP without readOnlyHint.
984 planner := NewPlannerAgent(nil, reg, NewSession("sys"), Options{}, event.Discard)
985 planner.SetPlanMode(true)
986 if !planner.plannerMCPExecution || !planner.readOnlyExecution {
987 t.Fatalf("planner flags = plannerMCP=%v readOnly=%v", planner.plannerMCPExecution, planner.readOnlyExecution)
988 }
989 out := planner.executeOne(context.Background(), &planner.turn, provider.ToolCall{
990 ID: "p1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:db/query","arguments":{}}`,
991 })
992 if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "target executed") {
993 t.Fatalf("planner non-readonly MCP outcome = %+v", out)
994 }
995 if calls != 1 {
996 t.Fatalf("planner target Execute calls = %d, want 1", calls)
997 }
998 }
999
1000 func TestPlannerPlanModeExecutesAuthorizedOpaqueMCPThroughRuntime(t *testing.T) {
1001 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
1002 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1003 defer cancel()
1004
1005 var toolCalls atomic.Int32
1006 server := opaqueMCPServer(t, &toolCalls)
1007 defer server.Close()
1008 spec := plugin.Spec{Name: "opaque", Type: "http", URL: server.URL, Authorized: true}
1009 host := plugin.NewHost()
1010 defer host.Close()
1011 if _, err := host.Add(ctx, spec); err != nil {
1012 t.Fatal(err)
1013 }
1014 runtime := NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), nil)
1015 reg := tool.NewRegistry()
1016 reg.Add(runtime.NewFrontend(capability.NewLedger(), nil))
1017 planner := NewPlannerAgent(nil, reg, NewSession("sys"), Options{Gate: denyAllGate{}}, event.Discard)
1018 planner.SetPlanMode(true)
1019
1020 out := planner.executeOne(ctx, &planner.turn, provider.ToolCall{
1021 ID: "opaque-plan", Name: "use_capability",
1022 Arguments: `{"action":"call","capability_id":"mcp-tool:opaque/query","arguments":{}}`,
1023 })
1024 if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "opaque result") {
1025 t.Fatalf("Planner opaque MCP outcome = %+v", out)
1026 }
1027 if got := toolCalls.Load(); got != 1 {
1028 t.Fatalf("opaque tools/call count = %d, want 1", got)
1029 }
1030 }
1031
1032 func TestPlannerAllowsConnectedServerDirectoryCall(t *testing.T) {
1033 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
1034 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1035 defer cancel()
1036
1037 var toolCalls atomic.Int32
1038 server := explicitReaderMCPServer(t, nil, &toolCalls)
1039 defer server.Close()
1040
1041 spec := plugin.Spec{Name: "connected", Type: "http", URL: server.URL, Authorized: true}
1042 host := plugin.NewHost()
1043 defer host.Close()
1044 if _, err := host.Add(ctx, spec); err != nil {
1045 t.Fatal(err)
1046 }
1047 runtime := NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), nil)
1048 reg := tool.NewRegistry()
1049 reg.Add(runtime.NewFrontend(capability.NewLedger(), nil))
1050 planner := NewPlannerAgent(nil, reg, NewSession("sys"), Options{}, event.Discard)
1051
1052 out := planner.executeOne(ctx, &planner.turn, provider.ToolCall{
1053 ID: "connected-directory", Name: "use_capability",
1054 Arguments: `{"action":"call","capability_id":"mcp-server:connected"}`,
1055 })
1056 if out.blocked || out.errMsg != "" {
1057 t.Fatalf("connected server directory outcome = %+v", out)
1058 }
1059 if !strings.Contains(out.output, `mcp-tool:connected/search`) {
1060 t.Fatalf("connected server directory missing tool capability: %q", out.output)
1061 }
1062 if toolCalls.Load() != 0 {
1063 t.Fatalf("server directory call executed tools/call %d times, want 0", toolCalls.Load())
1064 }
1065 }
1066
1067 func TestResolvedCapabilityDispatchRefreshesWriterClassification(t *testing.T) {
1068 calls := 0
1069 target := readOnlyBoundaryTarget{name: "mcp__db__write", readOnly: false, calls: &calls}
1070 reg := tool.NewRegistry()
1071 reg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
1072 ProxyAction: "call",
1073 CapabilityID: "mcp-tool:db/write",
1074 TargetName: target.Name(),
1075 Target: target,
1076 ReadOnly: false,
1077 Args: json.RawMessage(`{"value":"x"}`),
1078 }})
1079 session := NewSession("sys")
1080 call := provider.ToolCall{
1081 ID: "writer-1", Name: "use_capability",
1082 Arguments: `{"action":"call","capability_id":"mcp-tool:db/write","arguments":{"value":"x"}}`,
1083 }
1084 session.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{call}})
1085 var events []event.Event
1086 a := New(nil, reg, session, Options{}, event.FuncSink(func(e event.Event) {
1087 events = append(events, e)
1088 }))
1089
1090 results := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{call}).results
1091 if calls != 1 || len(results) != 1 || stripReceiptCitation(results[0]) != "target executed" {
1092 t.Fatalf("execution calls=%d results=%v", calls, results)
1093 }
1094
1095 var dispatches []event.Tool
1096 var result event.Tool
1097 for _, e := range events {
1098 switch e.Kind {
1099 case event.ToolDispatch:
1100 dispatches = append(dispatches, e.Tool)
1101 case event.ToolResult:
1102 result = e.Tool
1103 }
1104 }
1105 if len(dispatches) != 2 {
1106 t.Fatalf("dispatch count = %d, want initial + resolved refresh: %+v", len(dispatches), dispatches)
1107 }
1108 if dispatches[0].Refreshed || !dispatches[0].ReadOnly {
1109 t.Fatalf("initial proxy dispatch = %+v, want surface ReadOnly=true", dispatches[0])
1110 }
1111 refreshed := dispatches[1]
1112 if !refreshed.Refreshed || refreshed.ReadOnly || refreshed.ResolvedName != target.Name() || refreshed.CapabilityID != "mcp-tool:db/write" {
1113 t.Fatalf("resolved dispatch = %+v", refreshed)
1114 }
1115 if result.ReadOnly || result.ResolvedName != target.Name() || result.CapabilityID != "mcp-tool:db/write" {
1116 t.Fatalf("resolved result = %+v", result)
1117 }
1118
1119 stored := session.Snapshot()[1].ToolCalls[0]
1120 if stored.ResolvedReadOnly == nil || *stored.ResolvedReadOnly || stored.ResolvedName != target.Name() || stored.CapabilityID != "mcp-tool:db/write" {
1121 t.Fatalf("stored resolved metadata = %+v", stored)
1122 }
1123 }
1124
1125 func TestResolvedCapabilityRefreshesParallelCallsInProviderOrder(t *testing.T) {
1126 target := fakeTool{name: "mcp__db__query", readOnly: true, delay: 5 * time.Millisecond}
1127 reg := tool.NewRegistry()
1128 reg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
1129 ProxyAction: "call",
1130 CapabilityID: "mcp-tool:db/query",
1131 TargetName: target.Name(),
1132 Target: target,
1133 ReadOnly: true,
1134 Args: json.RawMessage(`{}`),
1135 }})
1136 calls := []provider.ToolCall{
1137 {ID: "c1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:db/query"}`},
1138 {ID: "c2", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:db/query"}`},
1139 }
1140 session := NewSession("sys")
1141 session.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: calls})
1142 var events []event.Event
1143 a := New(nil, reg, session, Options{}, event.FuncSink(func(e event.Event) {
1144 events = append(events, e)
1145 }))
1146
1147 a.executeBatch(context.Background(), &a.turn, calls)
1148
1149 var refreshed []string
1150 for _, e := range events {
1151 if e.Kind == event.ToolDispatch && e.Tool.Refreshed {
1152 refreshed = append(refreshed, e.Tool.ID)
1153 }
1154 }
1155 if strings.Join(refreshed, ",") != "c1,c2" {
1156 t.Fatalf("resolved refresh order = %v, want provider order", refreshed)
1157 }
1158 }
1159
1160 func TestPlannerBlocksDestructiveMCPWithExecutorHandoff(t *testing.T) {
1161 calls := 0
1162 target := layeredReadOnlyMCPBoundaryTarget{
1163 readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__db__drop", readOnly: false, calls: &calls},
1164 destructive: true,
1165 serverAuthorized: true,
1166 }
1167 reg := tool.NewRegistry()
1168 reg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
1169 ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: false, Args: json.RawMessage(`{}`),
1170 }})
1171 planner := NewPlannerAgent(nil, reg, NewSession("sys"), Options{}, event.Discard)
1172 out := planner.executeOne(context.Background(), &planner.turn, provider.ToolCall{
1173 ID: "p1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:db/drop","arguments":{}}`,
1174 })
1175 if !out.blocked || calls != 0 {
1176 t.Fatalf("destructive planner outcome = %+v calls=%d", out, calls)
1177 }
1178 if !strings.Contains(out.output, "Executor") || !strings.Contains(out.output, "handoff") {
1179 t.Fatalf("destructive block should guide Executor handoff, got %q", out.output)
1180 }
1181 if !strings.Contains(out.output, "do not treat this as missing MCP configuration") {
1182 t.Fatalf("destructive block should discourage config interpretation: %q", out.output)
1183 }
1184 }
1185
1186 func TestUseCapabilityDiscoveryCallsAreParallel(t *testing.T) {
1187 reg := tool.NewRegistry()
1188 reg.Add(fakeTool{name: "read_file", readOnly: true})
1189 reg.Add(NewUseCapabilityTool(t.Context(), nil, nil, reg, nil, nil, nil))
1190 calls := []provider.ToolCall{
1191 {ID: "1", Name: "use_capability", Arguments: `{"action":"list"}`},
1192 {ID: "2", Name: "use_capability", Arguments: `{"action":"list"}`},
1193 {ID: "3", Name: "read_file", Arguments: `{"path":"a.go"}`},
1194 }
1195 got := partitionToolCalls(reg, calls)
1196 if len(got) != 1 || !got[0].parallel || got[0].end != 3 {
1197 t.Fatalf("list/read-only discovery should batch: %+v", got)
1198 }
1199 }
1200
1201 func TestPlannerToolRegistryExcludesDirectMCPKeepsProxy(t *testing.T) {
1202 parent := tool.NewRegistry()
1203 parent.Add(fakeTool{name: "read_file", readOnly: true})
1204 parent.Add(fakeTool{name: "write_file", readOnly: false})
1205 parent.Add(annotatedMCPTool{
1206 fakeTool: fakeTool{name: "mcp__gh__search", readOnly: true},
1207 server: "gh",
1208 raw: "search",
1209 serverAuthorized: true,
1210 })
1211 parent.Add(fakeTool{name: "use_capability", readOnly: true})
1212 planner := PlannerToolRegistry(parent)
1213 names := strings.Join(planner.Names(), ",")
1214 if strings.Contains(names, "mcp__") {
1215 t.Fatalf("planner registry still has direct MCP: %s", names)
1216 }
1217 if _, ok := planner.Get("use_capability"); !ok {
1218 t.Fatal("planner registry missing use_capability")
1219 }
1220 if _, ok := planner.Get("write_file"); ok {
1221 t.Fatal("planner registry must not include writers")
1222 }
1223 if _, ok := planner.Get("read_file"); !ok {
1224 t.Fatal("planner registry missing read_file")
1225 }
1226 }
1227
1228 func TestPlannerSchemaStableAcrossProxyPresence(t *testing.T) {
1229 // Building planner registry with or without pre-registered mcp tools must
1230 // not change the fixed use_capability schema bytes.
1231 parent := tool.NewRegistry()
1232 proxy := NewUseCapabilityTool(context.Background(), nil, nil, parent, nil, nil, nil)
1233 parent.Add(proxy)
1234 parent.Add(fakeTool{name: "read_file", readOnly: true})
1235 parent.Add(annotatedMCPTool{
1236 fakeTool: fakeTool{name: "mcp__s__t", readOnly: true},
1237 server: "s", raw: "t", serverAuthorized: true,
1238 })
1239 reg1 := PlannerToolRegistry(parent)
1240 schema1, ok := reg1.Get("use_capability")
1241 if !ok {
1242 t.Fatal("missing use_capability")
1243 }
1244 bytes1 := string(schema1.Schema())
1245
1246 // Add more MCP tools and rebuild — schema bytes must match.
1247 parent.Add(annotatedMCPTool{
1248 fakeTool: fakeTool{name: "mcp__s__t2", readOnly: true},
1249 server: "s", raw: "t2", serverAuthorized: true,
1250 })
1251 reg2 := PlannerToolRegistry(parent)
1252 schema2, ok := reg2.Get("use_capability")
1253 if !ok {
1254 t.Fatal("missing use_capability after MCP add")
1255 }
1256 if string(schema2.Schema()) != bytes1 {
1257 t.Fatalf("use_capability schema changed after MCP add\nbefore=%s\nafter=%s", bytes1, schema2.Schema())
1258 }
1259 // Provider-visible tool order for planner must not include mcp__ and must
1260 // keep use_capability present with identical schema.
1261 for _, name := range reg2.Names() {
1262 if strings.HasPrefix(name, "mcp__") {
1263 t.Fatalf("reg2 still exposes %s", name)
1264 }
1265 }
1266 }
1267
1268 func TestMCPCapabilityRuntimeTracksHotLifecycleAndSharedHostRevocation(t *testing.T) {
1269 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
1270 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
1271 defer cancel()
1272
1273 var oldCalls atomic.Int32
1274 oldServer := explicitReaderMCPServer(t, nil, &oldCalls)
1275 defer oldServer.Close()
1276 var newCalls atomic.Int32
1277 newServer := explicitReaderMCPServer(t, nil, &newCalls)
1278 defer newServer.Close()
1279
1280 host := plugin.NewHost()
1281 defer host.Close()
1282 runtime := NewMCPCapabilityRuntime(ctx, host, nil, tool.NewRegistry(), nil)
1283 frontend := runtime.NewFrontend(nil, nil)
1284 entry := config.PluginEntry{Name: "hot", Type: "http", URL: oldServer.URL, Source: config.MCPSourceUserConfig}
1285 oldSpec := plugin.Spec{Name: "hot", Type: "http", URL: oldServer.URL, Authorized: true}
1286
1287 // Hot add must appear without rebuilding the provider-visible frontend.
1288 runtime.UpsertServer(entry, oldSpec, true)
1289 listed, err := frontend.Execute(ctx, json.RawMessage(`{"action":"list"}`))
1290 if err != nil || !strings.Contains(listed, `"name": "hot"`) {
1291 t.Fatalf("hot-added list = %q, %v", listed, err)
1292 }
1293 call := json.RawMessage(`{"action":"call","capability_id":"mcp-tool:hot/search","arguments":{"q":"x"}}`)
1294 if _, err := frontend.Execute(ctx, call); err != nil {
1295 t.Fatalf("old endpoint call: %v", err)
1296 }
1297 if oldCalls.Load() != 1 {
1298 t.Fatalf("old endpoint tool calls = %d, want 1", oldCalls.Load())
1299 }
1300
1301 // Updating the same stable server identity clears old live metadata and the
1302 // next disconnected call must use the replacement endpoint.
1303 host.Remove("hot")
1304 entry.URL = newServer.URL
1305 newSpec := plugin.Spec{Name: "hot", Type: "http", URL: newServer.URL, Authorized: true}
1306 runtime.UpsertServer(entry, newSpec, true)
1307 if runtime.ConnectedProxyTools() != nil {
1308 t.Fatal("endpoint update retained stale live tool snapshot")
1309 }
1310 if _, err := frontend.Execute(ctx, call); err != nil {
1311 t.Fatalf("new endpoint call: %v", err)
1312 }
1313 if newCalls.Load() != 1 || oldCalls.Load() != 1 {
1314 t.Fatalf("endpoint calls old=%d new=%d, want 1/1", oldCalls.Load(), newCalls.Load())
1315 }
1316
1317 // Per-controller disable wins over a still-connected shared Host client.
1318 // No reconnect or tools/call may occur, and live routing state is revoked.
1319 if !host.HasClient("hot") {
1320 t.Fatal("test requires shared Host client to remain connected")
1321 }
1322 if !runtime.SetServerEnabled("hot", false) {
1323 t.Fatal("disable did not find hot server")
1324 }
1325 if runtime.ConnectedProxyTools() != nil {
1326 t.Fatal("disable retained live proxy tools")
1327 }
1328 blocked, err := frontend.Execute(ctx, call)
1329 if err != nil || !strings.Contains(blocked, "disabled") {
1330 t.Fatalf("disabled shared-Host call = %q, %v, want fail-closed", blocked, err)
1331 }
1332 if newCalls.Load() != 1 {
1333 t.Fatalf("disabled shared-Host server executed %d calls, want 1", newCalls.Load())
1334 }
1335 listed, err = frontend.Execute(ctx, json.RawMessage(`{"action":"list"}`))
1336 if err != nil || !strings.Contains(listed, `"status": "disabled"`) || !strings.Contains(listed, `"connected": false`) {
1337 t.Fatalf("disabled list = %q, %v", listed, err)
1338 }
1339
1340 // Uninstall removes discovery and any possibility of a stale reconnect.
1341 if !runtime.RemoveServer("hot") {
1342 t.Fatal("remove did not find hot server")
1343 }
1344 listed, err = frontend.Execute(ctx, json.RawMessage(`{"action":"list"}`))
1345 if err != nil || strings.Contains(listed, `"name": "hot"`) {
1346 t.Fatalf("removed server leaked through list = %q, %v", listed, err)
1347 }
1348 }
1349
1350 func TestSharedHostSameNameRequiresCurrentRuntimeAuthorizationAndIdentity(t *testing.T) {
1351 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
1352 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1353 defer cancel()
1354
1355 for _, tc := range []struct {
1356 name string
1357 authorized bool
1358 want string
1359 }{
1360 {name: "unauthorized current identity", authorized: false, want: "not authorized"},
1361 {name: "different authorized identity", authorized: true, want: "identity"},
1362 } {
1363 t.Run(tc.name, func(t *testing.T) {
1364 var connectedCalls atomic.Int32
1365 connectedServer := explicitReaderMCPServer(t, nil, &connectedCalls)
1366 defer connectedServer.Close()
1367 var currentRequests atomic.Int32
1368 currentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1369 currentRequests.Add(1)
1370 http.Error(w, "must not connect", http.StatusForbidden)
1371 }))
1372 defer currentServer.Close()
1373
1374 host := plugin.NewHost()
1375 defer host.Close()
1376 connectedSpec := plugin.Spec{Name: "shared", Type: "http", URL: connectedServer.URL, Authorized: true}
1377 if _, err := host.Add(ctx, connectedSpec); err != nil {
1378 t.Fatal(err)
1379 }
1380 currentSpec := plugin.Spec{Name: "shared", Type: "http", URL: currentServer.URL, Authorized: tc.authorized}
1381 runtime := NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{currentSpec}, tool.NewRegistry(), nil)
1382 frontend := runtime.NewFrontend(capability.NewLedger(), nil)
1383
1384 out, err := frontend.Execute(ctx, json.RawMessage(`{"action":"call","capability_id":"mcp-tool:shared/search","arguments":{"q":"x"}}`))
1385 detail := strings.ToLower(out + " " + fmt.Sprint(err))
1386 if !strings.Contains(detail, tc.want) {
1387 t.Fatalf("same-name shared Host call = %q, %v, want %q", out, err, tc.want)
1388 }
1389 if connectedCalls.Load() != 0 || currentRequests.Load() != 0 {
1390 t.Fatalf("identity mismatch reached network: connected tools/call=%d current requests=%d", connectedCalls.Load(), currentRequests.Load())
1391 }
1392 })
1393 }
1394 }
1395
1396 func TestResolvedMCPCallRechecksRuntimeDisableBeforeDispatch(t *testing.T) {
1397 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
1398 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1399 defer cancel()
1400
1401 var toolCalls atomic.Int32
1402 server := explicitReaderMCPServer(t, nil, &toolCalls)
1403 defer server.Close()
1404 spec := plugin.Spec{Name: "revoked", Type: "http", URL: server.URL, Authorized: true}
1405 host := plugin.NewHost()
1406 defer host.Close()
1407 if _, err := host.Add(ctx, spec); err != nil {
1408 t.Fatal(err)
1409 }
1410 runtime := NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), nil)
1411 frontend := runtime.NewFrontend(capability.NewLedger(), nil)
1412 resolved, err := frontend.ResolveCall(ctx, json.RawMessage(`{"action":"call","capability_id":"mcp-tool:revoked/search","arguments":{"q":"x"}}`))
1413 if err != nil || resolved.Target == nil {
1414 t.Fatalf("resolve = %+v, %v", resolved, err)
1415 }
1416 if !runtime.SetServerEnabled("revoked", false) {
1417 t.Fatal("disable did not find resolved server")
1418 }
1419 if _, err := resolved.Target.Execute(ctx, resolved.Args); err == nil || !strings.Contains(strings.ToLower(err.Error()), "disabled") {
1420 t.Fatalf("resolved target after disable error = %v", err)
1421 }
1422 if toolCalls.Load() != 0 {
1423 t.Fatalf("resolved target executed tools/call %d times after disable", toolCalls.Load())
1424 }
1425 }
1426
1427 func TestRuntimeDisableLinearizesWithInFlightMCPDispatch(t *testing.T) {
1428 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
1429 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1430 defer cancel()
1431
1432 callStarted := make(chan struct{}, 1)
1433 releaseCall := make(chan struct{})
1434 var releaseOnce sync.Once
1435 release := func() { releaseOnce.Do(func() { close(releaseCall) }) }
1436 t.Cleanup(release)
1437 var toolCalls atomic.Int32
1438 server := blockingReaderMCPServer(t, callStarted, releaseCall, &toolCalls)
1439 defer server.Close()
1440
1441 spec := plugin.Spec{Name: "linear", Type: "http", URL: server.URL, Authorized: true}
1442 host := plugin.NewHost()
1443 defer host.Close()
1444 if _, err := host.Add(ctx, spec); err != nil {
1445 t.Fatal(err)
1446 }
1447 runtime := NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), nil)
1448 frontend := runtime.NewFrontend(capability.NewLedger(), nil)
1449 resolved, err := frontend.ResolveCall(ctx, json.RawMessage(`{"action":"call","capability_id":"mcp-tool:linear/search","arguments":{}}`))
1450 if err != nil || resolved.Target == nil {
1451 t.Fatalf("resolve = %+v, %v", resolved, err)
1452 }
1453
1454 executeDone := make(chan error, 1)
1455 go func() {
1456 _, err := resolved.Target.Execute(ctx, resolved.Args)
1457 executeDone <- err
1458 }()
1459 select {
1460 case <-callStarted:
1461 case <-ctx.Done():
1462 t.Fatalf("MCP call never reached dispatch: %v", ctx.Err())
1463 }
1464
1465 disableDone := make(chan bool, 1)
1466 go func() { disableDone <- runtime.SetServerEnabled("linear", false) }()
1467 select {
1468 case <-disableDone:
1469 t.Fatal("disable completed before the in-flight MCP dispatch crossed its linearization boundary")
1470 case <-time.After(100 * time.Millisecond):
1471 }
1472
1473 release()
1474 if err := <-executeDone; err != nil {
1475 t.Fatalf("in-flight MCP dispatch failed while disable waited: %v", err)
1476 }
1477 if ok := <-disableDone; !ok {
1478 t.Fatal("disable did not find the configured server")
1479 }
1480 if _, err := resolved.Target.Execute(ctx, resolved.Args); err == nil || !strings.Contains(strings.ToLower(err.Error()), "disabled") {
1481 t.Fatalf("post-disable resolved target error = %v", err)
1482 }
1483 if got := toolCalls.Load(); got != 1 {
1484 t.Fatalf("tools/call count = %d, want only the in-flight dispatch", got)
1485 }
1486 }
1487
1488 func TestMCPCapabilityRuntimeConcurrentUpdatesAndSnapshots(t *testing.T) {
1489 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
1490 runtime := NewMCPCapabilityRuntime(context.Background(), plugin.NewHost(), nil, tool.NewRegistry(), nil)
1491 defer runtime.host.Close()
1492 frontend := runtime.NewFrontend(nil, nil)
1493 entry := config.PluginEntry{Name: "race", Type: "http", Source: config.MCPSourceUserConfig}
1494
1495 var wg sync.WaitGroup
1496 wg.Add(2)
1497 go func() {
1498 defer wg.Done()
1499 for i := range 100 {
1500 entry.URL = fmt.Sprintf("http://127.0.0.1:%d", 10000+i)
1501 runtime.UpsertServer(entry, plugin.Spec{Name: "race", Type: "http", URL: entry.URL, Authorized: true}, true)
1502 runtime.state.setLiveTools("race", []plugin.CachedTool{{Name: "query", ReadOnly: true}})
1503 runtime.SetServerEnabled("race", i%2 == 0)
1504 if i%10 == 0 {
1505 runtime.RemoveServer("race")
1506 }
1507 }
1508 }()
1509 go func() {
1510 defer wg.Done()
1511 for range 100 {
1512 _, _ = frontend.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
1513 _, _, _, _, _ = runtime.CapabilityCatalogState()
1514 }
1515 }()
1516 wg.Wait()
1517 }
1518
1519 func TestUnauthorizedNonProjectMCPZeroProcessStart(t *testing.T) {
1520 // Spec.Authorized is the single truth: a host/session server with
1521 // RequireLaunchApproval=false but Authorized=false must not start.
1522 host := plugin.NewHost()
1523 defer host.Close()
1524 var started atomic.Int32
1525 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1526 started.Add(1)
1527 http.Error(w, "should not connect", http.StatusForbidden)
1528 }))
1529 defer srv.Close()
1530
1531 spec := plugin.Spec{
1532 Name: "untrusted", Type: "http", URL: srv.URL,
1533 // Explicitly unauthorized: boot/install must set Authorized=true for trust.
1534 Authorized: false, RequireLaunchApproval: false,
1535 }
1536 proxy := NewUseCapabilityTool(context.Background(), host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
1537 reg := tool.NewRegistry()
1538 reg.Add(proxy)
1539 a := New(nil, reg, NewSession("sys"), Options{}, event.Discard)
1540
1541 // Tool call path
1542 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
1543 ID: "u1", Name: "use_capability",
1544 Arguments: `{"action":"call","capability_id":"mcp-tool:untrusted/search","arguments":{}}`,
1545 })
1546 if !out.blocked && out.errMsg == "" {
1547 // May surface as error rather than blocked depending on resolve shape.
1548 if !strings.Contains(out.output, "not authorized") && !strings.Contains(out.errMsg, "not authorized") {
1549 t.Fatalf("unauthorized tool call outcome = %+v", out)
1550 }
1551 }
1552 if host.HasClient("untrusted") || started.Load() != 0 {
1553 t.Fatalf("unauthorized non-project MCP started process/network: connected=%v starts=%d", host.HasClient("untrusted"), started.Load())
1554 }
1555
1556 // Lifecycle connect path
1557 out2 := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
1558 ID: "u2", Name: "use_capability",
1559 Arguments: `{"action":"call","capability_id":"mcp-server:untrusted"}`,
1560 })
1561 if host.HasClient("untrusted") || started.Load() != 0 {
1562 t.Fatalf("unauthorized connect started process/network: outcome=%+v connected=%v starts=%d", out2, host.HasClient("untrusted"), started.Load())
1563 }
1564 if !out2.blocked && !strings.Contains(out2.output, "not authorized") && !strings.Contains(out2.errMsg, "not authorized") {
1565 t.Fatalf("unauthorized connect should refuse, got %+v", out2)
1566 }
1567 }
1568
1569 func TestAuthorizedMCPConnectUsesExplicitDenyOnlyGate(t *testing.T) {
1570 // dontAsk/ask policy must not block first connect of an authorized server;
1571 // only ExplicitlyDenies should stop it.
1572 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
1573 var toolCalls atomic.Int32
1574 server := explicitReaderMCPServer(t, nil, &toolCalls)
1575 defer server.Close()
1576
1577 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir())
1578 spec := plugin.Spec{
1579 Name: "explicit-reader", Type: "http", URL: server.URL,
1580 LaunchManager: manager, ConfigSource: "workspace_config",
1581 Authorized: true,
1582 }
1583 cacheExplicitReaderSchema(t, spec)
1584
1585 host := plugin.NewHost()
1586 defer host.Close()
1587 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1588 defer cancel()
1589 proxy := NewUseCapabilityTool(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
1590 reg := tool.NewRegistry()
1591 reg.Add(proxy)
1592
1593 // Gate that would deny all ordinary checks (simulates dontAsk / ask without answer).
1594 denyOrdinary := denyAllGate{}
1595 a := New(nil, reg, NewSession("sys"), Options{Gate: denyOrdinary}, event.Discard)
1596 out := a.executeOne(ctx, &a.turn, provider.ToolCall{
1597 ID: "c1", Name: "use_capability",
1598 Arguments: `{"action":"call","capability_id":"mcp-server:explicit-reader"}`,
1599 })
1600 // denyAllGate does not implement ExplicitDenyGate — trusted MCP path skips Gate.Check.
1601 if out.blocked || out.errMsg != "" {
1602 t.Fatalf("authorized lifecycle connect must not use ordinary Gate.Check: %+v", out)
1603 }
1604 if !host.HasClient("explicit-reader") {
1605 t.Fatal("authorized connect should start the server under deny-all ordinary gate")
1606 }
1607
1608 // Explicit deny on mcp_connect__ must still block a fresh unauthorized name.
1609 // Use a second server name with deny of its connect identity.
1610 spec2 := plugin.Spec{
1611 Name: "other-reader", Type: "http", URL: server.URL,
1612 LaunchManager: manager, ConfigSource: "workspace_config",
1613 Authorized: true,
1614 }
1615 cacheExplicitReaderSchema(t, spec2)
1616 proxy2 := NewUseCapabilityTool(ctx, host, []plugin.Spec{spec2}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
1617 reg2 := tool.NewRegistry()
1618 reg2.Add(proxy2)
1619 denyConnect := permission.NewGate(permission.New("ask", nil, nil, []string{plugin.MCPConnectPermissionName("other-reader")}), nil)
1620 a2 := New(nil, reg2, NewSession("sys"), Options{Gate: denyConnect}, event.Discard)
1621 out2 := a2.executeOne(ctx, &a2.turn, provider.ToolCall{
1622 ID: "c2", Name: "use_capability",
1623 Arguments: `{"action":"call","capability_id":"mcp-server:other-reader"}`,
1624 })
1625 if !out2.blocked || host.HasClient("other-reader") {
1626 t.Fatalf("explicit connect deny must block: outcome=%+v connected=%v", out2, host.HasClient("other-reader"))
1627 }
1628 }
1629
1629 lines GO