The mesh MCP server gains mesh_approvals: with no argument the calls asked on the machine in the last day, newest first, with state and fingerprint, the marks the gate applies with their sources and the catalog revision, and the state's fill against its cap (every machine's keys together); an ask read whole with the act's digest; record asking the hand-act again, and cancel taking an open ask back, each recorded in the hand-act log first and refused when it cannot be. mesh_describe answers needs-approval with its source, and a marked call that reached no one names withheld and mesh-grants.grants_user_list. Builds: design 46 7-approval-mark §18, §6 (the mesh MCP server's withheld answer), §22 (eight tools) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
378 lines
13 KiB
Go
378 lines
13 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 (
|
|
"context"
|
|
"encoding/json"
|
|
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"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
|
|
src Source
|
|
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
|
|
// Record, when set, records a hand act in place of the seat mesh-hand-acts' verb (a test's).
|
|
Record func(args map[string]any) error
|
|
// Approvals is this machine's approval gate, read by mesh_approvals and mesh_describe; nil when the runner has none.
|
|
Approvals Approvals
|
|
}
|
|
|
|
// MethodSeats is the console's JSON-RPC method beside MCP's that answers every seat's verbs and holders, in the shape
|
|
// the old `tools` answered: `{seats: [{seat, scope, tools: [{name, description, input, replaces}], holders:
|
|
// [{module, node}]}], holders_why}`.
|
|
const MethodSeats = "mesh/seats"
|
|
|
|
// seatsAnswer is MethodSeats' answer: mesh-sdk's seat definitions, with each seat's holders by assignment.
|
|
func (s *Surface) seatsAnswer() map[string]any {
|
|
placed, err := seatHoldersOn(s.src)
|
|
x := &index{Placed: placed}
|
|
seats := []map[string]any{}
|
|
for _, st := range catalogue.Seats() {
|
|
holders := x.placedHolders(st.Name, "")
|
|
if holders == nil {
|
|
holders = []meshmoduleassigner.SeatHoldersHoldersItem{}
|
|
}
|
|
tools := st.Serves
|
|
if tools == nil {
|
|
tools = []catalogue.Verb{}
|
|
}
|
|
seats = append(seats, map[string]any{"seat": st.Name, "scope": st.Scope, "tools": tools, "holders": holders})
|
|
}
|
|
out := map[string]any{"seats": seats}
|
|
if err != nil {
|
|
out["holders_why"] = "who holds which seat by assignment could not be read: " + err.Error()
|
|
}
|
|
return out
|
|
}
|
|
|
|
// NewSurface is the surface over a connection, as `who`.
|
|
func NewSurface(conn *bus.Conn, src Source, who string) *Surface {
|
|
return &Surface{conn: conn, src: src, 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, s.src)
|
|
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 { return s.HandleContext(context.Background(), r) }
|
|
|
|
// HandleContext answers one request for as long as ctx lasts: a wait whose client hung up or cancelled
|
|
// ends at once, its subscription closed (the review of issue 745).
|
|
func (s *Surface) HandleContext(ctx context.Context, 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 MethodSeats:
|
|
// The seats' contract and who holds each, for a client on this machine that has no seat definitions of its own
|
|
// (the TypeScript `mesh`): read in-process from mesh-sdk and from the assigner, never asked of anything else
|
|
// (novox/hq ADR 0571 clauses 4 and 5).
|
|
return answer(r.ID, s.seatsAnswer())
|
|
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(ctx, 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-delivery.deliveries, mesh-module-assigner.propose …) 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; mesh_wait waits on the " +
|
|
"bus for an event instead of polling (novox/hq ADR 0367). " + 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
|
|
}
|