package console import ( "bytes" "encoding/json" "io" "net/http" "strings" "testing" "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" "github.com/novox/mesh-tools/node-tools/internal/runtime" ) func connect(t *testing.T, module, node string) *bus.Conn { t.Helper() c, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: module, Node: node}) if err != nil { t.Fatal(err) } c.Logf = func(string, ...any) {} t.Cleanup(c.Close) return c } func post(t *testing.T, endpoint string, body string) map[string]any { t.Helper() res, err := http.Post(endpoint, "application/json", bytes.NewBufferString(body)) if err != nil { t.Fatal(err) } defer res.Body.Close() raw, _ := io.ReadAll(res.Body) var out map[string]any if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("%d %s", res.StatusCode, raw) } return out } // As node-tools, the runtime serves the bundles and is the console on loopback: the listing is what // the modules and the mesh's records answered, and a call reaches the module on the machine named. func TestTheConsoleListsAndCallsOverHTTP(t *testing.T) { mesh := mt.New(t) mesh.Issue(t, mt.MembershipOf("alpha", "desk", true, nil)) mesh.Issue(t, mt.MembershipOf("beta", "desk", false, map[string][]string{"node-shelf": {"list", "clear"}})) nodeTools := connect(t, "node-tools", "desk") stop, err := runtime.Run(nodeTools, []runtime.Served{ {Module: "alpha", Entrypoints: []string{mt.Fixture("many-alpha.serve.mjs")}}, {Module: "beta", Entrypoints: []string{mt.Fixture("many-beta.serve.mjs")}}, }, nil, (&mt.Logs{}).Logf) if err != nil { t.Fatal(err) } defer stop() catalogue := connect(t, "mesh-catalog", "") stopCat, _ := catalogue.Handle("catalog_modules", func(json.RawMessage) (any, error) { return map[string]any{"modules": []map[string]string{{"module": "alpha"}, {"module": "beta"}, {"module": "ghost"}}}, nil }) defer stopCat() controller := connect(t, "mesh-controller", "") stopSeat, _ := controller.HandleSubject("mesh.seat.mesh-controller.tool.tools", func(json.RawMessage) (any, error) { return map[string]any{"seats": []map[string]any{{"seat": "node-shelf", "scope": "node", "tools": []map[string]any{ {"name": "list", "description": "what is on the shelf", "input": map[string]any{}}, {"name": "clear", "description": "take it all off", "input": map[string]any{}}, }}}}, nil }) defer stopSeat() catalogue.Flush() controller.Flush() up, err := Serve(NewSurface(nodeTools, "desk.node-tools"), "127.0.0.1:0") if err != nil { t.Fatal(err) } defer up.Close() endpoint := "http://" + up.Address + "/mcp" init := post(t, endpoint, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{}}}`) if !strings.Contains(init["result"].(map[string]any)["instructions"].(string), "reached as desk.node-tools") { t.Errorf("initialize: %v", init) } listed := post(t, endpoint, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)["result"].(map[string]any) var names []string for _, x := range listed["tools"].([]any) { names = append(names, x.(map[string]any)["name"].(string)) } if got := strings.Join(names, ","); got != "alpha.one,alpha.two,beta.five,beta.four,beta.three,node-shelf.clear,node-shelf.list" { t.Errorf("listed %s", got) } if got := listed["_meta"].(map[string]any)["notAnswering"]; len(got.([]any)) != 1 || got.([]any)[0] != "ghost" { t.Errorf("not answering: %v", got) } for _, x := range listed["tools"].([]any) { tool := x.(map[string]any) if tool["name"] == "node-shelf.list" { schema := tool["inputSchema"].(map[string]any) if req, _ := schema["required"].([]any); len(req) != 1 || req[0] != "node" { t.Errorf("a node seat's verb does not require node: %v", schema) } } } called := post(t, endpoint, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"alpha.one","arguments":{"node":"desk"}}}`) content := called["result"].(map[string]any)["content"].([]any) var got map[string]any _ = json.Unmarshal([]byte(content[0].(map[string]any)["text"].(string)), &got) if got["alpha"] != float64(1) || content[1].(map[string]any)["text"] != "answered by desk" { t.Errorf("called: %v", called) } seat := post(t, endpoint, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"node-shelf.list","arguments":{"node":"desk"}}}`) if text := seat["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string); !strings.Contains(text, `"a"`) { t.Errorf("seat verb: %v", seat) } refused := post(t, endpoint, `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"node-shelf.list","arguments":{}}}`) if refused["error"] == nil { t.Errorf("a node seat's verb was called without its machine: %v", refused) } absent := post(t, endpoint, `{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ghost.boo","arguments":{}}}`) result := absent["result"].(map[string]any) if result["isError"] != true || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), "nothing serves ghost.boo") { t.Errorf("an absent tool: %v", absent) } res, err := http.Post(endpoint, "application/json", strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)) if err != nil || res.StatusCode != 202 { t.Errorf("a notification: %v %v", res, err) } } func TestTheConsoleListensOnLoopbackAndNowhereElse(t *testing.T) { if _, err := Serve(NewSurface(nil, "x"), "0.0.0.0:0"); err == nil || !strings.Contains(err.Error(), "loopback and nowhere else") { t.Errorf("a non-loopback console was not refused: %v", err) } }