返回 DeepSeek-Reasonix
proxy_e2e_test.go
根目录 / internal / plugin / proxy_e2e_test.go
1 package plugin_test
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/capability"
13 "reasonix/internal/plugin"
14 "reasonix/internal/tool"
15 )
16
17 // mockSpec mirrors helperSpec (lazy_test.go): the test binary re-runs itself
18 // as a stdio MCP server via TestHelperProcess.
19 func mockSpec() plugin.Spec {
20 return plugin.Spec{
21 Name: "mock",
22 Command: os.Args[0],
23 Args: []string{"-test.run=TestHelperProcess", "--"},
24 Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
25 Authorized: true, // install/boot sets this; proxy never invents trust
26 }
27 }
28
29 // TestUseCapabilityFirstDiscoveryConnectListCall is the reviewer-demanded end
30 // to end: no schema cache, action=call on the server id resolves to a gated
31 // connect target (no process before approval), Execute connects and lists the
32 // tools, and a follow-up mcp-tool call executes against the live server.
33 func TestUseCapabilityFirstDiscoveryConnectListCall(t *testing.T) {
34 host := plugin.NewHost()
35 defer host.Close()
36 lifeCtx, cancel := context.WithCancel(context.Background())
37 defer cancel()
38 ledger := capability.NewLedger()
39 audit := &capability.Audit{}
40 proxy := agent.NewUseCapabilityTool(lifeCtx, host, []plugin.Spec{mockSpec()}, tool.NewRegistry(), ledger, audit, nil)
41
42 resolved, err := proxy.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-server:mock"}`))
43 if err != nil {
44 t.Fatalf("resolve server call: %v", err)
45 }
46 if resolved.SkipExecute || resolved.Target == nil {
47 t.Fatalf("expected a deferred connect target, got %+v", resolved)
48 }
49 if resolved.TargetName != plugin.MCPConnectPermissionName("mock") {
50 t.Fatalf("connect permission name = %q, want %q", resolved.TargetName, plugin.MCPConnectPermissionName("mock"))
51 }
52 if resolved.ReadOnly {
53 t.Fatal("connect must not claim read-only: it spawns a subprocess")
54 }
55 if host.HasClient("mock") {
56 t.Fatal("resolution must not start the server")
57 }
58
59 listing, err := resolved.Target.Execute(context.Background(), resolved.Args)
60 if err != nil {
61 t.Fatalf("connect execute: %v", err)
62 }
63 if !strings.Contains(listing, "mcp-tool:mock/echo") {
64 t.Fatalf("listing missing echo tool id:\n%s", listing)
65 }
66 if !host.HasClient("mock") {
67 t.Fatal("server should be connected after approved execute")
68 }
69 if got := proxy.ConnectedProxyTools(); len(got["mock"]) == 0 {
70 t.Fatalf("proxy snapshot empty after connect: %v", got)
71 }
72
73 // A repeated server-level call now lists side-effect-free.
74 again, err := proxy.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-server:mock"}`))
75 if err != nil {
76 t.Fatal(err)
77 }
78 if !again.SkipExecute || !strings.Contains(again.Result, "mcp-tool:mock/echo") {
79 t.Fatalf("connected server call should list immediately, got %+v", again)
80 }
81 if _, err := proxy.Execute(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-server:mock"}`)); err != nil {
82 t.Fatalf("connected server call execute: %v", err)
83 }
84 if entry, ok := ledger.Get("mcp-server:mock"); !ok || entry.Outcome != capability.OutcomeSucceeded {
85 t.Fatalf("connected server call ledger = %+v, found=%v", entry, ok)
86 }
87 if snap := audit.Snapshot(); snap.MCPCall != 1 || snap.MCPCallFailures != 0 {
88 t.Fatalf("connected server call audit = %d/%d, want 1/0", snap.MCPCall, snap.MCPCallFailures)
89 }
90
91 // Then the discovered tool is callable end to end.
92 call, err := proxy.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:mock/echo","arguments":{"msg":"hi"}}`))
93 if err != nil {
94 t.Fatal(err)
95 }
96 callCtx, cancelCall := context.WithTimeout(context.Background(), 5*time.Second)
97 defer cancelCall()
98 out, err := call.Target.Execute(callCtx, call.Args)
99 if err != nil {
100 t.Fatalf("tool execute: %v", err)
101 }
102 if out != "echo: hi" {
103 t.Fatalf("echo result = %q", out)
104 }
105 }
106
107 // TestUseCapabilitySharedHostSnapshot covers the cross-tab gap: a server
108 // connected by another controller on the shared host must still populate this
109 // proxy's snapshot (and thus the catalog) through resolve and inspect.
110 func TestUseCapabilitySharedHostSnapshot(t *testing.T) {
111 host := plugin.NewHost()
112 defer host.Close()
113 lifeCtx, cancel := context.WithCancel(context.Background())
114 defer cancel()
115 callCtx, cancelCall := context.WithTimeout(context.Background(), 5*time.Second)
116 defer cancelCall()
117 // "Tab A" connects directly (not through any proxy).
118 if _, err := host.AddWithLifecycle(lifeCtx, callCtx, mockSpec()); err != nil {
119 t.Fatalf("tab A connect: %v", err)
120 }
121
122 // "Tab B": empty registry (auto_start=false semantics), fresh proxy.
123 proxyB := agent.NewUseCapabilityTool(lifeCtx, host, []plugin.Spec{mockSpec()}, tool.NewRegistry(), capability.NewLedger(), nil, nil)
124 resolved, err := proxyB.ResolveCall(context.Background(), json.RawMessage(`{"action":"call","capability_id":"mcp-tool:mock/echo","arguments":{}}`))
125 if err != nil {
126 t.Fatal(err)
127 }
128 if resolved.Target == nil {
129 t.Fatalf("expected live target via shared host, got %+v", resolved)
130 }
131 if got := proxyB.ConnectedProxyTools(); len(got["mock"]) == 0 {
132 t.Fatalf("resolve on a shared-host server must build the snapshot, got %v", got)
133 }
134
135 // A third proxy sees it via inspect too.
136 proxyC := agent.NewUseCapabilityTool(lifeCtx, host, []plugin.Spec{mockSpec()}, tool.NewRegistry(), capability.NewLedger(), nil, func() capability.Catalog {
137 return capability.Catalog{Entries: []capability.Entry{{
138 ID: "mcp-server:mock", Kind: capability.KindMCPServer, Name: "mock", Source: "mock", Status: capability.StatusReady,
139 }}}
140 })
141 if _, err := proxyC.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"mcp-server:mock"}`)); err != nil {
142 t.Fatal(err)
143 }
144 if got := proxyC.ConnectedProxyTools(); len(got["mock"]) == 0 {
145 t.Fatalf("inspect on a shared-host server must build the snapshot, got %v", got)
146 }
147 }
148
148 lines GO