| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/capability" |
| 13 | "reasonix/internal/plugin" |
| 14 | "reasonix/internal/tool" |
| 15 | ) |
| 16 | |
| 17 | func twoToolMCPServer(t *testing.T) *httptest.Server { |
| 18 | t.Helper() |
| 19 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 20 | var request struct { |
| 21 | ID *int `json:"id"` |
| 22 | Method string `json:"method"` |
| 23 | } |
| 24 | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { |
| 25 | http.Error(w, "bad request", http.StatusBadRequest) |
| 26 | return |
| 27 | } |
| 28 | if request.ID == nil { |
| 29 | w.WriteHeader(http.StatusAccepted) |
| 30 | return |
| 31 | } |
| 32 | var result any |
| 33 | switch request.Method { |
| 34 | case "initialize": |
| 35 | result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "bfexplorer", "version": "1"}} |
| 36 | case "tools/list": |
| 37 | result = map[string]any{"tools": []map[string]any{ |
| 38 | { |
| 39 | "name": "get_data", "description": "get data", |
| 40 | "inputSchema": map[string]any{"type": "object"}, |
| 41 | }, |
| 42 | { |
| 43 | "name": "execute_console_script", "description": "execute a console script", |
| 44 | "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"filePathName": map[string]any{"type": "string"}}}, |
| 45 | }, |
| 46 | }} |
| 47 | case "tools/call": |
| 48 | result = map[string]any{"content": []map[string]any{{"type": "text", "text": "ok"}}} |
| 49 | } |
| 50 | w.Header().Set("Content-Type", "application/json") |
| 51 | _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result}) |
| 52 | })) |
| 53 | } |
| 54 | |
| 55 | // TestCatalogSurfacesLiveServerToolMissingFromRegistrySnapshot reproduces |
| 56 | // issue #9516: the session registry holds a stale subset of an MCP server's |
| 57 | // tools, the live server exposes one more, and the capability catalog must |
| 58 | // still surface the live tool so inspect on its mcp-tool id succeeds instead |
| 59 | // of returning "unknown capability_id". |
| 60 | func TestCatalogSurfacesLiveServerToolMissingFromRegistrySnapshot(t *testing.T) { |
| 61 | t.Setenv("REASONIX_CACHE_HOME", t.TempDir()) |
| 62 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| 63 | defer cancel() |
| 64 | |
| 65 | server := twoToolMCPServer(t) |
| 66 | defer server.Close() |
| 67 | |
| 68 | spec := plugin.Spec{Name: "bfexplorer", Type: "http", URL: server.URL, Authorized: true} |
| 69 | host := plugin.NewHost() |
| 70 | defer host.Close() |
| 71 | |
| 72 | // Stale registry snapshot: only get_data was known when it was registered. |
| 73 | reg := tool.NewRegistry() |
| 74 | reg.Add(fakeTool{name: plugin.ModelToolName("bfexplorer", "get_data"), readOnly: true}) |
| 75 | |
| 76 | // Catalog assembly mirrors boot.Build: registry contract entries plus the |
| 77 | // runtime's configured/cached/live MCP state. |
| 78 | var runtime *MCPCapabilityRuntime |
| 79 | catalogFn := func() capability.Catalog { |
| 80 | conn := map[string]bool{} |
| 81 | for _, name := range host.ServerNames() { |
| 82 | conn[name] = true |
| 83 | } |
| 84 | opts := capability.CatalogOptions{Tools: reg.AllContractEntries(), Connected: conn} |
| 85 | if runtime != nil { |
| 86 | opts.Plugins, opts.CachedTools, opts.CacheKeyOK, opts.Disabled, opts.ProxyTools = runtime.CapabilityCatalogState() |
| 87 | } |
| 88 | return capability.BuildCatalog(opts) |
| 89 | } |
| 90 | runtime = NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, reg, catalogFn) |
| 91 | |
| 92 | // The server connects after the runtime exists and never sends |
| 93 | // notifications/tools/list_changed, so only the live tools/list — not the |
| 94 | // registry — knows about execute_console_script. |
| 95 | if _, err := host.Add(ctx, spec); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | |
| 99 | frontend := runtime.NewFrontend(capability.NewLedger(), nil) |
| 100 | listing, err := frontend.Execute(ctx, json.RawMessage(`{"action":"call","capability_id":"mcp-server:bfexplorer"}`)) |
| 101 | if err != nil { |
| 102 | t.Fatalf("connect-and-list on the ready server: %v", err) |
| 103 | } |
| 104 | if !strings.Contains(listing, "execute_console_script") { |
| 105 | t.Fatalf("live server directory must list execute_console_script:\n%s", listing) |
| 106 | } |
| 107 | |
| 108 | out, err := frontend.Execute(ctx, json.RawMessage(`{"action":"inspect","capability_id":"mcp-tool:bfexplorer/execute_console_script"}`)) |
| 109 | if err != nil { |
| 110 | t.Fatalf("inspect of a tool the live connected server exposes must succeed, got: %v", err) |
| 111 | } |
| 112 | if !strings.Contains(out, "mcp-tool:bfexplorer/execute_console_script") { |
| 113 | t.Fatalf("inspect payload lost the tool identity:\n%s", out) |
| 114 | } |
| 115 | } |
| 116 |