Files
esengine b364c1693c refactor(agent): remove the final-readiness gate, review gate and baseline checks
A turn that ends with its answer is done. The agent no longer asks, at the
point of stopping, whether a check ran after the last write, whether a
high-risk change was reviewed, or whether captured test criteria still hold;
it no longer ends a run with a readiness error, and the controller no longer
re-runs the turn to satisfy one.

Goal keeps its update_goal report and the evaluator, and still holds a
complete claim while the task list is unfinished; it no longer reads a
readiness result or freezes a verification contract. Review sub-agents are
ordinary sub-agents: nothing requires them to file a typed report.

Removes the readiness, owed-review, baseline-criteria, carried-proof and
review-report code, the delivery checkpoint, the readiness continuation, the
orphaned helpers they left in evidence, and the tests that asserted the removed
behaviour.
2026-11-02 05:15:57 -08:00

1670 lines
68 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"reasonix/internal/runtime/usecap"
"reasonix/internal/state/sessionstore"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/mcplaunch"
"reasonix/internal/ext/plugin"
"reasonix/internal/ext/skill"
"reasonix/internal/runtime/capability"
"reasonix/internal/safety/permission"
)
type denyAllGate struct{}
func (denyAllGate) Check(_ context.Context, name string, _ json.RawMessage, _ bool) (bool, string, error) {
return false, "denied " + name, nil
}
type completedProxyCallTool struct{}
func (completedProxyCallTool) Name() string { return "use_capability" }
func (completedProxyCallTool) Description() string { return "" }
func (completedProxyCallTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (completedProxyCallTool) ReadOnly() bool { return true }
func (completedProxyCallTool) Execute(context.Context, json.RawMessage) (string, error) {
return "", nil
}
type readOnlyBoundaryTarget struct {
name string
readOnly bool
hostStart bool
calls *int
}
func (t readOnlyBoundaryTarget) Name() string { return t.name }
func (readOnlyBoundaryTarget) Description() string { return "" }
func (readOnlyBoundaryTarget) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (t readOnlyBoundaryTarget) ReadOnly() bool { return t.readOnly }
func (t readOnlyBoundaryTarget) ReadOnlyExecutionHostMutation() bool { return t.hostStart }
func (t readOnlyBoundaryTarget) Execute(context.Context, json.RawMessage) (string, error) {
if t.calls != nil {
(*t.calls)++
}
return "target executed", nil
}
type readOnlyBoundaryProxy struct {
resolved tool.ResolvedCall
}
func (readOnlyBoundaryProxy) Name() string { return "use_capability" }
func (readOnlyBoundaryProxy) Description() string { return "" }
func (readOnlyBoundaryProxy) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (readOnlyBoundaryProxy) ReadOnly() bool { return true }
func (readOnlyBoundaryProxy) Execute(context.Context, json.RawMessage) (string, error) {
return "proxy executed", nil
}
func (p readOnlyBoundaryProxy) ResolveCall(context.Context, json.RawMessage) (tool.ResolvedCall, error) {
return p.resolved, nil
}
type layeredReadOnlyMCPBoundaryTarget struct {
readOnlyBoundaryTarget
destructive bool
serverAuthorized bool
}
func (layeredReadOnlyMCPBoundaryTarget) MCPServerName() string { return "test" }
func (layeredReadOnlyMCPBoundaryTarget) MCPRawToolName() string { return "read" }
func (t layeredReadOnlyMCPBoundaryTarget) MCPDestructiveHint() bool { return t.destructive }
func (t layeredReadOnlyMCPBoundaryTarget) MCPServerAuthorized() bool { return t.serverAuthorized }
func executeReadOnlyBoundaryCall(t *testing.T, resolved tool.ResolvedCall) toolOutcome {
t.Helper()
reg := tool.NewRegistry()
reg.Add(readOnlyBoundaryProxy{resolved: resolved})
a := New(nil, reg, sessionstore.NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
return a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "ro-1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:test/tool"}`,
})
}
func TestReadOnlyExecutionBlocksResolvedWriterAndHostStartup(t *testing.T) {
for _, tc := range []struct {
name string
readOnly bool
hostStart bool
}{
{name: "writer"},
{name: "host startup", readOnly: true, hostStart: true},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
target := readOnlyBoundaryTarget{name: "mcp__test__tool", readOnly: tc.readOnly, hostStart: tc.hostStart, calls: &calls}
out := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: tc.readOnly, Args: json.RawMessage(`{}`),
})
if !out.blocked || !strings.Contains(out.output, "read-only agent") {
t.Fatalf("resolved call outcome = %+v, want host block", out)
}
if calls != 0 {
t.Fatalf("target Execute calls = %d, want 0", calls)
}
})
}
}
func TestReadOnlyExecutionAllowsInspectAndOrdinaryReadOnlyCall(t *testing.T) {
inspect := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
ProxyAction: "inspect", SkipExecute: true, ReadOnly: true, Result: "metadata",
})
if inspect.blocked || inspect.errMsg != "" || inspect.output != "metadata" {
t.Fatalf("inspect outcome = %+v", inspect)
}
calls := 0
target := readOnlyBoundaryTarget{name: "mcp__test__read", readOnly: true, calls: &calls}
call := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: true, Args: json.RawMessage(`{}`),
})
if call.blocked || call.errMsg != "" || !strings.Contains(call.output, "target executed") {
t.Fatalf("read-only call outcome = %+v", call)
}
if calls != 1 {
t.Fatalf("target Execute calls = %d, want 1", calls)
}
}
func TestReadOnlyExecutionAllowsOnlyAuthorizedReadOnlyMCPStartup(t *testing.T) {
for _, tc := range []struct {
name string
authorized bool
destructive bool
wantBlocked bool
}{
{name: "authorized reader", authorized: true},
{name: "unauthorized server", wantBlocked: true},
{name: "destructive reader", authorized: true, destructive: true, wantBlocked: true},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
target := layeredReadOnlyMCPBoundaryTarget{
readOnlyBoundaryTarget: readOnlyBoundaryTarget{
name: "mcp__test__read", readOnly: true, hostStart: true, calls: &calls,
},
destructive: tc.destructive, serverAuthorized: tc.authorized,
}
out := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: true, Args: json.RawMessage(`{}`),
})
if out.blocked != tc.wantBlocked {
t.Fatalf("layered MCP outcome = %+v, want blocked=%v", out, tc.wantBlocked)
}
wantCalls := 1
if tc.wantBlocked {
wantCalls = 0
}
if calls != wantCalls {
t.Fatalf("target Execute calls = %d, want %d", calls, wantCalls)
}
})
}
}
func TestStrictReadOnlyExecutionRegistryFailsClosed(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "writer", readOnly: false})
reg.Add(readOnlyBoundaryTarget{name: "ordinary_read", readOnly: true})
reg.Add(layeredReadOnlyMCPBoundaryTarget{
readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__test__trusted", readOnly: true, hostStart: true},
serverAuthorized: true,
})
reg.Add(layeredReadOnlyMCPBoundaryTarget{
readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__test__destructive", readOnly: true, hostStart: true},
destructive: true,
})
filtered := strictReadOnlyExecutionRegistry(reg)
if got, want := strings.Join(filtered.Names(), ","), "ordinary_read,mcp__test__trusted"; got != want {
t.Fatalf("strict registry = %q, want %q", got, want)
}
}
func TestReadOnlyExecutionBlocksUnauthorizedMCPAndDecline(t *testing.T) {
calls := 0
target := layeredReadOnlyMCPBoundaryTarget{readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__test__hint", readOnly: true, calls: &calls}}
out := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: true, Args: json.RawMessage(`{}`),
})
if !out.blocked || calls != 0 {
t.Fatalf("unauthorized read-only outcome = %+v calls=%d", out, calls)
}
ledger := capability.NewLedger()
ledger.SeedCandidates(capability.RouteDecision{Candidates: []capability.RouteCandidate{
{Entry: capability.Entry{ID: "skill:review"}, Policy: capability.AutoUsePrefer},
}})
proxy := usecap.NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), ledger, nil, nil)
reg := tool.NewRegistry()
reg.Add(proxy)
readOnlyAgent := New(nil, reg, sessionstore.NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
declineArgs := `{"action":"decline","capability_id":"skill:review","reason":"not needed"}`
decline := readOnlyAgent.executeOne(context.Background(), &readOnlyAgent.turn, provider.ToolCall{ID: "decline-1", Name: "use_capability", Arguments: declineArgs})
if !decline.blocked {
t.Fatalf("decline outcome = %+v, want block", decline)
}
if gate := ledger.CheckFinalGate(); gate.Reason == "" {
t.Fatal("read-only decline mutated the capability ledger")
}
ordinary := New(nil, reg, sessionstore.NewSession("sys"), Options{}, event.Discard)
allowed := ordinary.executeOne(context.Background(), &ordinary.turn, provider.ToolCall{ID: "decline-2", Name: "use_capability", Arguments: declineArgs})
if allowed.blocked || allowed.errMsg != "" {
t.Fatalf("ordinary executor decline outcome = %+v", allowed)
}
if gate := ledger.CheckFinalGate(); gate.Reason != "" {
t.Fatalf("ordinary decline did not update ledger: %+v", gate)
}
}
func TestReadOnlyExecutionDoesNotStartUnauthorizedUnconnectedMCP(t *testing.T) {
host := plugin.NewHost()
defer host.Close()
proxy := usecap.NewUseCapabilityTool(context.Background(), host, []plugin.Spec{{
Name: "lazy", Type: "stdio", Command: "reasonix-test-definitely-missing-binary",
}}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
reg := tool.NewRegistry()
reg.Add(proxy)
a := New(nil, reg, sessionstore.NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "lazy-1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:lazy/read_thing","arguments":{}}`,
})
if !out.blocked {
t.Fatalf("lazy MCP outcome = %+v, want block", out)
}
if host.HasClient("lazy") {
t.Fatal("read-only Agent started an unconnected MCP server")
}
}
func explicitReaderMCPServer(t *testing.T, schemaDrift *atomic.Bool, toolCalls *atomic.Int32) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID *int `json:"id"`
Method string `json:"method"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if request.ID == nil {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch request.Method {
case "initialize":
result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "explicit-reader", "version": "1"}}
case "tools/list":
schemaType := "string"
if schemaDrift != nil && schemaDrift.Load() {
schemaType = "number"
}
result = map[string]any{"tools": []map[string]any{{
"name": "search", "description": "search",
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": schemaType}}},
"annotations": map[string]any{"readOnlyHint": true},
}}}
case "tools/call":
toolCalls.Add(1)
result = map[string]any{"content": []map[string]any{{"type": "text", "text": "reader result"}}}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
}))
}
func imageMCPServer(t *testing.T, toolCalls *atomic.Int32, payload string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID *int `json:"id"`
Method string `json:"method"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if request.ID == nil {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch request.Method {
case "initialize":
result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "image", "version": "1"}}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "screenshot", "description": "capture screenshot",
"inputSchema": map[string]any{"type": "object"},
}}}
case "tools/call":
toolCalls.Add(1)
result = map[string]any{"content": []map[string]any{
{"type": "text", "text": "captured "},
{"type": "image", "mimeType": "image/png", "data": payload},
}}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
}))
}
func TestPlannerFirstOnDemandMCPCallPreservesImages(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
// A decodable 1x1 PNG: an image whose bytes do not decode never reaches the
// provider, so a placeholder payload would assert nothing.
payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
var toolCalls atomic.Int32
server := imageMCPServer(t, &toolCalls, payload)
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
host := plugin.NewHost()
defer host.Close()
spec := plugin.Spec{Name: "image", Type: "http", URL: server.URL, Authorized: true}
runtime := usecap.NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), nil)
proxy := runtime.NewFrontend(capability.NewLedger(), nil)
reg := tool.NewRegistry()
reg.Add(proxy)
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("image-call", "use_capability", `{"action":"call","capability_id":"mcp-tool:image/screenshot","arguments":{}}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
session := sessionstore.NewSession("sys")
planner := NewPlannerAgent(prov, reg, session, Options{}, event.Discard)
if host.HasClient("image") {
t.Fatal("test requires the MCP server to start on first tool dispatch")
}
if err := planner.Run(ctx, "take a screenshot"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := toolCalls.Load(); got != 1 {
t.Fatalf("image tools/call count = %d, want 1", got)
}
wantImage := "data:image/png;base64," + payload
for _, message := range session.Messages {
if message.Role != provider.RoleTool || message.ToolCallID != "image-call" {
continue
}
if len(message.Images) != 1 || message.Images[0] != wantImage {
t.Fatalf("first on-demand MCP images = %v, want %q", message.Images, wantImage)
}
if !strings.Contains(message.Content, "captured [image: image/png]") {
t.Fatalf("first on-demand MCP text = %q, want image placeholder", message.Content)
}
return
}
t.Fatal("no tool message recorded for first on-demand MCP call")
}
func blockingReaderMCPServer(t *testing.T, callStarted chan<- struct{}, releaseCall <-chan struct{}, toolCalls *atomic.Int32) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID *int `json:"id"`
Method string `json:"method"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if request.ID == nil {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch request.Method {
case "initialize":
result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "blocking-reader", "version": "1"}}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "search", "description": "search",
"inputSchema": map[string]any{"type": "object"},
"annotations": map[string]any{"readOnlyHint": true},
}}}
case "tools/call":
toolCalls.Add(1)
select {
case callStarted <- struct{}{}:
default:
}
select {
case <-releaseCall:
case <-r.Context().Done():
return
}
result = map[string]any{"content": []map[string]any{{"type": "text", "text": "reader result"}}}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
}))
}
func opaqueMCPServer(t *testing.T, toolCalls *atomic.Int32) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID *int `json:"id"`
Method string `json:"method"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if request.ID == nil {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch request.Method {
case "initialize":
result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "opaque", "version": "1"}}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "query", "description": "query without MCP safety hints",
"inputSchema": map[string]any{"type": "object"},
}}}
case "tools/call":
toolCalls.Add(1)
result = map[string]any{"content": []map[string]any{{"type": "text", "text": "opaque result"}}}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
}))
}
func cacheExplicitReaderSchema(t *testing.T, spec plugin.Spec) {
t.Helper()
err := plugin.SaveCachedSchema(spec.Name, plugin.CachedSchema{
CacheKey: plugin.SchemaCacheKey(spec),
Tools: []plugin.CachedTool{{
Name: "search", Description: "search",
Schema: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`),
ReadOnly: true,
}},
})
if err != nil {
t.Fatal(err)
}
}
func TestReadOnlyExecutionStartsInstalledUnconnectedMCPReader(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
var toolCalls atomic.Int32
server := explicitReaderMCPServer(t, nil, &toolCalls)
defer server.Close()
manager := mcplaunch.NewManager(filepath.Join(testenv.TempDir(t), mcplaunch.StateFilename), testenv.TempDir(t))
spec := plugin.Spec{
Name: "explicit-reader", Type: "http", URL: server.URL,
LaunchManager: manager, ConfigSource: "workspace_config",
Authorized: true,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cacheExplicitReaderSchema(t, spec)
host := plugin.NewHost()
defer host.Close()
proxy := usecap.NewUseCapabilityTool(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
reg := tool.NewRegistry()
reg.Add(proxy)
a := New(nil, reg, sessionstore.NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
out := a.executeOne(ctx, &a.turn, provider.ToolCall{
ID: "installed-reader-1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:explicit-reader/search","arguments":{}}`,
})
if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "reader result") {
t.Fatalf("installed lazy reader outcome = %+v", out)
}
if got := toolCalls.Load(); got != 1 {
t.Fatalf("reader tools/call count = %d, want 1", got)
}
}
func TestReadOnlyExecutionStartsPreviouslyAuthorizedProjectMCPReaderOnDemand(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
var toolCalls atomic.Int32
server := explicitReaderMCPServer(t, nil, &toolCalls)
defer server.Close()
manager := mcplaunch.NewManager(filepath.Join(testenv.TempDir(t), mcplaunch.StateFilename), testenv.TempDir(t))
spec := plugin.Spec{
Name: "project-reader", Type: "http", URL: server.URL,
LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := plugin.AuthorizeSpecLaunch(ctx, spec); err != nil {
t.Fatalf("AuthorizeSpecLaunch: %v", err)
}
cacheExplicitReaderSchema(t, spec)
host := plugin.NewHost()
defer host.Close()
proxy := usecap.NewUseCapabilityTool(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
reg := tool.NewRegistry()
reg.Add(proxy)
a := New(nil, reg, sessionstore.NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
out := a.executeOne(ctx, &a.turn, provider.ToolCall{
ID: "authorized-project-1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:project-reader/search","arguments":{}}`,
})
if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "reader result") {
t.Fatalf("authorized project reader outcome = %+v", out)
}
if got := toolCalls.Load(); got != 1 {
t.Fatalf("project reader tools/call count = %d, want 1", got)
}
}
func TestReadOnlyExecutionAllowsSchemaOnlyDriftForAuthorizedReader(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
var schemaDrift atomic.Bool
var toolCalls atomic.Int32
server := explicitReaderMCPServer(t, &schemaDrift, &toolCalls)
defer server.Close()
manager := mcplaunch.NewManager(filepath.Join(testenv.TempDir(t), mcplaunch.StateFilename), testenv.TempDir(t))
spec := plugin.Spec{
Name: "explicit-reader", Type: "http", URL: server.URL,
LaunchManager: manager, ConfigSource: "workspace_config",
Authorized: true,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cacheExplicitReaderSchema(t, spec)
schemaDrift.Store(true)
host := plugin.NewHost()
defer host.Close()
proxy := usecap.NewUseCapabilityTool(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
reg := tool.NewRegistry()
reg.Add(proxy)
a := New(nil, reg, sessionstore.NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
out := a.executeOne(ctx, &a.turn, provider.ToolCall{
ID: "drifted-lazy-1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:explicit-reader/search","arguments":{}}`,
})
if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "reader result") {
t.Fatalf("schema-only drifted reader outcome = %+v", out)
}
if got := toolCalls.Load(); got != 1 {
t.Fatalf("schema-only drifted reader tools/call count = %d, want 1", got)
}
}
func TestReadOnlyExecutionDoesNotMarkUnknownCapabilityUnavailable(t *testing.T) {
ledger := capability.NewLedger()
proxy := usecap.NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), ledger, nil, nil)
reg := tool.NewRegistry()
reg.Add(proxy)
a := New(nil, reg, sessionstore.NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "missing-1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:missing/read","arguments":{}}`,
})
if !out.blocked {
t.Fatalf("unknown capability outcome = %+v, want block", out)
}
if _, ok := ledger.Get("mcp-tool:missing/read"); ok {
t.Fatal("read-only Agent mutated the ledger for an unknown capability")
}
}
func (completedProxyCallTool) ResolveCall(context.Context, json.RawMessage) (tool.ResolvedCall, error) {
return tool.ResolvedCall{
DisplayName: "use_capability",
ProxyAction: "call",
CapabilityID: "mcp-server:mock",
SkipExecute: true,
ReadOnly: true,
Result: "mcp-tool:mock/echo",
}, nil
}
func TestUseCapabilityDeclineAndInspect(t *testing.T) {
ledger := capability.NewLedger()
ledger.SeedCandidates(capability.RouteDecision{Candidates: []capability.RouteCandidate{
{Entry: capability.Entry{ID: "skill:review"}, Policy: capability.AutoUsePrefer},
}})
audit := &capability.Audit{}
tl := usecap.NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), ledger, audit, func() capability.Catalog {
return capability.Catalog{Entries: []capability.Entry{{
ID: "skill:review", Kind: capability.KindSkill, Name: "review", Description: "review code", Status: capability.StatusReady,
}}}
})
out, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"skill:review"}`))
if err != nil || !strings.Contains(out, "skill:review") {
t.Fatalf("inspect: out=%q err=%v", out, err)
}
if _, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"decline","capability_id":"skill:review","reason":"not needed"}`)); err != nil {
t.Fatal(err)
}
if gate := ledger.CheckFinalGate(); gate.Reason != "" {
t.Fatalf("after decline gate = %+v", gate)
}
if got := audit.Snapshot().Declines; got != 1 {
t.Fatalf("decline audit = %d, want 1", got)
}
// Cannot decline require.
ledger.SeedCandidates(capability.RouteDecision{Candidates: []capability.RouteCandidate{
{Entry: capability.Entry{ID: "skill:must"}, Policy: capability.AutoUseRequire},
}})
if _, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"decline","capability_id":"skill:must","reason":"no"}`)); err == nil {
t.Fatal("expected decline of require to fail")
}
}
func TestUseCapabilityInspectMCPToolDoesNotListSiblingSchemas(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
spec := plugin.Spec{Name: "db", Authorized: true}
if err := plugin.SaveCachedSchema(spec.Name, plugin.CachedSchema{
CacheKey: plugin.SchemaCacheKey(spec),
Tools: []plugin.CachedTool{
{Name: "read", Description: "allowed reader", Schema: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string"}}}`), ReadOnly: true},
{Name: "drop", Description: "secret destructive sibling", Schema: json.RawMessage(`{"type":"object","properties":{"table":{"type":"string"}}}`), Destructive: true},
},
}); err != nil {
t.Fatal(err)
}
target := capability.Entry{
ID: "mcp-tool:db/read", Kind: capability.KindMCPTool, Name: "read",
Description: "allowed reader", Source: "db", Status: capability.StatusConfigured,
}
tl := usecap.NewUseCapabilityTool(context.Background(), nil, []plugin.Spec{spec}, tool.NewRegistry(), nil, nil, func() capability.Catalog {
return capability.Catalog{Entries: []capability.Entry{target}}
})
out, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"mcp-tool:db/read"}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "mcp-tool:db/read") || !strings.Contains(out, "allowed reader") {
t.Fatalf("inspect omitted allowed tool metadata:\n%s", out)
}
if strings.Contains(out, "mcp-tool:db/drop") || strings.Contains(out, "secret destructive sibling") || strings.Contains(out, `"table"`) {
t.Fatalf("tool inspection leaked sibling metadata:\n%s", out)
}
}
func TestDedicatedSecurityReviewUsesCanonicalSkillCapabilityID(t *testing.T) {
got := capabilityIDFromToolCall("security_review", json.RawMessage(`{"task":"audit auth"}`))
if got != "skill:security-review" {
t.Fatalf("capability ID = %q, want skill:security-review", got)
}
}
func TestSkillInvocationUnavailableIsAudited(t *testing.T) {
audit := &capability.Audit{}
a := New(&scriptedProvider{name: "p"}, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{
CapabilityLedger: capability.NewLedger(),
CapabilityAudit: audit,
}, event.Discard)
a.noteCapabilityInvocation("run_skill", json.RawMessage(`{"name":"delivery-only"}`), nil, fmt.Errorf("run_skill: %w", skill.ErrInvocationUnavailable))
snap := audit.Snapshot()
if snap.SkillInvocations != 1 || snap.SkillFailures != 1 || snap.SkillUnavailable != 1 {
t.Fatalf("skill unavailable audit: invocations=%d failures=%d unavailable=%d",
snap.SkillInvocations, snap.SkillFailures, snap.SkillUnavailable)
}
}
func TestUseCapabilityProxyHonorsRealMCPPermissionDeny(t *testing.T) {
// Register a fake MCP tool in the registry so resolve uses it without host.
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "mcp__github__search_issues", readOnly: true})
tl := usecap.NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil, nil)
resolved, err := tl.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:github/search_issues","arguments":{}}`))
if err != nil {
t.Fatal(err)
}
if resolved.TargetName != "mcp__github__search_issues" {
t.Fatalf("target = %q", resolved.TargetName)
}
if resolved.Target == nil {
t.Fatal("expected resolved target tool")
}
gate := denyAllGate{}
allow, reason, _ := gate.Check(context.Background(), resolved.TargetName, resolved.Args, resolved.ReadOnly)
if allow || !strings.Contains(reason, "mcp__github__search_issues") {
t.Fatalf("gate allow=%v reason=%q", allow, reason)
}
}
func TestUseCapabilityServerConnectHonorsPermissionInPlanMode(t *testing.T) {
host := plugin.NewHost()
defer host.Close()
specs := []plugin.Spec{{Name: "lazy", Type: "stdio", Command: "reasonix-test-definitely-missing-binary", Authorized: true}}
reg := tool.NewRegistry()
uc := usecap.NewUseCapabilityTool(context.Background(), host, specs, reg, capability.NewLedger(), nil, nil)
reg.Add(uc)
resolved, err := uc.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-server:lazy"}`))
if err != nil {
t.Fatal(err)
}
if resolved.Target == nil || resolved.SkipExecute {
t.Fatalf("expected deferred connect target, got %+v", resolved)
}
if resolved.TargetName != plugin.MCPConnectPermissionName("lazy") || resolved.ReadOnly {
t.Fatalf("connect gating identity wrong: name=%q readOnly=%v", resolved.TargetName, resolved.ReadOnly)
}
policyGate := permission.NewGate(permission.New("ask", nil, nil, []string{plugin.MCPConnectPermissionName("lazy")}), nil)
allow, _, err := policyGate.Check(context.Background(), resolved.TargetName, resolved.Args, resolved.ReadOnly)
if err != nil || allow {
t.Fatalf("exact MCP connect deny must block before spawn: allow=%v err=%v", allow, err)
}
deniedAgent := New(&scriptedProvider{name: "p"}, reg, sessionstore.NewSession("sys"), Options{Gate: policyGate}, event.Discard)
deniedAgent.SetPlanMode(true)
denied := deniedAgent.executeOne(context.Background(), &deniedAgent.turn, provider.ToolCall{
ID: "deny", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-server:lazy"}`,
})
if !denied.blocked || host.HasClient("lazy") {
t.Fatalf("exact connect deny must block before process start: outcome=%+v connected=%v", denied, host.HasClient("lazy"))
}
if host.HasClient("lazy") {
t.Fatal("server-level resolution must not start the server")
}
}
func TestOnDemandModelNameMatchesPluginCanonicalName(t *testing.T) {
host := plugin.NewHost()
defer host.Close()
specs := []plugin.Spec{{Name: "lazy", Type: "stdio", Command: "reasonix-test-definitely-missing-binary"}}
tl := usecap.NewUseCapabilityTool(context.Background(), host, specs, tool.NewRegistry(), capability.NewLedger(), nil, nil)
for _, raw := range []string{"@model/tool", "search/issues", "with space", "plain_ok"} {
resolved, err := tl.ResolveCall(context.Background(),
json.RawMessage(`{"action":"call","capability_id":"mcp-tool:lazy/`+raw+`"}`))
if err != nil {
t.Fatalf("%q: %v", raw, err)
}
want := plugin.ModelToolName("lazy", raw)
if resolved.TargetName != want {
t.Fatalf("raw %q: permission-checked name %q differs from executed canonical name %q — deny/ask rules would miss", raw, resolved.TargetName, want)
}
}
}
func TestProxyCallAuditCountsOnAgentPath(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "mcp__github__search_issues", readOnly: true})
audit := &capability.Audit{}
uc := usecap.NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), audit, nil)
reg.Add(uc)
a := New(&scriptedProvider{name: "p"}, reg, sessionstore.NewSession("sys"),
Options{CapabilityLedger: capability.NewLedger(), CapabilityAudit: audit}, event.Discard)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:github/search_issues","arguments":{}}`,
})
if out.blocked || out.errMsg != "" {
t.Fatalf("call failed: %+v", out)
}
if snap := audit.Snapshot(); snap.MCPCall != 1 || snap.MCPCallFailures != 0 {
t.Fatalf("MCPCall=%d failures=%d, want 1/0", snap.MCPCall, snap.MCPCallFailures)
}
}
func TestCompletedProxyCallCountsOnAgentSkipExecutePath(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(completedProxyCallTool{})
ledger := capability.NewLedger()
audit := &capability.Audit{}
a := New(&scriptedProvider{name: "p"}, reg, sessionstore.NewSession("sys"),
Options{CapabilityLedger: ledger, CapabilityAudit: audit}, event.Discard)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-server:mock"}`,
})
if out.blocked || out.errMsg != "" {
t.Fatalf("completed call failed: %+v", out)
}
if entry, ok := ledger.Get("mcp-server:mock"); !ok || entry.Outcome != capability.OutcomeSucceeded {
t.Fatalf("completed call ledger = %+v, found=%v", entry, ok)
}
if snap := audit.Snapshot(); snap.MCPCall != 1 || snap.MCPCallFailures != 0 {
t.Fatalf("completed call audit = %d/%d, want 1/0", snap.MCPCall, snap.MCPCallFailures)
}
}
func TestUseCapabilityInspectDoesNotStartServer(t *testing.T) {
host := plugin.NewHost()
defer host.Close()
specs := []plugin.Spec{{Name: "lazy", Type: "stdio", Command: "reasonix-test-definitely-missing-binary"}}
tl := usecap.NewUseCapabilityTool(context.Background(), host, specs, tool.NewRegistry(), capability.NewLedger(), nil, func() capability.Catalog {
return capability.Catalog{Entries: []capability.Entry{{
ID: "mcp-server:lazy", Kind: capability.KindMCPServer, Name: "lazy", Source: "lazy", Status: capability.StatusConfigured,
}}}
})
out, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"mcp-server:lazy"}`))
if err != nil {
t.Fatal(err)
}
if host.HasClient("lazy") {
t.Fatal("inspect must not start the MCP server")
}
if !strings.Contains(out, "not connected") {
t.Fatalf("inspect output should say the server is not connected: %q", out)
}
}
func TestPlanModeBlocksInstalledWriteMCPResolvedThroughUseCapability(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(annotatedMCPTool{fakeTool: fakeTool{name: "mcp__github__create_issue", readOnly: false}, server: "github", raw: "create_issue"})
reg.Add(annotatedMCPTool{fakeTool: fakeTool{name: "mcp__github__search_issues", readOnly: true}, server: "github", raw: "search_issues", serverAuthorized: true})
uc := usecap.NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil, nil)
reg.Add(uc)
gate := &mcpPermissionRecordingGate{allowNormal: true}
a := New(&scriptedProvider{name: "p"}, reg, sessionstore.NewSession("sys"), Options{Gate: gate}, event.Discard)
a.SetPlanMode(true)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:github/create_issue","arguments":{}}`,
})
if !out.blocked || gate.normalCalls != 0 {
t.Fatalf("installed MCP writer should be blocked before permission, outcome=%+v calls=%d", out, gate.normalCalls)
}
// A read-only target still passes through the proxy in plan mode.
out = a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "2", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:github/search_issues","arguments":{}}`,
})
if out.blocked {
t.Fatalf("read-only proxy call should pass in plan mode, got %+v", out)
}
}
func TestPlanModeMCPStyleNameWithoutMetadataStillUsesPermission(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "mcp__github__create_issue", readOnly: false})
uc := usecap.NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil, nil)
reg.Add(uc)
gate := &recordingPermissionGate{reason: "denied by ordinary permission"}
a := New(&scriptedProvider{name: "p"}, reg, sessionstore.NewSession("sys"), Options{Gate: gate}, event.Discard)
a.SetPlanMode(true)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:github/create_issue","arguments":{}}`,
})
if !out.blocked || !strings.Contains(out.output, "Plan mode") || len(gate.calls) != 0 {
t.Fatalf("MCP-style name must be hard-blocked in Plan: outcome=%+v calls=%+v", out, gate.calls)
}
}
func TestPlanModeBlocksAuthorizedDestructiveMCPThroughUseCapability(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(annotatedMCPTool{
fakeTool: fakeTool{name: "mcp__github__delete_issue", readOnly: false},
server: "github",
raw: "delete_issue",
destructive: true,
serverAuthorized: true,
})
uc := usecap.NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil, nil)
reg.Add(uc)
gate := &mcpPermissionRecordingGate{allowNormal: true}
a := New(&scriptedProvider{name: "p"}, reg, sessionstore.NewSession("sys"), Options{Gate: gate}, event.Discard)
a.SetPlanMode(true)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:github/delete_issue","arguments":{"number":1}}`,
})
if !out.blocked || gate.normalCalls != 0 {
t.Fatalf("destructive proxy should be blocked before permission, outcome=%+v normal=%d", out, gate.normalCalls)
}
}
func TestUseCapabilityListActionNoSideEffects(t *testing.T) {
host := plugin.NewHost()
defer host.Close()
proxy := usecap.NewUseCapabilityTool(context.Background(), host, []plugin.Spec{
{Name: "zeta", Authorized: true},
{Name: "alpha", Authorized: true},
}, tool.NewRegistry(), nil, nil, nil)
resolved, err := proxy.ResolveCall(context.Background(), json.RawMessage(`{"action":"list"}`))
if err != nil {
t.Fatal(err)
}
if !resolved.SkipExecute || !resolved.ReadOnly || resolved.Target != nil {
t.Fatalf("list resolve = %+v", resolved)
}
if !strings.Contains(resolved.Result, `"name": "alpha"`) || !strings.Contains(resolved.Result, `"name": "zeta"`) {
t.Fatalf("list result missing sorted servers:\n%s", resolved.Result)
}
// alpha must appear before zeta in the JSON array for stable ordering.
if idxA, idxZ := strings.Index(resolved.Result, `"name": "alpha"`), strings.Index(resolved.Result, `"name": "zeta"`); idxA < 0 || idxZ < 0 || idxA > idxZ {
t.Fatalf("list servers not sorted: alpha@%d zeta@%d\n%s", idxA, idxZ, resolved.Result)
}
if host.HasClient("alpha") || host.HasClient("zeta") {
t.Fatal("list must not start servers")
}
}
func TestPlannerAllowsAuthorizedNonReadOnlyNonDestructiveMCP(t *testing.T) {
calls := 0
target := layeredReadOnlyMCPBoundaryTarget{
readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__db__query", readOnly: false, calls: &calls},
serverAuthorized: true,
}
reg := tool.NewRegistry()
reg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: false, Args: json.RawMessage(`{}`),
}})
// Ordinary strict read-only still blocks non-readOnly MCP.
strict := New(nil, reg, sessionstore.NewSession("sys"), Options{ReadOnlyExecution: true}, event.Discard)
strictOut := strict.executeOne(context.Background(), &strict.turn, provider.ToolCall{
ID: "s1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:db/query","arguments":{}}`,
})
if !strictOut.blocked || calls != 0 {
t.Fatalf("strict read-only outcome = %+v calls=%d", strictOut, calls)
}
// Planner trusts authorized non-destructive MCP without readOnlyHint.
planner := NewPlannerAgent(nil, reg, sessionstore.NewSession("sys"), Options{}, event.Discard)
planner.SetPlanMode(true)
if !planner.role.plannerMCPExecution || !planner.role.readOnlyExecution {
t.Fatalf("planner flags = plannerMCP=%v readOnly=%v", planner.role.plannerMCPExecution, planner.role.readOnlyExecution)
}
out := planner.executeOne(context.Background(), &planner.turn, provider.ToolCall{
ID: "p1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:db/query","arguments":{}}`,
})
if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "target executed") {
t.Fatalf("planner non-readonly MCP outcome = %+v", out)
}
if calls != 1 {
t.Fatalf("planner target Execute calls = %d, want 1", calls)
}
}
func TestPlannerPlanModeExecutesAuthorizedOpaqueMCPThroughRuntime(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var toolCalls atomic.Int32
server := opaqueMCPServer(t, &toolCalls)
defer server.Close()
spec := plugin.Spec{Name: "opaque", Type: "http", URL: server.URL, Authorized: true}
host := plugin.NewHost()
defer host.Close()
if _, err := host.Add(ctx, spec); err != nil {
t.Fatal(err)
}
runtime := usecap.NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), nil)
reg := tool.NewRegistry()
reg.Add(runtime.NewFrontend(capability.NewLedger(), nil))
planner := NewPlannerAgent(nil, reg, sessionstore.NewSession("sys"), Options{Gate: denyAllGate{}}, event.Discard)
planner.SetPlanMode(true)
out := planner.executeOne(ctx, &planner.turn, provider.ToolCall{
ID: "opaque-plan", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:opaque/query","arguments":{}}`,
})
if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "opaque result") {
t.Fatalf("Planner opaque MCP outcome = %+v", out)
}
if got := toolCalls.Load(); got != 1 {
t.Fatalf("opaque tools/call count = %d, want 1", got)
}
}
func TestPlannerAllowsConnectedServerDirectoryCall(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var toolCalls atomic.Int32
server := explicitReaderMCPServer(t, nil, &toolCalls)
defer server.Close()
spec := plugin.Spec{Name: "connected", Type: "http", URL: server.URL, Authorized: true}
host := plugin.NewHost()
defer host.Close()
if _, err := host.Add(ctx, spec); err != nil {
t.Fatal(err)
}
runtime := usecap.NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), nil)
reg := tool.NewRegistry()
reg.Add(runtime.NewFrontend(capability.NewLedger(), nil))
planner := NewPlannerAgent(nil, reg, sessionstore.NewSession("sys"), Options{}, event.Discard)
out := planner.executeOne(ctx, &planner.turn, provider.ToolCall{
ID: "connected-directory", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-server:connected"}`,
})
if out.blocked || out.errMsg != "" {
t.Fatalf("connected server directory outcome = %+v", out)
}
if !strings.Contains(out.output, `mcp-tool:connected/search`) {
t.Fatalf("connected server directory missing tool capability: %q", out.output)
}
if toolCalls.Load() != 0 {
t.Fatalf("server directory call executed tools/call %d times, want 0", toolCalls.Load())
}
}
func TestResolvedCapabilityDispatchRefreshesWriterClassification(t *testing.T) {
calls := 0
target := readOnlyBoundaryTarget{name: "mcp__db__write", readOnly: false, calls: &calls}
reg := tool.NewRegistry()
reg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
ProxyAction: "call",
CapabilityID: "mcp-tool:db/write",
TargetName: target.Name(),
Target: target,
ReadOnly: false,
Args: json.RawMessage(`{"value":"x"}`),
}})
session := sessionstore.NewSession("sys")
call := provider.ToolCall{
ID: "writer-1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:db/write","arguments":{"value":"x"}}`,
}
session.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{call}})
var events []event.Event
a := New(nil, reg, session, Options{}, event.FuncSink(func(e event.Event) {
events = append(events, e)
}))
results := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{call}).results
if calls != 1 || len(results) != 1 || !strings.HasPrefix(results[0], "target executed") {
t.Fatalf("execution calls=%d results=%v", calls, results)
}
var dispatches []event.Tool
var result event.Tool
for _, e := range events {
switch e.Kind {
case event.ToolDispatch:
dispatches = append(dispatches, e.Tool)
case event.ToolResult:
result = e.Tool
}
}
if len(dispatches) != 2 {
t.Fatalf("dispatch count = %d, want initial + resolved refresh: %+v", len(dispatches), dispatches)
}
if dispatches[0].Refreshed || !dispatches[0].ReadOnly {
t.Fatalf("initial proxy dispatch = %+v, want surface ReadOnly=true", dispatches[0])
}
refreshed := dispatches[1]
if !refreshed.Refreshed || refreshed.ReadOnly || refreshed.ResolvedName != target.Name() || refreshed.CapabilityID != "mcp-tool:db/write" {
t.Fatalf("resolved dispatch = %+v", refreshed)
}
if result.ReadOnly || result.ResolvedName != target.Name() || result.CapabilityID != "mcp-tool:db/write" {
t.Fatalf("resolved result = %+v", result)
}
stored := session.Snapshot()[1].ToolCalls[0]
if stored.ResolvedReadOnly == nil || *stored.ResolvedReadOnly || stored.ResolvedName != target.Name() || stored.CapabilityID != "mcp-tool:db/write" {
t.Fatalf("stored resolved metadata = %+v", stored)
}
}
func TestResolvedCapabilityRefreshesParallelCallsInProviderOrder(t *testing.T) {
target := fakeTool{name: "mcp__db__query", readOnly: true, delay: 5 * time.Millisecond}
reg := tool.NewRegistry()
reg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
ProxyAction: "call",
CapabilityID: "mcp-tool:db/query",
TargetName: target.Name(),
Target: target,
ReadOnly: true,
Args: json.RawMessage(`{}`),
}})
calls := []provider.ToolCall{
{ID: "c1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:db/query"}`},
{ID: "c2", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:db/query"}`},
}
session := sessionstore.NewSession("sys")
session.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: calls})
var events []event.Event
a := New(nil, reg, session, Options{}, event.FuncSink(func(e event.Event) {
events = append(events, e)
}))
a.executeBatch(context.Background(), &a.turn, calls)
var refreshed []string
for _, e := range events {
if e.Kind == event.ToolDispatch && e.Tool.Refreshed {
refreshed = append(refreshed, e.Tool.ID)
}
}
if strings.Join(refreshed, ",") != "c1,c2" {
t.Fatalf("resolved refresh order = %v, want provider order", refreshed)
}
}
func TestPlannerBlocksDestructiveMCPWithExecutorHandoff(t *testing.T) {
calls := 0
target := layeredReadOnlyMCPBoundaryTarget{
readOnlyBoundaryTarget: readOnlyBoundaryTarget{name: "mcp__db__drop", readOnly: false, calls: &calls},
destructive: true,
serverAuthorized: true,
}
reg := tool.NewRegistry()
reg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: false, Args: json.RawMessage(`{}`),
}})
planner := NewPlannerAgent(nil, reg, sessionstore.NewSession("sys"), Options{}, event.Discard)
out := planner.executeOne(context.Background(), &planner.turn, provider.ToolCall{
ID: "p1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:db/drop","arguments":{}}`,
})
if !out.blocked || calls != 0 {
t.Fatalf("destructive planner outcome = %+v calls=%d", out, calls)
}
if !strings.Contains(out.output, "Executor") || !strings.Contains(out.output, "handoff") {
t.Fatalf("destructive block should guide Executor handoff, got %q", out.output)
}
if !strings.Contains(out.output, "do not treat this as missing MCP configuration") {
t.Fatalf("destructive block should discourage config interpretation: %q", out.output)
}
}
func TestUseCapabilityBatchingFollowsWhatItProxies(t *testing.T) {
reg := builtinToolRegistry()
cat := capability.Catalog{Entries: []capability.Entry{
{ID: "mcp:srv/read", Kind: capability.KindTool, ReadOnly: true},
{ID: "mcp:srv/write", Kind: capability.KindTool, ReadOnly: false},
}}
proxy := usecap.NewUseCapabilityTool(context.Background(), nil, nil, reg, capability.NewLedger(), nil,
func() capability.Catalog { return cat })
reg.Add(proxy)
uc := func(args string) provider.ToolCall {
return provider.ToolCall{Name: "use_capability", Arguments: args}
}
for _, tc := range []struct {
name string
calls []provider.ToolCall
want []toolCallBatch
}{
{
// Two directory reads and a file read are all reads: one batch.
name: "list shares a batch",
calls: []provider.ToolCall{
uc(`{"action":"list"}`), uc(`{"action":"list"}`),
{Name: "read_file", Arguments: `{"path":"a.go"}`},
},
want: []toolCallBatch{{start: 0, end: 3, parallel: true}},
},
{
// A read-only target is a read; the proxy is not what decides.
name: "call to a read-only capability shares a batch",
calls: []provider.ToolCall{
uc(`{"action":"call","capability_id":"mcp:srv/read"}`),
{Name: "read_file", Arguments: `{"path":"a.go"}`},
},
want: []toolCallBatch{{start: 0, end: 2, parallel: true}},
},
{
name: "call to a writer keeps its place",
calls: []provider.ToolCall{
{Name: "read_file", Arguments: `{"path":"a.go"}`},
uc(`{"action":"call","capability_id":"mcp:srv/write"}`),
},
want: []toolCallBatch{{start: 0, end: 1, parallel: true}, {start: 1, end: 2}},
},
{
// An id the catalog cannot resolve is the one whose effects are unknown.
name: "call to an unknown id keeps its place",
calls: []provider.ToolCall{
{Name: "read_file", Arguments: `{"path":"a.go"}`},
uc(`{"action":"call","capability_id":"mcp:srv/never-heard-of"}`),
},
want: []toolCallBatch{{start: 0, end: 1, parallel: true}, {start: 1, end: 2}},
},
{
// inspect can start a server and decline writes the ledger.
name: "inspect and decline keep their places",
calls: []provider.ToolCall{
uc(`{"action":"inspect","capability_id":"mcp:srv/read"}`),
uc(`{"action":"decline","capability_id":"mcp:srv/read","reason":"no"}`),
},
want: []toolCallBatch{{start: 0, end: 1}, {start: 1, end: 2}},
},
{
name: "unparseable args keep their place",
calls: []provider.ToolCall{uc(`{`)},
want: []toolCallBatch{{start: 0, end: 1}},
},
} {
t.Run(tc.name, func(t *testing.T) {
got := partitionToolCalls(context.Background(), reg, tc.calls)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("partition = %+v, want %+v", got, tc.want)
}
})
}
}
func TestPlannerToolRegistryExcludesDirectMCPKeepsProxy(t *testing.T) {
parent := tool.NewRegistry()
parent.Add(fakeTool{name: "read_file", readOnly: true})
parent.Add(fakeTool{name: "write_file", readOnly: false, writesPaths: true})
parent.Add(annotatedMCPTool{
fakeTool: fakeTool{name: "mcp__gh__search", readOnly: true},
server: "gh",
raw: "search",
serverAuthorized: true,
})
parent.Add(fakeTool{name: "use_capability", readOnly: true})
planner := PlannerToolRegistry(parent)
names := strings.Join(planner.Names(), ",")
if strings.Contains(names, "mcp__") {
t.Fatalf("planner registry still has direct MCP: %s", names)
}
if _, ok := planner.Get("use_capability"); !ok {
t.Fatal("planner registry missing use_capability")
}
if _, ok := planner.Get("write_file"); ok {
t.Fatal("planner registry must not include writers")
}
if _, ok := planner.Get("read_file"); !ok {
t.Fatal("planner registry missing read_file")
}
}
func TestPlannerSchemaStableAcrossProxyPresence(t *testing.T) {
// Building planner registry with or without pre-registered mcp tools must
// not change the fixed use_capability schema bytes.
parent := tool.NewRegistry()
proxy := usecap.NewUseCapabilityTool(context.Background(), nil, nil, parent, nil, nil, nil)
parent.Add(proxy)
parent.Add(fakeTool{name: "read_file", readOnly: true})
parent.Add(annotatedMCPTool{
fakeTool: fakeTool{name: "mcp__s__t", readOnly: true},
server: "s", raw: "t", serverAuthorized: true,
})
reg1 := PlannerToolRegistry(parent)
schema1, ok := reg1.Get("use_capability")
if !ok {
t.Fatal("missing use_capability")
}
bytes1 := string(schema1.Schema())
// Add more MCP tools and rebuild — schema bytes must match.
parent.Add(annotatedMCPTool{
fakeTool: fakeTool{name: "mcp__s__t2", readOnly: true},
server: "s", raw: "t2", serverAuthorized: true,
})
reg2 := PlannerToolRegistry(parent)
schema2, ok := reg2.Get("use_capability")
if !ok {
t.Fatal("missing use_capability after MCP add")
}
if string(schema2.Schema()) != bytes1 {
t.Fatalf("use_capability schema changed after MCP add\nbefore=%s\nafter=%s", bytes1, schema2.Schema())
}
// Provider-visible tool order for planner must not include mcp__ and must
// keep use_capability present with identical schema.
for _, name := range reg2.Names() {
if strings.HasPrefix(name, "mcp__") {
t.Fatalf("reg2 still exposes %s", name)
}
}
}
func TestMCPCapabilityRuntimeTracksHotLifecycleAndSharedHostRevocation(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var oldCalls atomic.Int32
oldServer := explicitReaderMCPServer(t, nil, &oldCalls)
defer oldServer.Close()
var newCalls atomic.Int32
newServer := explicitReaderMCPServer(t, nil, &newCalls)
defer newServer.Close()
host := plugin.NewHost()
defer host.Close()
runtime := usecap.NewMCPCapabilityRuntime(ctx, host, nil, tool.NewRegistry(), nil)
frontend := runtime.NewFrontend(nil, nil)
entry := config.PluginEntry{Name: "hot", Type: "http", URL: oldServer.URL, Source: config.MCPSourceUserConfig}
oldSpec := plugin.Spec{Name: "hot", Type: "http", URL: oldServer.URL, Authorized: true}
// Hot add must appear without rebuilding the provider-visible frontend.
runtime.UpsertServer(entry, oldSpec, true)
listed, err := frontend.Execute(ctx, json.RawMessage(`{"action":"list"}`))
if err != nil || !strings.Contains(listed, `"name": "hot"`) {
t.Fatalf("hot-added list = %q, %v", listed, err)
}
call := json.RawMessage(`{"action":"call","capability_id":"mcp-tool:hot/search","arguments":{"q":"x"}}`)
if _, err := frontend.Execute(ctx, call); err != nil {
t.Fatalf("old endpoint call: %v", err)
}
if oldCalls.Load() != 1 {
t.Fatalf("old endpoint tool calls = %d, want 1", oldCalls.Load())
}
// Updating the same stable server identity clears old live metadata and the
// next disconnected call must use the replacement endpoint.
host.Remove("hot")
entry.URL = newServer.URL
newSpec := plugin.Spec{Name: "hot", Type: "http", URL: newServer.URL, Authorized: true}
runtime.UpsertServer(entry, newSpec, true)
if runtime.ConnectedProxyTools() != nil {
t.Fatal("endpoint update retained stale live tool snapshot")
}
if _, err := frontend.Execute(ctx, call); err != nil {
t.Fatalf("new endpoint call: %v", err)
}
if newCalls.Load() != 1 || oldCalls.Load() != 1 {
t.Fatalf("endpoint calls old=%d new=%d, want 1/1", oldCalls.Load(), newCalls.Load())
}
// Per-controller disable wins over a still-connected shared Host client.
// No reconnect or tools/call may occur, and live routing state is revoked.
if !host.HasClient("hot") {
t.Fatal("test requires shared Host client to remain connected")
}
if !runtime.SetServerEnabled("hot", false) {
t.Fatal("disable did not find hot server")
}
if runtime.ConnectedProxyTools() != nil {
t.Fatal("disable retained live proxy tools")
}
blocked, err := frontend.Execute(ctx, call)
if err != nil || !strings.Contains(blocked, "disabled") {
t.Fatalf("disabled shared-Host call = %q, %v, want fail-closed", blocked, err)
}
if newCalls.Load() != 1 {
t.Fatalf("disabled shared-Host server executed %d calls, want 1", newCalls.Load())
}
listed, err = frontend.Execute(ctx, json.RawMessage(`{"action":"list"}`))
if err != nil || !strings.Contains(listed, `"status": "disabled"`) || !strings.Contains(listed, `"connected": false`) {
t.Fatalf("disabled list = %q, %v", listed, err)
}
// Uninstall removes discovery and any possibility of a stale reconnect.
if !runtime.RemoveServer("hot") {
t.Fatal("remove did not find hot server")
}
listed, err = frontend.Execute(ctx, json.RawMessage(`{"action":"list"}`))
if err != nil || strings.Contains(listed, `"name": "hot"`) {
t.Fatalf("removed server leaked through list = %q, %v", listed, err)
}
}
func TestSharedHostSameNameRequiresCurrentRuntimeAuthorizationAndIdentity(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, tc := range []struct {
name string
authorized bool
want string
}{
{name: "unauthorized current identity", authorized: false, want: "not authorized"},
{name: "different authorized identity", authorized: true, want: "identity"},
} {
t.Run(tc.name, func(t *testing.T) {
var connectedCalls atomic.Int32
connectedServer := explicitReaderMCPServer(t, nil, &connectedCalls)
defer connectedServer.Close()
var currentRequests atomic.Int32
currentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
currentRequests.Add(1)
http.Error(w, "must not connect", http.StatusForbidden)
}))
defer currentServer.Close()
host := plugin.NewHost()
defer host.Close()
connectedSpec := plugin.Spec{Name: "shared", Type: "http", URL: connectedServer.URL, Authorized: true}
if _, err := host.Add(ctx, connectedSpec); err != nil {
t.Fatal(err)
}
currentSpec := plugin.Spec{Name: "shared", Type: "http", URL: currentServer.URL, Authorized: tc.authorized}
runtime := usecap.NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{currentSpec}, tool.NewRegistry(), nil)
frontend := runtime.NewFrontend(capability.NewLedger(), nil)
out, err := frontend.Execute(ctx, json.RawMessage(`{"action":"call","capability_id":"mcp-tool:shared/search","arguments":{"q":"x"}}`))
detail := strings.ToLower(out + " " + fmt.Sprint(err))
if !strings.Contains(detail, tc.want) {
t.Fatalf("same-name shared Host call = %q, %v, want %q", out, err, tc.want)
}
if connectedCalls.Load() != 0 || currentRequests.Load() != 0 {
t.Fatalf("identity mismatch reached network: connected tools/call=%d current requests=%d", connectedCalls.Load(), currentRequests.Load())
}
})
}
}
func TestResolvedMCPCallRechecksRuntimeDisableBeforeDispatch(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var toolCalls atomic.Int32
server := explicitReaderMCPServer(t, nil, &toolCalls)
defer server.Close()
spec := plugin.Spec{Name: "revoked", Type: "http", URL: server.URL, Authorized: true}
host := plugin.NewHost()
defer host.Close()
if _, err := host.Add(ctx, spec); err != nil {
t.Fatal(err)
}
runtime := usecap.NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), nil)
frontend := runtime.NewFrontend(capability.NewLedger(), nil)
resolved, err := frontend.ResolveCall(ctx, json.RawMessage(`{"action":"call","capability_id":"mcp-tool:revoked/search","arguments":{"q":"x"}}`))
if err != nil || resolved.Target == nil {
t.Fatalf("resolve = %+v, %v", resolved, err)
}
if !runtime.SetServerEnabled("revoked", false) {
t.Fatal("disable did not find resolved server")
}
if _, err := resolved.Target.Execute(ctx, resolved.Args); err == nil || !strings.Contains(strings.ToLower(err.Error()), "disabled") {
t.Fatalf("resolved target after disable error = %v", err)
}
if toolCalls.Load() != 0 {
t.Fatalf("resolved target executed tools/call %d times after disable", toolCalls.Load())
}
}
func TestRuntimeDisableLinearizesWithInFlightMCPDispatch(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
callStarted := make(chan struct{}, 1)
releaseCall := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseCall) }) }
t.Cleanup(release)
var toolCalls atomic.Int32
server := blockingReaderMCPServer(t, callStarted, releaseCall, &toolCalls)
defer server.Close()
spec := plugin.Spec{Name: "linear", Type: "http", URL: server.URL, Authorized: true}
host := plugin.NewHost()
defer host.Close()
if _, err := host.Add(ctx, spec); err != nil {
t.Fatal(err)
}
runtime := usecap.NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), nil)
frontend := runtime.NewFrontend(capability.NewLedger(), nil)
resolved, err := frontend.ResolveCall(ctx, json.RawMessage(`{"action":"call","capability_id":"mcp-tool:linear/search","arguments":{}}`))
if err != nil || resolved.Target == nil {
t.Fatalf("resolve = %+v, %v", resolved, err)
}
executeDone := make(chan error, 1)
go func() {
_, err := resolved.Target.Execute(ctx, resolved.Args)
executeDone <- err
}()
select {
case <-callStarted:
case <-ctx.Done():
t.Fatalf("MCP call never reached dispatch: %v", ctx.Err())
}
disableDone := make(chan bool, 1)
go func() { disableDone <- runtime.SetServerEnabled("linear", false) }()
select {
case <-disableDone:
t.Fatal("disable completed before the in-flight MCP dispatch crossed its linearization boundary")
case <-time.After(100 * time.Millisecond):
}
release()
if err := <-executeDone; err != nil {
t.Fatalf("in-flight MCP dispatch failed while disable waited: %v", err)
}
if ok := <-disableDone; !ok {
t.Fatal("disable did not find the configured server")
}
if _, err := resolved.Target.Execute(ctx, resolved.Args); err == nil || !strings.Contains(strings.ToLower(err.Error()), "disabled") {
t.Fatalf("post-disable resolved target error = %v", err)
}
if got := toolCalls.Load(); got != 1 {
t.Fatalf("tools/call count = %d, want only the in-flight dispatch", got)
}
}
func TestUnauthorizedNonProjectMCPZeroProcessStart(t *testing.T) {
// Spec.Authorized is the single truth: a host/session server with
// RequireLaunchApproval=false but Authorized=false must not start.
host := plugin.NewHost()
defer host.Close()
var started atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started.Add(1)
http.Error(w, "should not connect", http.StatusForbidden)
}))
defer srv.Close()
spec := plugin.Spec{
Name: "untrusted", Type: "http", URL: srv.URL,
// Explicitly unauthorized: boot/install must set Authorized=true for trust.
Authorized: false, RequireLaunchApproval: false,
}
proxy := usecap.NewUseCapabilityTool(context.Background(), host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
reg := tool.NewRegistry()
reg.Add(proxy)
a := New(nil, reg, sessionstore.NewSession("sys"), Options{}, event.Discard)
// Tool call path
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "u1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-tool:untrusted/search","arguments":{}}`,
})
if !out.blocked && out.errMsg == "" {
// May surface as error rather than blocked depending on resolve shape.
if !strings.Contains(out.output, "not authorized") && !strings.Contains(out.errMsg, "not authorized") {
t.Fatalf("unauthorized tool call outcome = %+v", out)
}
}
if host.HasClient("untrusted") || started.Load() != 0 {
t.Fatalf("unauthorized non-project MCP started process/network: connected=%v starts=%d", host.HasClient("untrusted"), started.Load())
}
// Lifecycle connect path
out2 := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
ID: "u2", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-server:untrusted"}`,
})
if host.HasClient("untrusted") || started.Load() != 0 {
t.Fatalf("unauthorized connect started process/network: outcome=%+v connected=%v starts=%d", out2, host.HasClient("untrusted"), started.Load())
}
if !out2.blocked && !strings.Contains(out2.output, "not authorized") && !strings.Contains(out2.errMsg, "not authorized") {
t.Fatalf("unauthorized connect should refuse, got %+v", out2)
}
}
func TestAuthorizedMCPConnectUsesExplicitDenyOnlyGate(t *testing.T) {
// dontAsk/ask policy must not block first connect of an authorized server;
// only ExplicitlyDenies should stop it.
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
var toolCalls atomic.Int32
server := explicitReaderMCPServer(t, nil, &toolCalls)
defer server.Close()
manager := mcplaunch.NewManager(filepath.Join(testenv.TempDir(t), mcplaunch.StateFilename), testenv.TempDir(t))
spec := plugin.Spec{
Name: "explicit-reader", Type: "http", URL: server.URL,
LaunchManager: manager, ConfigSource: "workspace_config",
Authorized: true,
}
cacheExplicitReaderSchema(t, spec)
host := plugin.NewHost()
defer host.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
proxy := usecap.NewUseCapabilityTool(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
reg := tool.NewRegistry()
reg.Add(proxy)
// Gate that would deny all ordinary checks (simulates dontAsk / ask without answer).
denyOrdinary := denyAllGate{}
a := New(nil, reg, sessionstore.NewSession("sys"), Options{Gate: denyOrdinary}, event.Discard)
out := a.executeOne(ctx, &a.turn, provider.ToolCall{
ID: "c1", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-server:explicit-reader"}`,
})
// denyAllGate does not implement ExplicitDenyGate — trusted MCP path skips Gate.Check.
if out.blocked || out.errMsg != "" {
t.Fatalf("authorized lifecycle connect must not use ordinary Gate.Check: %+v", out)
}
if !host.HasClient("explicit-reader") {
t.Fatal("authorized connect should start the server under deny-all ordinary gate")
}
// Explicit deny on mcp_connect__ must still block a fresh unauthorized name.
// Use a second server name with deny of its connect identity.
spec2 := plugin.Spec{
Name: "other-reader", Type: "http", URL: server.URL,
LaunchManager: manager, ConfigSource: "workspace_config",
Authorized: true,
}
cacheExplicitReaderSchema(t, spec2)
proxy2 := usecap.NewUseCapabilityTool(ctx, host, []plugin.Spec{spec2}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
reg2 := tool.NewRegistry()
reg2.Add(proxy2)
denyConnect := permission.NewGate(permission.New("ask", nil, nil, []string{plugin.MCPConnectPermissionName("other-reader")}), nil)
a2 := New(nil, reg2, sessionstore.NewSession("sys"), Options{Gate: denyConnect}, event.Discard)
out2 := a2.executeOne(ctx, &a2.turn, provider.ToolCall{
ID: "c2", Name: "use_capability",
Arguments: `{"action":"call","capability_id":"mcp-server:other-reader"}`,
})
if !out2.blocked || host.HasClient("other-reader") {
t.Fatalf("explicit connect deny must block: outcome=%+v connected=%v", out2, host.HasClient("other-reader"))
}
}
func TestReadOnlyExecutionAllowsEveryCatalogReadAction(t *testing.T) {
for _, action := range usecap.CatalogReadActions {
out := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{
ProxyAction: action, SkipExecute: true, ReadOnly: true, Result: "ranked",
})
if out.blocked || out.output != "ranked" {
t.Errorf("%s outcome = %+v, want the host result", action, out)
}
for name, rc := range map[string]tool.ResolvedCall{
"without SkipExecute": {ProxyAction: action, ReadOnly: true},
"with a Commit": {ProxyAction: action, SkipExecute: true, ReadOnly: true, Commit: func() error { return nil }},
} {
if out := executeReadOnlyBoundaryCall(t, rc); !out.blocked {
t.Errorf("%s resolved %s must stay blocked", action, name)
}
}
}
}
func TestReadOnlyExecutionNamesAllowedActionsForUnknownAction(t *testing.T) {
out := executeReadOnlyBoundaryCall(t, tool.ResolvedCall{ProxyAction: "frobnicate", SkipExecute: true, ReadOnly: true})
if !out.blocked {
t.Fatalf("unknown action outcome = %+v, want block", out)
}
for _, want := range append([]string{`"frobnicate"`}, usecap.CatalogReadActions...) {
if !strings.Contains(out.output, want) {
t.Errorf("block reason %q does not name %s", out.output, want)
}
}
}
func TestPlannerMCPExecutionAllowsCatalogSearch(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{ProxyAction: usecap.ActionSearch, SkipExecute: true, ReadOnly: true, Result: "ranked"}})
a := New(nil, reg, sessionstore.NewSession("sys"), Options{ReadOnlyExecution: true, PlannerMCPExecution: true}, event.Discard)
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{ID: "p-1", Name: "use_capability", Arguments: `{"action":"search","query":"x"}`})
if out.blocked || out.output != "ranked" {
t.Fatalf("planner search outcome = %+v", out)
}
}