// 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 // 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)) for _, t := range l.Tools { 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 .." } 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." }