mesh_overview, mesh_machine, mesh_search, mesh_describe and mesh_call walk the mesh's structure; every tool has one address per layer: <seat>.<verb>, <node>/<seat>.<verb>, <node>/<module>.<tool>, and <module>.<tool> for a module the mesh issued a plain subject. A stateful module called without its machine, a node seat without one, a mesh seat with one, or a module on the wrong machine is refused naming what would work. Answers come from the mesh when asked, kept five seconds, so a tool that arrives mid-session is found. The flat catalogue stays behind MESH_CONSOLE_FLAT=1 and old <module>.<tool> names still answer.
298 lines
9.1 KiB
Go
298 lines
9.1 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
|
|
// 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, whyItFailed(catalogueModules, err))
|
|
}
|
|
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 <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. " + grammar + " What may be called was fixed when this account " +
|
|
"was issued, so a refusal means the account, not the tool."
|
|
}
|