mesh/merge-gate pass: builds mesh-tools, node-tools → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of …
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery delivered
A round in which no PING reached the laptop's console within the 750 ms window was taken as the mesh's answer that nothing is there, and replaced the index: every seat and module call was refused for five seconds at a time. Discovery now waits up to its patience for a first PING, a round that heard nothing is not kept, a runtime not heard this round is resolved from what it announced last (said with its age), and a round that misses runtimes is logged once. A PING carries a digest of what the instance serves, so an unchanged announcement is not pulled again every round (some 880 kB to the laptop).
332 lines
10 KiB
Go
332 lines
10 KiB
Go
// Package console is the mesh's tools as an MCP server on a machine's loopback (novox/hq design 34,
|
|
// ADR 0152, ADR 0175 §6): the Go port of node-tools' http.ts, mcp.ts and client.ts. A thin adapter:
|
|
// every tool listed is one a module answered for, the schema is the module's, the answer the module's.
|
|
package console
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
|
)
|
|
|
|
// Protocol is the MCP version spoken to an agent host.
|
|
const Protocol = "2025-03-26"
|
|
|
|
// ListingKept is how long a fetched tool list is kept before the modules are asked again.
|
|
var ListingKept = 30 * time.Second
|
|
|
|
// Request is one JSON-RPC message from a host.
|
|
type Request struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id,omitempty"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
}
|
|
|
|
// Reply is one JSON-RPC answer.
|
|
type Reply struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id"`
|
|
Result any `json:"result,omitempty"`
|
|
Error *RPCError `json:"error,omitempty"`
|
|
}
|
|
|
|
// RPCError is a protocol-level refusal.
|
|
type RPCError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// Surface answers MCP requests over one bus connection, as one account.
|
|
type Surface struct {
|
|
conn *bus.Conn
|
|
who string
|
|
mu sync.Mutex
|
|
known *Listing
|
|
at time.Time
|
|
idx *index
|
|
idxAt time.Time
|
|
// runtimes is every runtime discovery has heard, as it last heard it (mesh_runtimes).
|
|
runtimes map[string]*runtimeSeen
|
|
// roundSaid is what the last round logged was missing, so a round is logged once (novox/hq issue 554).
|
|
roundSaid string
|
|
// 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, Flat: os.Getenv("MESH_CONSOLE_FLAT") == "1"}
|
|
}
|
|
|
|
func (s *Surface) listing() (*Listing, error) {
|
|
s.mu.Lock()
|
|
if s.known != nil && time.Since(s.at) <= ListingKept {
|
|
l := s.known
|
|
s.mu.Unlock()
|
|
return l, nil
|
|
}
|
|
s.mu.Unlock()
|
|
l, err := toolsOn(s.conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
s.known, s.at = l, time.Now()
|
|
s.mu.Unlock()
|
|
return l, nil
|
|
}
|
|
|
|
func isNotification(id json.RawMessage) bool {
|
|
t := strings.TrimSpace(string(id))
|
|
return t == "" || t == "null"
|
|
}
|
|
|
|
func answer(id json.RawMessage, result any) *Reply {
|
|
return &Reply{JSONRPC: "2.0", ID: idOrNull(id), Result: result}
|
|
}
|
|
|
|
func refuse(id json.RawMessage, code int, message string) *Reply {
|
|
return &Reply{JSONRPC: "2.0", ID: idOrNull(id), Error: &RPCError{Code: code, Message: message}}
|
|
}
|
|
|
|
func idOrNull(id json.RawMessage) json.RawMessage {
|
|
if isNotification(id) {
|
|
return json.RawMessage("null")
|
|
}
|
|
return id
|
|
}
|
|
|
|
// Handle answers one request; nil for a notification, which expects none.
|
|
func (s *Surface) Handle(r Request) *Reply {
|
|
notification := isNotification(r.ID)
|
|
switch r.Method {
|
|
case "initialize":
|
|
return answer(r.ID, map[string]any{
|
|
"protocolVersion": Protocol,
|
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
|
"serverInfo": map[string]any{"name": "mesh", "version": "1"},
|
|
"instructions": s.instructions(),
|
|
})
|
|
case "notifications/initialized":
|
|
return nil
|
|
case "ping":
|
|
if notification {
|
|
return nil
|
|
}
|
|
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, "the mesh's discovery failed: "+err.Error())
|
|
}
|
|
tools := make([]map[string]any, 0, len(l.Tools))
|
|
inputs := map[string]map[string]json.RawMessage{} // module → tool → input, asked once per module
|
|
for i, t := range l.Tools {
|
|
if !t.Seat && len(t.Input) == 0 {
|
|
if inputs[t.Module] == nil {
|
|
inputs[t.Module] = toolInputs(s.conn, t.Module)
|
|
}
|
|
l.Tools[i].Input = inputs[t.Module][t.Name]
|
|
t = l.Tools[i]
|
|
}
|
|
var schema map[string]any
|
|
switch {
|
|
case t.Seat && t.Scope != "node":
|
|
schema = asSchema(t.Input)
|
|
case t.Seat:
|
|
schema = withNode(asSchema(t.Input), "the machine whose seat answers; required, the seat is held once per machine", true)
|
|
default:
|
|
schema = withNode(asSchema(t.Input), "", false)
|
|
}
|
|
description := t.Description
|
|
if description == "" {
|
|
description = t.Name + ", served by " + t.Module
|
|
}
|
|
tools = append(tools, map[string]any{"name": t.Module + "." + t.Name, "description": description, "inputSchema": schema})
|
|
}
|
|
return answer(r.ID, map[string]any{"tools": tools, "_meta": map[string]any{"notAnswering": l.NotAnswering}})
|
|
case "tools/call":
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
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
|
|
}
|
|
l, _ := s.listing()
|
|
var roles Seats
|
|
if l != nil {
|
|
roles = seatsIn(l)
|
|
}
|
|
bare, _, _ := strings.Cut(p.Name, "@")
|
|
isSeatVerb := roles != nil && strings.HasPrefix(toolKey(bare, roles), "seat:")
|
|
nodeScoped := false
|
|
if isSeatVerb && l != nil {
|
|
for _, t := range l.Tools {
|
|
if t.Seat && t.Scope == "node" && t.Module+"."+t.Name == bare {
|
|
nodeScoped = true
|
|
}
|
|
}
|
|
}
|
|
takesNode := !isSeatVerb || nodeScoped
|
|
node := ""
|
|
if takesNode {
|
|
if n, ok := args["node"].(string); ok {
|
|
node = n
|
|
}
|
|
delete(args, "node")
|
|
}
|
|
if nodeScoped && node == "" && !strings.Contains(p.Name, "@") {
|
|
return refuse(r.ID, -32602, p.Name+" is a machine's seat's verb: name the machine with `node`")
|
|
}
|
|
name := p.Name
|
|
if node != "" && !strings.Contains(p.Name, "@") {
|
|
name = p.Name + "@" + node
|
|
}
|
|
got, err := callTool(s.conn, name, args, roles, l)
|
|
if err != nil {
|
|
return answer(r.ID, map[string]any{
|
|
"content": []map[string]any{{"type": "text", "text": whyItFailed(name, err)}},
|
|
"isError": true,
|
|
})
|
|
}
|
|
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 answer(r.ID, map[string]any{"content": content})
|
|
}
|
|
if notification {
|
|
return nil
|
|
}
|
|
return refuse(r.ID, -32601, "mesh's MCP surface has no "+r.Method)
|
|
}
|
|
|
|
// pretty is a module's answer as JSON text, indented as JSON.stringify(result, null, 2) writes it.
|
|
func pretty(raw json.RawMessage) string {
|
|
if len(raw) == 0 {
|
|
return "null"
|
|
}
|
|
var v any
|
|
if json.Unmarshal(raw, &v) != nil {
|
|
return string(raw)
|
|
}
|
|
b, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
return string(raw)
|
|
}
|
|
return strings.NewReplacer(`<`, "<", `>`, ">", `&`, "&").Replace(string(b))
|
|
}
|
|
|
|
// asSchema is a module's declared input as a JSON schema: wrapped when it is a bare map of
|
|
// properties, passed through when it is a schema, empty when nothing was declared.
|
|
func asSchema(raw json.RawMessage) map[string]any {
|
|
var given map[string]any
|
|
if json.Unmarshal(raw, &given) != nil || given == nil {
|
|
return map[string]any{"type": "object", "properties": map[string]any{}}
|
|
}
|
|
if given["type"] == "object" {
|
|
return given
|
|
}
|
|
if _, has := given["properties"]; has {
|
|
return given
|
|
}
|
|
if len(given) == 0 {
|
|
return map[string]any{"type": "object", "properties": map[string]any{}}
|
|
}
|
|
return map[string]any{"type": "object", "properties": given}
|
|
}
|
|
|
|
// withNode adds the optional — or, for a node seat, required — `node` argument (ADR 0159).
|
|
func withNode(schema map[string]any, description string, required bool) map[string]any {
|
|
properties := map[string]any{}
|
|
if p, ok := schema["properties"].(map[string]any); ok {
|
|
for k, v := range p {
|
|
properties[k] = v
|
|
}
|
|
}
|
|
if _, has := properties["node"]; !has {
|
|
if description == "" {
|
|
description = "the machine to ask, when this module runs on several; else whichever answers, and the answer says which"
|
|
}
|
|
properties["node"] = map[string]any{"type": "string", "description": description}
|
|
}
|
|
out := map[string]any{}
|
|
for k, v := range schema {
|
|
out[k] = v
|
|
}
|
|
out["type"] = "object"
|
|
out["properties"] = properties
|
|
if required {
|
|
have := []any{}
|
|
if r, ok := schema["required"].([]any); ok {
|
|
have = r
|
|
}
|
|
hasNode := false
|
|
for _, x := range have {
|
|
hasNode = hasNode || x == "node"
|
|
}
|
|
if !hasNode {
|
|
have = append(have, "node")
|
|
}
|
|
out["required"] = have
|
|
}
|
|
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 <module>.<tool>."
|
|
}
|
|
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; mesh_runtimes says which machines' runtimes answered discovery, how fast " +
|
|
"and how much — ask it when an address is said to be missing or on another machine. " + grammar + " What may be called was fixed when this account " +
|
|
"was issued, so a refusal means the account, not the tool."
|
|
}
|
|
|
|
// toolInputs is every tool's input schema of one module, asked of a runtime that serves it.
|
|
func toolInputs(conn *bus.Conn, module string) map[string]json.RawMessage {
|
|
out := map[string]json.RawMessage{}
|
|
got, err := conn.Ask(module+".tools", map[string]any{}, "")
|
|
if err != nil {
|
|
return out
|
|
}
|
|
var answer struct {
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
Input json.RawMessage `json:"input"`
|
|
} `json:"tools"`
|
|
}
|
|
if json.Unmarshal(got.Result, &answer) == nil {
|
|
for _, t := range answer.Tools {
|
|
out[t.Name] = t.Input
|
|
}
|
|
}
|
|
return out
|
|
}
|