diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go new file mode 100644 index 0000000..3909b59 --- /dev/null +++ b/node-tools/internal/console/address.go @@ -0,0 +1,735 @@ +package console + +// The mesh's tools found by address, not announced whole (novox/hq ADR 0195, to-be 34 §3a). +// +// The console announces five tools. Everything the mesh answers is reached through them by one +// address per layer: +// +// . a seat held once for the mesh — its holder answers +// /. a seat held once per machine — that machine's holder answers +// /. a module assigned to a machine — that assignment answers +// . also, for a module whose instances are interchangeable (ADR 0160) +// +// A module that is not interchangeable is called with its machine or refused, naming the machines +// it runs on: "whichever answers" is no answer for state a machine holds. +// +// Every discovery verb asks the mesh when it is called — kept a few seconds at most, never for a +// session — so a tool that arrived a minute ago is found without the client reconnecting. + +import ( + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/novox/mesh-tools/node-tools/internal/bus" +) + +// IndexKept is how long what the mesh answered is kept before it is asked again: long enough that +// one agent turn's search, describe and call ask once, short enough that nothing goes stale. +var IndexKept = 5 * time.Second + +// The five tools the console announces. Names of the API's kind — letters, digits, `_`, `-`. +const ( + verbOverview = "mesh_overview" + verbMachine = "mesh_machine" + verbSearch = "mesh_search" + verbDescribe = "mesh_describe" + verbCall = "mesh_call" +) + +// searchCap is how many matches a search answers before it says how many more there were. +const searchCap = 25 + +const grammar = "Addresses: `.` for a seat held once for the mesh (e.g. `mesh-controller.nodes`); " + + "`/.` for a seat every machine holds (e.g. `ace/node-packet-filter.rules`); " + + "`/.` for a module on one machine (e.g. `novox/postgres.postgres_list_databases`); " + + "and `.` also for a module whose instances are interchangeable." + +// discovery is the five tools as tools/list announces them. +func discovery() []map[string]any { + str := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } + obj := func(props map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + s["required"] = required + } + return s + } + return []map[string]any{ + {"name": verbOverview, "inputSchema": obj(map[string]any{}), + "description": "The mesh at a glance: the seats it holds once for the whole mesh with their verbs, the seats " + + "every machine holds, and its machines. Start here, then `mesh_machine` for one machine. " + grammar}, + {"name": verbMachine, "inputSchema": obj(map[string]any{"node": str("the machine, as mesh_overview names it")}, "node"), + "description": "One machine: the seats it holds with their verbs, and the modules assigned to it with their tools — " + + "each with the address to describe or call it by. " + grammar}, + {"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases`")}, "query"), + "description": "Find tools anywhere in the mesh by words: every match's address and a line of what it does, across the " + + "mesh's seats, the machines' seats and every module on every machine. " + grammar}, + {"name": verbDescribe, "inputSchema": obj(map[string]any{"address": str("the tool's address")}, "address"), + "description": "What one tool does and the arguments it takes, as a JSON schema. The machine is in the address, " + + "never an argument. " + grammar}, + {"name": verbCall, "inputSchema": obj(map[string]any{ + "address": str("the tool's address"), + "arguments": map[string]any{"type": "object", "description": "the tool's arguments, as mesh_describe gives its schema"}, + }, "address"), + "description": "Call one tool by its address with its arguments; the answer says which machine gave it. " + grammar}, + } +} + +func isDiscovery(name string) bool { + switch name { + case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall: + return true + } + return false +} + +// seatInfo is a seat as the mesh's records define it, and who holds it where. +type seatInfo struct { + Seat string + Scope string // "mesh" or "node" + Verbs []Tool + Holders []holder +} + +type holder struct { + Module string `json:"module"` + Node string `json:"node"` +} + +// moduleInfo is a module that answers tools: where it runs, whether any instance will do, and its +// tools as one of its instances described them. +type moduleInfo struct { + Module string + On []string + Interchangeable bool + Tools []Tool +} + +// index is what the mesh answered about itself, at one moment. +type index struct { + Seats []seatInfo + Machines []string + Modules map[string]*moduleInfo + NotAnswering []string + Listing *Listing // the flat catalogue the call path resolves subjects with +} + +func (x *index) seat(name string) *seatInfo { + for i := range x.Seats { + if x.Seats[i].Seat == name { + return &x.Seats[i] + } + } + return nil +} + +func findTool(tools []Tool, name string) *Tool { + for i := range tools { + if tools[i].Name == name { + return &tools[i] + } + } + return nil +} + +// controllerOutput is a controller seat verb's answer: the command's printed output. +func controllerOutput(conn *bus.Conn, verb string) (string, error) { + got, err := conn.Ask("seat:mesh-controller."+verb, map[string]any{}, "") + if err != nil { + return "", err + } + var r struct { + Output string `json:"output"` + OK *bool `json:"ok"` + } + if json.Unmarshal(got.Result, &r) != nil { + return "", fmt.Errorf("mesh-controller.%s answered something that is not its output", verb) + } + if r.OK != nil && !*r.OK { + return "", fmt.Errorf("mesh-controller.%s: %s", verb, strings.TrimSpace(r.Output)) + } + return r.Output, nil +} + +// jsonIn is the JSON document a command printed, after any lines it said first: a seat verb runs +// the controller's command, and a command may warn before it answers. +func jsonIn(output string) string { + if strings.HasPrefix(strings.TrimSpace(output), "{") { + return strings.TrimSpace(output) + } + if i := strings.Index(output, "\n{"); i >= 0 { + return strings.TrimSpace(output[i+1:]) + } + return "" +} + +// A machine as `node list` prints it: its name, when it was last heard from, its mode — converged +// or adopted, the only two the command prints — and its id. Lines the command says around them +// (a warning about the bus's users, "no node records yet") are not machines and are skipped. +var nodeLine = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\s+.*\s(converged|adopted)\s+\S+\s*$`) + +// machinesIn reads the machines from `node list`'s output. +func machinesIn(output string) []string { + var out []string + for _, line := range strings.Split(output, "\n") { + if m := nodeLine.FindStringSubmatch(line); m != nil { + out = append(out, m[1]) + } + } + sort.Strings(out) + return out +} + +// A module as `module list` prints it: name, version, how it was built, and where it runs. +var moduleLine = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\s+\S+\s+.*?\s+on (.+)$`) + +// assignmentsIn reads, from `module list`'s output, the machines each module runs on. +func assignmentsIn(output string) map[string][]string { + out := map[string][]string{} + for _, line := range strings.Split(output, "\n") { + if line == "" || line[0] == ' ' || line[0] == '\t' { + continue + } + m := moduleLine.FindStringSubmatch(strings.TrimRight(line, " ")) + if m == nil { + continue + } + on := strings.TrimSpace(m[2]) + if on == "nothing" { + out[m[1]] = []string{} + continue + } + var nodes []string + for _, n := range strings.Split(on, ",") { + if n = strings.TrimSpace(n); n != "" { + nodes = append(nodes, n) + } + } + sort.Strings(nodes) + out[m[1]] = nodes + } + return out +} + +// interchangeable is whether the mesh issued the module a plain subject for this tool — one any of +// its instances answers (ADR 0160): the module's own subject with no machine after it. +func interchangeable(module string, t Tool) bool { + plain := "mesh.mod." + module + ".tool." + t.Name + for _, s := range t.Subjects { + if s == plain { + return true + } + } + return false +} + +// indexOn asks the mesh what it holds: the flat listing (catalogue, modules, the seats' tools), +// and from the controller the seats' holders, the machines and the assignments — at once. +func indexOn(conn *bus.Conn) (*index, error) { + var wg sync.WaitGroup + var seatsOut, nodesOut, modulesOut string + wg.Add(3) + go func() { defer wg.Done(); seatsOut, _ = controllerOutput(conn, "seats") }() + go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }() + go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }() + l, err := toolsOn(conn) + wg.Wait() + if err != nil { + return nil, err + } + + x := &index{Modules: map[string]*moduleInfo{}, NotAnswering: l.NotAnswering, Listing: l} + + // The seats: their verbs from the mesh's records, their holders from the controller. + var held struct { + Seats []struct { + Seat string `json:"seat"` + Scope string `json:"scope"` + Holders []holder `json:"holders"` + } `json:"seats"` + } + _ = json.Unmarshal([]byte(jsonIn(seatsOut)), &held) + holders := map[string][]holder{} + scopes := map[string]string{} + for _, s := range held.Seats { + holders[s.Seat] = s.Holders + scopes[s.Seat] = s.Scope + } + bySeat := map[string]*seatInfo{} + for _, t := range l.Tools { + if !t.Seat { + continue + } + s := bySeat[t.Module] + if s == nil { + scope := t.Scope + if scope == "" { + scope = scopes[t.Module] + } + if scope != "node" { + scope = "mesh" + } + s = &seatInfo{Seat: t.Module, Scope: scope, Holders: holders[t.Module]} + bySeat[t.Module] = s + } + s.Verbs = append(s.Verbs, t) + } + for _, s := range bySeat { + x.Seats = append(x.Seats, *s) + } + sort.Slice(x.Seats, func(i, j int) bool { return x.Seats[i].Seat < x.Seats[j].Seat }) + + // The machines: what the controller knows, and any that hold a seat. + seen := map[string]bool{} + for _, n := range machinesIn(nodesOut) { + seen[n] = true + } + for _, s := range x.Seats { + for _, h := range s.Holders { + if h.Node != "" { + seen[h.Node] = true + } + } + } + + // The modules that answer tools, where they run, and whether any instance will do. + on := assignmentsIn(modulesOut) + for _, t := range l.Tools { + if t.Seat { + continue + } + m := x.Modules[t.Module] + if m == nil { + m = &moduleInfo{Module: t.Module, On: on[t.Module]} + x.Modules[t.Module] = m + } + m.Tools = append(m.Tools, t) + if interchangeable(t.Module, t) { + m.Interchangeable = true + } + } + for _, m := range x.Modules { + // A module the controller could not place is placed where its own answer says it runs. + if len(m.On) == 0 { + nodes := map[string]bool{} + for _, t := range m.Tools { + for _, s := range t.Subjects { + base := "mesh.mod." + m.Module + ".tool." + t.Name + "." + if strings.HasPrefix(s, base) { + nodes[strings.TrimPrefix(s, base)] = true + } + } + } + for n := range nodes { + m.On = append(m.On, n) + } + sort.Strings(m.On) + } + for _, n := range m.On { + seen[n] = true + } + } + for n := range seen { + x.Machines = append(x.Machines, n) + } + sort.Strings(x.Machines) + return x, nil +} + +func (s *Surface) index() (*index, error) { + s.mu.Lock() + if s.idx != nil && time.Since(s.idxAt) <= IndexKept { + x := s.idx + s.mu.Unlock() + return x, nil + } + s.mu.Unlock() + x, err := indexOn(s.conn) + if err != nil { + return nil, err + } + s.mu.Lock() + s.idx, s.idxAt = x, time.Now() + s.mu.Unlock() + return x, nil +} + +// target is what an address resolves to. +type target struct { + Address string + Key string // the call key the existing path takes: seat:.[@node] or .[@node] + Name string // . or ., for the listing's subject lookup + Node string + Tool Tool + Seat bool +} + +// resolve turns an address into exactly one target, or says why it cannot. +func resolve(x *index, address string) (target, error) { + address = strings.TrimSpace(address) + node, rest, hasNode := strings.Cut(address, "/") + if !hasNode { + rest, node = address, "" + } + dot := strings.Index(rest, ".") + if dot <= 0 || dot == len(rest)-1 || strings.Contains(rest, "/") { + return target{}, fmt.Errorf("%q is not an address. %s", address, grammar) + } + prefix, name := rest[:dot], rest[dot+1:] + + if s := x.seat(prefix); s != nil { + verb := findTool(s.Verbs, name) + if verb == nil { + return target{}, fmt.Errorf("the seat %s has no verb %s; it has %s", prefix, name, toolNames(s.Verbs)) + } + if s.Scope == "node" { + if node == "" { + return target{}, fmt.Errorf("%s is held once per machine: write /%s — it is held on %s", + prefix, rest, orNobody(nodesOf(s.Holders))) + } + return target{Address: node + "/" + rest, Key: "seat:" + rest + "@" + node, Name: rest, Node: node, Tool: *verb, Seat: true}, nil + } + if node != "" { + return target{}, fmt.Errorf("%s is held once for the whole mesh: write %s, without a machine", prefix, rest) + } + return target{Address: rest, Key: "seat:" + rest, Name: rest, Tool: *verb, Seat: true}, nil + } + + m := x.Modules[prefix] + if m == nil { + return target{}, fmt.Errorf("nothing in the mesh is called %s: no seat, and no module that answers tools. "+ + "mesh_search finds a tool by words", prefix) + } + tool := findTool(m.Tools, name) + if tool == nil { + return target{}, fmt.Errorf("%s has no tool %s; it has %s", prefix, name, toolNames(m.Tools)) + } + if node == "" { + if !m.Interchangeable { + return target{}, fmt.Errorf("%s keeps state on each machine it runs on, so a call names the machine: "+ + "write /%s — it runs on %s", prefix, rest, orNobody(m.On)) + } + return target{Address: rest, Key: rest, Name: rest, Tool: *tool}, nil + } + if len(m.On) > 0 && !contains(m.On, node) { + return target{}, fmt.Errorf("%s does not run on %s; it runs on %s", prefix, node, orNobody(m.On)) + } + return target{Address: node + "/" + rest, Key: rest + "@" + node, Name: rest, Node: node, Tool: *tool}, nil +} + +func contains(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +} + +func toolNames(ts []Tool) string { + names := make([]string, 0, len(ts)) + for _, t := range ts { + names = append(names, t.Name) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +func nodesOf(hs []holder) []string { + var out []string + for _, h := range hs { + if h.Node != "" && !contains(out, h.Node) { + out = append(out, h.Node) + } + } + sort.Strings(out) + return out +} + +func orNobody(nodes []string) string { + if len(nodes) == 0 { + return "no machine the mesh knows of" + } + return strings.Join(nodes, ", ") +} + +// firstLine is a description's first line, for a list. +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexAny(s, "\n"); i >= 0 { + s = s[:i] + } + if len(s) > 160 { + s = s[:157] + "…" + } + return s +} + +// schemaWithoutNode is a tool's schema as an agent passes it: the machine is in the address. +func schemaWithoutNode(raw json.RawMessage) map[string]any { + schema := asSchema(raw) + out := map[string]any{} + for k, v := range schema { + out[k] = v + } + if p, ok := schema["properties"].(map[string]any); ok { + props := map[string]any{} + for k, v := range p { + if k != "node" { + props[k] = v + } + } + out["properties"] = props + } + if r, ok := schema["required"].([]any); ok { + var keep []any + for _, k := range r { + if k != "node" { + keep = append(keep, k) + } + } + if len(keep) == 0 { + delete(out, "required") + } else { + out["required"] = keep + } + } + return out +} + +// answerText is a discovery verb's answer as MCP content: JSON, indented. +func answerText(v any) map[string]any { + var b strings.Builder + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) // `/…` is read by an agent, not embedded in a page + enc.SetIndent("", " ") + _ = enc.Encode(v) + return map[string]any{"content": []map[string]any{{"type": "text", "text": strings.TrimRight(b.String(), "\n")}}} +} + +func failure(text string) map[string]any { + return map[string]any{"content": []map[string]any{{"type": "text", "text": text}}, "isError": true} +} + +// discover answers one of the five. +func (s *Surface) discover(name string, args map[string]any) map[string]any { + x, err := s.index() + if err != nil { + return failure(whyItFailed(catalogueModules, err)) + } + str := func(k string) string { v, _ := args[k].(string); return strings.TrimSpace(v) } + + switch name { + case verbOverview: + type verbLine struct { + Address string `json:"address"` + Description string `json:"description"` + } + var mesh, node []map[string]any + for _, st := range x.Seats { + var verbs []verbLine + for _, v := range st.Verbs { + addr := st.Seat + "." + v.Name + if st.Scope == "node" { + addr = "/" + addr + } + verbs = append(verbs, verbLine{addr, firstLine(v.Description)}) + } + entry := map[string]any{"seat": st.Seat, "verbs": verbs} + if st.Scope == "node" { + entry["held on"] = nodesOf(st.Holders) + node = append(node, entry) + } else { + if h := nodesOf(st.Holders); len(h) > 0 { + entry["held on"] = h + } + mesh = append(mesh, entry) + } + } + modules := 0 + tools := 0 + for _, m := range x.Modules { + modules++ + tools += len(m.Tools) + } + return answerText(map[string]any{ + "seats of the mesh": mesh, + "seats every machine": node, + "machines": x.Machines, + "modules with tools": fmt.Sprintf("%d modules, %d tools — mesh_machine lists a machine's, mesh_search finds one", modules, tools), + "not answering": x.NotAnswering, + }) + + case verbMachine: + node := str("node") + if node == "" { + return failure("mesh_machine needs `node`: one of " + orNobody(x.Machines)) + } + if !contains(x.Machines, node) { + return failure(fmt.Sprintf("the mesh knows no machine %q; it has %s", node, orNobody(x.Machines))) + } + var seats []map[string]any + for _, st := range x.Seats { + if st.Scope != "node" || !contains(nodesOf(st.Holders), node) { + continue + } + var verbs []string + for _, v := range st.Verbs { + verbs = append(verbs, node+"/"+st.Seat+"."+v.Name) + } + var by string + for _, h := range st.Holders { + if h.Node == node { + by = h.Module + } + } + seats = append(seats, map[string]any{"seat": st.Seat, "held by": by, "verbs": verbs}) + } + var modules []map[string]any + names := make([]string, 0, len(x.Modules)) + for n := range x.Modules { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + m := x.Modules[n] + if !contains(m.On, node) { + continue + } + var tools []map[string]string + for _, t := range m.Tools { + tools = append(tools, map[string]string{"address": node + "/" + m.Module + "." + t.Name, "does": firstLine(t.Description)}) + } + entry := map[string]any{"module": m.Module, "tools": tools} + if m.Interchangeable { + entry["interchangeable"] = "any instance answers . as well" + } + modules = append(modules, entry) + } + return answerText(map[string]any{"machine": node, "seats": seats, "modules": modules}) + + case verbSearch: + query := strings.ToLower(str("query")) + if query == "" { + return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`") + } + words := strings.Fields(query) + type hit struct { + Address string `json:"address"` + Does string `json:"does"` + Also string `json:"also,omitempty"` + } + var hits []hit + matches := func(parts ...string) bool { + hay := strings.ToLower(strings.Join(parts, " ")) + for _, w := range words { + if !strings.Contains(hay, w) { + return false + } + } + return true + } + for _, st := range x.Seats { + for _, v := range st.Verbs { + if !matches(st.Seat, v.Name, v.Description) { + continue + } + if st.Scope == "node" { + on := nodesOf(st.Holders) + first := "" + also := "" + if len(on) > 0 { + first = on[0] + if len(on) > 1 { + also = "also on " + strings.Join(on[1:], ", ") + } + } + hits = append(hits, hit{first + "/" + st.Seat + "." + v.Name, firstLine(v.Description), also}) + } else { + hits = append(hits, hit{st.Seat + "." + v.Name, firstLine(v.Description), ""}) + } + } + } + names := make([]string, 0, len(x.Modules)) + for n := range x.Modules { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + m := x.Modules[n] + for _, t := range m.Tools { + if !matches(m.Module, t.Name, t.Description) { + continue + } + switch { + case m.Interchangeable: + hits = append(hits, hit{m.Module + "." + t.Name, firstLine(t.Description), "any instance; on " + orNobody(m.On)}) + case len(m.On) == 0: + hits = append(hits, hit{"/" + m.Module + "." + t.Name, firstLine(t.Description), "the mesh places it on no machine"}) + default: + also := "" + if len(m.On) > 1 { + also = "also on " + strings.Join(m.On[1:], ", ") + } + hits = append(hits, hit{m.On[0] + "/" + m.Module + "." + t.Name, firstLine(t.Description), also}) + } + } + } + out := map[string]any{"matches": hits} + if len(hits) > searchCap { + out["matches"] = hits[:searchCap] + out["more"] = fmt.Sprintf("%d more; narrow the words", len(hits)-searchCap) + } + if len(hits) == 0 { + out["matches"] = []hit{} + out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse" + } + return answerText(out) + + case verbDescribe: + t, err := resolve(x, str("address")) + if err != nil { + return failure(err.Error()) + } + description := t.Tool.Description + if description == "" { + description = t.Name + } + return answerText(map[string]any{"address": t.Address, "description": description, + "arguments": schemaWithoutNode(t.Tool.Input)}) + + case verbCall: + t, err := resolve(x, str("address")) + if err != nil { + return failure(err.Error()) + } + callArgs := map[string]any{} + if a, ok := args["arguments"].(map[string]any); ok { + for k, v := range a { + if k != "node" { + callArgs[k] = v + } + } + } + var got bus.Answered + if t.Seat { + got, err = s.conn.Ask(t.Key, callArgs, "") + } else { + got, err = callTool(s.conn, t.Key, callArgs, nil, x.Listing) + } + if err != nil { + return failure(whyItFailed(t.Address, err)) + } + content := []map[string]any{{"type": "text", "text": pretty(got.Result)}} + if got.Node != "" { + content = append(content, map[string]any{"type": "text", "text": "answered by " + got.Node}) + } + return map[string]any{"content": content} + } + return failure("no discovery verb " + name) +} diff --git a/node-tools/internal/console/address_test.go b/node-tools/internal/console/address_test.go new file mode 100644 index 0000000..25d897b --- /dev/null +++ b/node-tools/internal/console/address_test.go @@ -0,0 +1,222 @@ +package console + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" + "github.com/novox/mesh-tools/node-tools/internal/runtime" +) + +// text is a tool result's first text, and whether it was an error. +func text(t *testing.T, reply map[string]any) (string, bool) { + t.Helper() + result, ok := reply["result"].(map[string]any) + if !ok { + t.Fatalf("no result: %v", reply) + } + content := result["content"].([]any) + isErr, _ := result["isError"].(bool) + var parts []string + for _, c := range content { + parts = append(parts, c.(map[string]any)["text"].(string)) + } + return strings.Join(parts, "\n"), isErr +} + +func call(t *testing.T, endpoint, tool string, args map[string]any) (string, bool) { + t.Helper() + body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 9, "method": "tools/call", + "params": map[string]any{"name": tool, "arguments": args}}) + return text(t, post(t, endpoint, string(body))) +} + +// novox/hq ADR 0195: the console announces five tools, and everything the mesh answers is reached +// through them by one address per layer. +func TestTheMeshsToolsAreFoundByAddress(t *testing.T) { + was := IndexKept + IndexKept = 0 // every discovery asks the mesh, so a module arriving mid-test is found + t.Cleanup(func() { IndexKept = was }) + + mesh := mt.New(t) + // alpha: interchangeable (the mesh issued it a plain subject); beta: state on its machine, holds + // the node-shelf seat there. + 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": "gamma"}}}, nil + }) + defer stopCat() + + // The controller, answering as its seat verbs do: the command's printed output. + controller := connect(t, "mesh-controller", "") + out := func(s string) map[string]any { return map[string]any{"output": s, "ok": true} } + serve := func(verb string, answer func() any) { + stop, err := controller.HandleSubject("mesh.seat.mesh-controller.tool."+verb, func(json.RawMessage) (any, error) { + return answer(), nil + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(stop) + } + serve("tools", func() any { + return map[string]any{"seats": []map[string]any{ + {"seat": "mesh-controller", "scope": "mesh", "tools": []map[string]any{ + {"name": "nodes", "description": "Every machine the mesh knows.", "input": 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{}}}}, + }} + }) + serve("nodes", func() any { + return out("bench 3m ago converged 1f2e\ndesk here converged 9a8b\n") + }) + serve("seats", func() any { + return out("a warning the command printed first\n" + `{ + "seats": [ + {"seat": "mesh-controller", "scope": "mesh", "decision": "x", "holders": [{"module": "mesh-controller", "node": "bench"}]}, + {"seat": "node-shelf", "scope": "node", "decision": "y", "holders": [{"module": "beta", "node": "desk"}]} + ] +}`) + }) + gammaOn := "nothing" + serve("modules", func() any { + return out(fmt.Sprintf("alpha 1 built 1a2b3c4d on desk\n needs container-runtime\n"+ + "beta 1 built 1a2b3c4d on desk\n"+ + "gamma 1 built 1a2b3c4d on %s\n", gammaOn)) + }) + 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" + + // Five tools, nothing else. + 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) { + name := x.(map[string]any)["name"].(string) + names = append(names, name) + for _, r := range name { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') || len(name) > 64 { + t.Errorf("%q is not a name the API takes", name) + } + } + } + if got := strings.Join(names, ","); got != "mesh_overview,mesh_machine,mesh_search,mesh_describe,mesh_call" { + t.Errorf("announced %s", got) + } + + // The overview names the mesh's seats, the machines' seats and the machines. + overview, isErr := call(t, endpoint, "mesh_overview", nil) + if isErr || !strings.Contains(overview, "mesh-controller.nodes") || !strings.Contains(overview, "/node-shelf.list") || + !strings.Contains(overview, `"bench"`) || !strings.Contains(overview, `"desk"`) { + t.Errorf("overview: %s", overview) + } + machine, isErr := call(t, endpoint, "mesh_machine", map[string]any{"node": "desk"}) + if isErr || !strings.Contains(machine, "desk/node-shelf.list") || !strings.Contains(machine, "desk/beta.three") || + !strings.Contains(machine, "desk/alpha.one") { + t.Errorf("machine: %s", machine) + } + + // A mesh seat, a node seat, an assignment and an interchangeable module, each by address. + if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "mesh-controller.nodes"}); isErr || !strings.Contains(got, "converged") { + t.Errorf("mesh seat: %s", got) + } + if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "desk/node-shelf.list"}); isErr || !strings.Contains(got, `"a"`) { + t.Errorf("node seat: %s", got) + } + if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "desk/beta.three"}); isErr || + !strings.Contains(got, `"beta": 3`) || !strings.Contains(got, "answered by desk") { + t.Errorf("assignment: %s", got) + } + if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "alpha.one"}); isErr || !strings.Contains(got, `"alpha": 1`) { + t.Errorf("interchangeable module: %s", got) + } + + // Refused, by name, where the address does not say enough or says the wrong thing. + for address, want := range map[string]string{ + "beta.three": "keeps state on each machine it runs on, so a call names the machine: write /beta.three — it runs on desk", + "node-shelf.list": "held once per machine: write /node-shelf.list — it is held on desk", + "desk/mesh-controller.nodes": "held once for the whole mesh", + "bench/beta.three": "beta does not run on bench; it runs on desk", + "nonsense": "is not an address", + } { + got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": address}) + if !isErr || !strings.Contains(got, want) { + t.Errorf("%s: %s (want %q)", address, got, want) + } + } + + // Described without `node`: the address carries the machine. + described, isErr := call(t, endpoint, "mesh_describe", map[string]any{"address": "desk/beta.three"}) + if isErr || strings.Contains(described, `"node"`) || !strings.Contains(described, `"address": "desk/beta.three"`) { + t.Errorf("describe: %s", described) + } + + // Search finds across the layers; a module that starts serving after the first answer is found. + if got, _ := call(t, endpoint, "mesh_search", map[string]any{"query": "shelf"}); !strings.Contains(got, "desk/node-shelf.list") { + t.Errorf("search a node seat: %s", got) + } + if got, _ := call(t, endpoint, "mesh_search", map[string]any{"query": "gamma"}); strings.Contains(got, "gamma.given") { + t.Fatalf("gamma was found before it served: %s", got) + } + mesh.Issue(t, mt.MembershipOf("gamma", "desk", false, nil)) + gammaOn = "desk" + late := connect(t, "node-tools", "desk") + stopLate, err := runtime.Run(late, []runtime.Served{{Module: "gamma", Entrypoints: []string{mt.Fixture("env-gamma.serve.mjs")}}}, + nil, (&mt.Logs{}).Logf) + if err != nil { + t.Fatal(err) + } + defer stopLate() + var found string + for i := 0; i < 30; i++ { + found, _ = call(t, endpoint, "mesh_search", map[string]any{"query": "gamma given"}) + if strings.Contains(found, "desk/gamma.given") { + break + } + time.Sleep(100 * time.Millisecond) + } + if !strings.Contains(found, "desk/gamma.given") { + t.Errorf("a module that arrived later was not found: %s", found) + } + + // The old names still answer, unannounced. + if got, isErr := call(t, endpoint, "alpha.one", nil); isErr || !strings.Contains(got, `"alpha": 1`) { + t.Errorf("an old name: %s", got) + } +} + +func TestTheControllersPrintedListsAreRead(t *testing.T) { + machines := machinesIn("the bus's user list leaves out 2 user(s)\nace 2m ago converged 0c1d\n" + + "g14 here converged 77aa\nnovox 5s ago adopted 3e4f\n") + if got := strings.Join(machines, ","); got != "ace,g14,novox" { + t.Errorf("machines: %s", got) + } + on := assignmentsIn("baserow 1 built 7c800705 on ace\n requires postgres-database\n" + + "confluence 1 built 7c800705 on nothing\n" + + "mesh-wireguard 1 with the control plane on ace, g14, novox, shanks\n") + if strings.Join(on["baserow"], ",") != "ace" || len(on["confluence"]) != 0 || strings.Join(on["mesh-wireguard"], ",") != "ace,g14,novox,shanks" { + t.Errorf("assignments: %v", on) + } +} diff --git a/node-tools/internal/console/console_test.go b/node-tools/internal/console/console_test.go index 0645ae9..8052ea9 100644 --- a/node-tools/internal/console/console_test.go +++ b/node-tools/internal/console/console_test.go @@ -71,7 +71,9 @@ func TestTheConsoleListsAndCallsOverHTTP(t *testing.T) { catalogue.Flush() controller.Flush() - up, err := Serve(NewSurface(nodeTools, "desk.node-tools"), "127.0.0.1:0") + flat := NewSurface(nodeTools, "desk.node-tools") + flat.Flat = true // the whole catalogue, as before ADR 0195: still reachable, no longer announced + up, err := Serve(flat, "127.0.0.1:0") if err != nil { t.Fatal(err) } diff --git a/node-tools/internal/console/mcp.go b/node-tools/internal/console/mcp.go index 07de2b1..d26b543 100644 --- a/node-tools/internal/console/mcp.go +++ b/node-tools/internal/console/mcp.go @@ -5,6 +5,7 @@ package console import ( "encoding/json" + "os" "strings" "sync" "time" @@ -47,10 +48,17 @@ type Surface struct { mu sync.Mutex known *Listing at time.Time + idx *index + idxAt time.Time + // Flat announces the whole catalogue, as the console did before ADR 0195: for a person reading + // it or a client that wants it. Off by default; MESH_CONSOLE_FLAT=1 turns it on. + Flat bool } // NewSurface is the surface over a connection, as `who`. -func NewSurface(conn *bus.Conn, who string) *Surface { return &Surface{conn: conn, who: who} } +func NewSurface(conn *bus.Conn, who string) *Surface { + return &Surface{conn: conn, who: who, Flat: os.Getenv("MESH_CONSOLE_FLAT") == "1"} +} func (s *Surface) listing() (*Listing, error) { s.mu.Lock() @@ -99,12 +107,7 @@ func (s *Surface) Handle(r Request) *Reply { "protocolVersion": Protocol, "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]any{"name": "mesh", "version": "1"}, - "instructions": "These are the tools of a Novox mesh, reached as " + s.who + ". Every call goes to the module " + - "that serves it; what may be called was fixed when this account was issued, so a " + - "refusal means the account, not the tool. The list is what the running modules " + - "answered, plus every role's tools from the mesh's records — the mesh's own verbs " + - "(mesh-controller.status, .push, .assign …) among them; a module that did not answer " + - "is named in the list's _meta and can still be called by ..", + "instructions": s.instructions(), }) case "notifications/initialized": return nil @@ -114,6 +117,9 @@ func (s *Surface) Handle(r Request) *Reply { } return answer(r.ID, map[string]any{}) case "tools/list": + if !s.Flat { + return answer(r.ID, map[string]any{"tools": discovery()}) + } l, err := s.listing() if err != nil { return refuse(r.ID, -32603, whyItFailed(catalogueModules, err)) @@ -142,6 +148,12 @@ func (s *Surface) Handle(r Request) *Reply { Arguments map[string]any `json:"arguments"` } _ = json.Unmarshal(r.Params, &p) + if isDiscovery(p.Name) { + if p.Arguments == nil { + p.Arguments = map[string]any{} + } + return answer(r.ID, s.discover(p.Name, p.Arguments)) + } args := map[string]any{} for k, v := range p.Arguments { args[k] = v @@ -266,3 +278,20 @@ func withNode(schema map[string]any, description string, required bool) map[stri } return out } + +// instructions is what an agent host is told about this surface when it connects. +func (s *Surface) instructions() string { + if s.Flat { + return "These are the tools of a Novox mesh, reached as " + s.who + ". Every call goes to the module " + + "that serves it; what may be called was fixed when this account was issued, so a " + + "refusal means the account, not the tool. The list is what the running modules " + + "answered, plus every role's tools from the mesh's records — the mesh's own verbs " + + "(mesh-controller.status, .push, .assign …) among them; a module that did not answer " + + "is named in the list's _meta and can still be called by .." + } + return "The tools of a Novox mesh, reached as " + s.who + ", found by address rather than listed " + + "whole (novox/hq ADR 0195). mesh_overview shows the mesh's seats and machines; mesh_machine one " + + "machine's seats and modules; mesh_search finds a tool by words; mesh_describe gives one tool's " + + "arguments; mesh_call calls it. " + grammar + " What may be called was fixed when this account " + + "was issued, so a refusal means the account, not the tool." +}