返回 DeepSeek-Reasonix
mcp_host_session_enabled_test.go
根目录 / internal / boot / mcp_host_session_enabled_test.go
1 package boot
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "sync/atomic"
11 "testing"
12
13 "reasonix/internal/agent/testutil"
14 "reasonix/internal/config"
15 "reasonix/internal/event"
16 "reasonix/internal/plugin"
17 "reasonix/internal/provider"
18 )
19
20 // mcpHostSessionStub is a minimal Streamable-HTTP MCP server: enough to complete
21 // initialize + tools/list so the spec reaches pluginHost exactly as a real
22 // host-session server would.
23 func mcpHostSessionStub(t *testing.T, name string, calls *atomic.Int32) *httptest.Server {
24 t.Helper()
25 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26 var req struct {
27 ID *json.RawMessage `json:"id"`
28 Method string `json:"method"`
29 }
30 _ = json.NewDecoder(r.Body).Decode(&req)
31 if req.ID == nil {
32 w.WriteHeader(http.StatusAccepted)
33 return
34 }
35 var result any
36 switch req.Method {
37 case "initialize":
38 result = map[string]any{
39 "protocolVersion": "2024-11-05",
40 "serverInfo": map[string]any{"name": name, "version": "1"},
41 "capabilities": map[string]any{"tools": map[string]any{}},
42 }
43 case "tools/list":
44 result = map[string]any{"tools": []map[string]any{{
45 "name": "ping",
46 "description": "probe tool",
47 "inputSchema": map[string]any{"type": "object"},
48 }}}
49 case "tools/call":
50 if calls != nil {
51 calls.Add(1)
52 }
53 result = map[string]any{"content": []map[string]any{{
54 "type": "text",
55 "text": "pong:" + name,
56 }}}
57 default:
58 http.Error(w, "unsupported method", http.StatusBadRequest)
59 return
60 }
61 w.Header().Set("Content-Type", "application/json")
62 _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result})
63 }))
64 }
65
66 const mcpCapabilityTestProviderConfig = `
67 default_model = "test-model"
68
69 [agent]
70 system_prompt = "BASE"
71
72 [[providers]]
73 name = "test-model"
74 kind = "boot-token-profile-test"
75 model = "x"
76 `
77
78 func runUseCapabilityCalls(t *testing.T, opts Options, capabilityIDs ...string) string {
79 t.Helper()
80 registerBootTokenProfileTestProvider()
81 turns := make([]testutil.Turn, 0, len(capabilityIDs)+1)
82 for i, id := range capabilityIDs {
83 args, err := json.Marshal(map[string]any{
84 "action": "call",
85 "capability_id": id,
86 "arguments": map[string]any{},
87 })
88 if err != nil {
89 t.Fatalf("marshal use_capability call: %v", err)
90 }
91 turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
92 ID: fmt.Sprintf("mcp-%d", i),
93 Name: "use_capability",
94 Arguments: string(args),
95 }}})
96 }
97 turns = append(turns, testutil.Turn{Text: "done"})
98 prov := testutil.NewMock("mcp-host-session", turns...)
99 setBootTokenProfileTestProvider(t, prov)
100
101 ctrl, err := Build(context.Background(), opts)
102 if err != nil {
103 t.Fatalf("Build: %v", err)
104 }
105 defer ctrl.Close()
106 if err := ctrl.Run(context.Background(), "call the requested MCP capability"); err != nil {
107 t.Fatalf("Run: %v", err)
108 }
109 for _, req := range prov.Requests() {
110 for _, name := range toolSchemaNames(req.Tools) {
111 if strings.HasPrefix(name, "mcp__") {
112 t.Fatalf("dynamic MCP tool leaked into provider-visible schemas: %v", toolSchemaNames(req.Tools))
113 }
114 }
115 }
116
117 var out strings.Builder
118 for _, msg := range ctrl.History() {
119 if msg.Role == provider.RoleTool {
120 out.WriteString(msg.Content)
121 }
122 }
123 return out.String()
124 }
125
126 // A host-session MCP server (ACP session/new mcpServers) arrives as
127 // Options.ExtraPlugins and reaches pluginHost through eagerSpecs, so it connects
128 // and its tools show up in the capability catalog as ready. The capability
129 // runtime is a separate registry seeded only from cfg.Plugins, so before this
130 // fix every mcp-tool:<server>/<tool> dispatch was refused — a failure invisible
131 // to a happy-path turn and only reachable once the model actually called the
132 // tool mid-turn.
133 func TestBuildEnablesHostSessionMCPForCapabilityDispatch(t *testing.T) {
134 isolateConfigHome(t)
135 dir := robustTempDir(t)
136 t.Chdir(dir)
137 writeFile(t, dir, "reasonix.toml", mcpCapabilityTestProviderConfig)
138 approveWorkspace(t, dir)
139
140 var calls atomic.Int32
141 srv := mcpHostSessionStub(t, "acp-extra", &calls)
142 defer srv.Close()
143
144 out := runUseCapabilityCalls(t, Options{
145 Sink: event.Discard,
146 ExtraPlugins: []plugin.Spec{{
147 Name: "acp-extra",
148 Type: "http",
149 URL: srv.URL,
150 Authorized: true,
151 }},
152 }, "mcp-tool:acp-extra/ping")
153 if calls.Load() != 1 || !strings.Contains(out, "pong:acp-extra") {
154 t.Fatalf("host-session dispatch calls=%d output=%q, want one successful tools/call", calls.Load(), out)
155 }
156 }
157
158 // A config server the user explicitly turned off must stay off, even while an
159 // unrelated host-session server is being enabled in the same boot. This is the
160 // invariant the enablement loop could plausibly break in a future refactor —
161 // widen it from "every extraSpec name" to "every name" and only this test
162 // notices.
163 func TestBuildLeavesDisabledConfigMCPUndispatchableAlongsideHostSession(t *testing.T) {
164 isolateConfigHome(t)
165 dir := robustTempDir(t)
166 t.Chdir(dir)
167
168 var cfgCalls atomic.Int32
169 cfgSrv := mcpHostSessionStub(t, "config-off", &cfgCalls)
170 defer cfgSrv.Close()
171 var extraCalls atomic.Int32
172 extraSrv := mcpHostSessionStub(t, "acp-extra", &extraCalls)
173 defer extraSrv.Close()
174
175 writeFile(t, dir, "reasonix.toml", mcpCapabilityTestProviderConfig+`
176 [[plugins]]
177 name = "config-off"
178 type = "http"
179 url = "`+cfgSrv.URL+`"
180 auto_start = false
181 `)
182 approveWorkspace(t, dir)
183
184 out := runUseCapabilityCalls(t, Options{
185 Sink: event.Discard,
186 ExtraPlugins: []plugin.Spec{{
187 Name: "acp-extra",
188 Type: "http",
189 URL: extraSrv.URL,
190 Authorized: true,
191 }},
192 }, "mcp-tool:acp-extra/ping", "mcp-tool:config-off/ping")
193 if extraCalls.Load() != 1 || !strings.Contains(out, "pong:acp-extra") {
194 t.Errorf("host-session dispatch calls=%d output=%q, want success", extraCalls.Load(), out)
195 }
196 if cfgCalls.Load() != 0 || !strings.Contains(out, `MCP server "config-off" is disabled in this session`) {
197 t.Errorf("disabled config dispatch calls=%d output=%q, want refusal without tools/call", cfgCalls.Load(), out)
198 }
199 }
200
201 // A host-session server takes precedence over a same-named config entry,
202 // including a disabled one: the client asked for that endpoint in this session,
203 // and it is the spec pluginHost connected and registered tools for.
204 //
205 // Asserted on the merge directly rather than through a booted Controller,
206 // because what must hold is which spec lands in the runtime registry — and the
207 // only way to observe that from outside would be to add a production accessor
208 // that exists solely for this test.
209 func TestMergeHostSessionCapabilitySpecsPrefersHostSessionOnNameCollision(t *testing.T) {
210 autoStartOff := false
211 configEntries := []config.PluginEntry{
212 {Name: "shared-name", Type: "http", URL: "http://config.invalid", AutoStart: &autoStartOff},
213 {Name: "config-only", Type: "http", URL: "http://config-only.invalid"},
214 }
215 configSpecs := []plugin.Spec{
216 {Name: "shared-name", Type: "http", URL: "http://config.invalid"},
217 {Name: "config-only", Type: "http", URL: "http://config-only.invalid"},
218 }
219 hostSession := []plugin.Spec{
220 {Name: "shared-name", Type: "http", URL: "http://host-session.invalid"},
221 }
222
223 entries, specs := mergeHostSessionCapabilitySpecs(configEntries, configSpecs, hostSession)
224
225 byName := map[string]plugin.Spec{}
226 for _, spec := range specs {
227 if _, dup := byName[spec.Name]; dup {
228 t.Fatalf("%q appears twice; ConfigureServers would resolve it by slice order", spec.Name)
229 }
230 byName[spec.Name] = spec
231 }
232 if got := byName["shared-name"].URL; got != "http://host-session.invalid" {
233 t.Errorf("collision resolved to %q, want the host-session endpoint", got)
234 }
235 if got := byName["config-only"].URL; got != "http://config-only.invalid" {
236 t.Errorf("non-colliding config server = %q, want it untouched", got)
237 }
238 // The shadowed entry must go too, or ConfigureServers pairs auto_start=false
239 // with the live host-session spec and shows it as neither auto-start nor
240 // disabled.
241 for _, entry := range entries {
242 if entry.Name == "shared-name" {
243 t.Error("shadowed config entry survived; it would be paired with the host-session spec")
244 }
245 }
246 if len(entries) != 1 || entries[0].Name != "config-only" {
247 t.Errorf("entries = %+v, want only config-only", entries)
248 }
249 }
250
251 // With no host-session servers the inventory must be handed through untouched —
252 // the merge is not allowed to reorder or reallocate the ordinary config path.
253 func TestMergeHostSessionCapabilitySpecsIsIdentityWithoutHostSession(t *testing.T) {
254 entriesIn := []config.PluginEntry{{Name: "a"}, {Name: "b"}}
255 specsIn := []plugin.Spec{{Name: "a"}, {Name: "b"}}
256
257 entries, specs := mergeHostSessionCapabilitySpecs(entriesIn, specsIn, nil)
258
259 if len(entries) != len(entriesIn) || len(specs) != len(specsIn) {
260 t.Fatalf("lengths changed: entries %d->%d, specs %d->%d",
261 len(entriesIn), len(entries), len(specsIn), len(specs))
262 }
263 for i := range specs {
264 if specs[i].Name != specsIn[i].Name || entries[i].Name != entriesIn[i].Name {
265 t.Fatalf("order changed at %d", i)
266 }
267 }
268 }
269
270 // A name nobody supplied must stay undispatchable, so the fix above cannot be
271 // satisfied by defaulting unknown servers to enabled.
272 func TestBuildLeavesUnknownMCPServerUndispatchable(t *testing.T) {
273 isolateConfigHome(t)
274 dir := robustTempDir(t)
275 t.Chdir(dir)
276 writeFile(t, dir, "reasonix.toml", mcpCapabilityTestProviderConfig)
277 approveWorkspace(t, dir)
278
279 out := runUseCapabilityCalls(t, Options{Sink: event.Discard}, "mcp-tool:never-configured/ping")
280 if !strings.Contains(out, `MCP server "never-configured" is not registered in this session`) {
281 t.Fatalf("unknown server output=%q, want an unregistered refusal", out)
282 }
283 }
284
284 lines GO