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