node-tools in Go: the node's runtime, launch-only, wire-compatible with the TypeScript (hq ADR 0193)
The runtime knows no language, so nothing ties it to Node.js. This ports its serve mode — the pinned bus connection and patient connect, following memberships, launching every served bundle over MCP on stdio with its own environment, a child's emit published as its module, each tool, the tools verb and seat verbs served where the mesh issued them, and the console on loopback — to one static binary. Same subjects, request and reply bodies, event headers and MCP answers. The TypeScript stays: it is still the runtime inside the per-module containers until WP4c. Tests run against a real bus and share the TypeScript fixtures.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
)
|
||||
|
||||
const (
|
||||
catalogueModules = "mesh-catalog.catalog_modules"
|
||||
seatTools = "seat:mesh-controller.tools"
|
||||
toolsVerb = "tools"
|
||||
)
|
||||
|
||||
// Tool is a tool as its module — or, for a role's tool, the mesh's records — describes it.
|
||||
type Tool struct {
|
||||
Module string
|
||||
Name string
|
||||
Description string
|
||||
Input json.RawMessage
|
||||
Seat bool
|
||||
Scope string
|
||||
Subjects []string
|
||||
}
|
||||
|
||||
// Listing is what the mesh could say about its tools; silence is named, never dropped (design 34 §3).
|
||||
type Listing struct {
|
||||
Tools []Tool
|
||||
NotAnswering []string
|
||||
}
|
||||
|
||||
// Seats is the seats and their verbs, so `<seat>.<verb>` resolves to the role.
|
||||
type Seats map[string]map[string]bool
|
||||
|
||||
func seatsIn(l *Listing) Seats {
|
||||
out := Seats{}
|
||||
for _, t := range l.Tools {
|
||||
if !t.Seat {
|
||||
continue
|
||||
}
|
||||
if out[t.Module] == nil {
|
||||
out[t.Module] = map[string]bool{}
|
||||
}
|
||||
out[t.Module][t.Name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toolKey is the key a call uses: a role's when the prefix is a seat declaring that verb.
|
||||
func toolKey(name string, seats Seats) string {
|
||||
if strings.HasPrefix(name, "seat:") {
|
||||
return name
|
||||
}
|
||||
dot := strings.Index(name, ".")
|
||||
if dot < 0 {
|
||||
return name
|
||||
}
|
||||
if seats[name[:dot]][name[dot+1:]] {
|
||||
return "seat:" + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// toolsOn asks the mesh what tools it has: the catalogue which modules it holds, each module what it
|
||||
// serves, the controller's seat every role's tools — at once, so a restarting control plane hides
|
||||
// nothing else.
|
||||
func toolsOn(conn *bus.Conn) (*Listing, error) {
|
||||
type rolesAnswer struct {
|
||||
Seats []struct {
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"tools"`
|
||||
} `json:"seats"`
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
var roles *rolesAnswer
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if got, err := conn.Ask(seatTools, map[string]any{}, ""); err == nil {
|
||||
var r rolesAnswer
|
||||
if json.Unmarshal(got.Result, &r) == nil {
|
||||
roles = &r
|
||||
}
|
||||
}
|
||||
}()
|
||||
answered, err := conn.Ask(catalogueModules, map[string]any{}, "")
|
||||
if err != nil {
|
||||
wg.Wait()
|
||||
return nil, err
|
||||
}
|
||||
var held struct {
|
||||
Modules []struct {
|
||||
Module string `json:"module"`
|
||||
} `json:"modules"`
|
||||
}
|
||||
_ = json.Unmarshal(answered.Result, &held)
|
||||
names := make([]string, 0, len(held.Modules))
|
||||
for _, m := range held.Modules {
|
||||
if m.Module != "" {
|
||||
names = append(names, m.Module)
|
||||
}
|
||||
}
|
||||
type outcome struct {
|
||||
ok bool
|
||||
answer struct {
|
||||
Tools *[]struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
Subjects []string `json:"subjects"`
|
||||
} `json:"tools"`
|
||||
Failed *string `json:"failed"`
|
||||
}
|
||||
}
|
||||
outcomes := make([]outcome, len(names))
|
||||
for i, module := range names {
|
||||
wg.Add(1)
|
||||
go func(i int, module string) {
|
||||
defer wg.Done()
|
||||
got, err := conn.Ask(module+"."+toolsVerb, map[string]any{}, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if json.Unmarshal(got.Result, &outcomes[i].answer) == nil {
|
||||
outcomes[i].ok = true
|
||||
}
|
||||
}(i, module)
|
||||
}
|
||||
wg.Wait()
|
||||
l := &Listing{Tools: []Tool{}, NotAnswering: []string{}}
|
||||
if roles != nil {
|
||||
for _, s := range roles.Seats {
|
||||
for _, t := range s.Tools {
|
||||
l.Tools = append(l.Tools, Tool{Module: s.Seat, Name: t.Name, Description: t.Description,
|
||||
Input: t.Input, Seat: true, Scope: s.Scope})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
l.NotAnswering = append(l.NotAnswering, "mesh-controller (seat)")
|
||||
}
|
||||
for i, module := range names {
|
||||
o := outcomes[i]
|
||||
switch {
|
||||
case o.ok && o.answer.Failed != nil:
|
||||
l.NotAnswering = append(l.NotAnswering, fmt.Sprintf("%s (its tools bundle failed to load: %s)", module, *o.answer.Failed))
|
||||
case o.ok && o.answer.Tools != nil:
|
||||
for _, t := range *o.answer.Tools {
|
||||
l.Tools = append(l.Tools, Tool{Module: module, Name: t.Name, Description: t.Description,
|
||||
Input: t.Input, Subjects: t.Subjects})
|
||||
}
|
||||
default:
|
||||
l.NotAnswering = append(l.NotAnswering, module)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(l.Tools, func(i, j int) bool {
|
||||
return l.Tools[i].Module+"."+l.Tools[i].Name < l.Tools[j].Module+"."+l.Tools[j].Name
|
||||
})
|
||||
sort.Strings(l.NotAnswering)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// callTool calls `<module>.<tool>[@<node>]`, on the subject the listing names for it when it names one.
|
||||
func callTool(conn *bus.Conn, key string, args any, seats Seats, l *Listing) (bus.Answered, error) {
|
||||
name, node, _ := strings.Cut(key, "@")
|
||||
if !strings.Contains(name, ".") {
|
||||
return bus.Answered{}, fmt.Errorf("%q does not name a tool: write <module>.<tool>, as `mesh tools` lists "+
|
||||
"them, or <module>.<tool>@<node> for the instance on one machine", key)
|
||||
}
|
||||
resolved := toolKey(name, seats)
|
||||
if node != "" {
|
||||
resolved += "@" + node
|
||||
}
|
||||
return conn.Ask(resolved, args, subjectListed(name, node, l))
|
||||
}
|
||||
|
||||
func subjectListed(name, node string, l *Listing) string {
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
dot := strings.Index(name, ".")
|
||||
module, tool := name[:dot], name[dot+1:]
|
||||
for _, t := range l.Tools {
|
||||
if t.Module == module && t.Name == tool && !t.Seat {
|
||||
if len(t.Subjects) == 0 {
|
||||
return ""
|
||||
}
|
||||
if node == "" {
|
||||
return t.Subjects[0]
|
||||
}
|
||||
for _, s := range t.Subjects {
|
||||
if strings.HasSuffix(s, "."+node) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
noResponders = regexp.MustCompile(`(?i)no responders|503`)
|
||||
refused = regexp.MustCompile(`(?i)permissions violation|authorization`)
|
||||
timedOut = regexp.MustCompile(`(?i)timeout`)
|
||||
)
|
||||
|
||||
// whyItFailed says why a call failed, so the remedy is in the words.
|
||||
func whyItFailed(key string, err error) string {
|
||||
if err == nil {
|
||||
err = errors.New("failed")
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case noResponders.MatchString(msg):
|
||||
extra := ""
|
||||
if strings.HasPrefix(key, "seat:") {
|
||||
extra = ", or nothing holds that seat"
|
||||
}
|
||||
return "nothing serves " + key + ". The module may not be assigned to any machine, or it is down" +
|
||||
extra + " — `mesh tools` lists what answered."
|
||||
case refused.MatchString(msg):
|
||||
return "this account may not call " + key + ". What it may call was fixed when it was issued — a " +
|
||||
"person's by `operator issue`, the console's by its manifest."
|
||||
case timedOut.MatchString(msg):
|
||||
return key + " did not answer in time. Something is serving it, so this is the tool being slow " +
|
||||
"rather than absent."
|
||||
}
|
||||
return key + " failed: " + msg
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/runtime"
|
||||
)
|
||||
|
||||
func connect(t *testing.T, module, node string) *bus.Conn {
|
||||
t.Helper()
|
||||
c, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: module, Node: node})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Logf = func(string, ...any) {}
|
||||
t.Cleanup(c.Close)
|
||||
return c
|
||||
}
|
||||
|
||||
func post(t *testing.T, endpoint string, body string) map[string]any {
|
||||
t.Helper()
|
||||
res, err := http.Post(endpoint, "application/json", bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
raw, _ := io.ReadAll(res.Body)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("%d %s", res.StatusCode, raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// As node-tools, the runtime serves the bundles and is the console on loopback: the listing is what
|
||||
// the modules and the mesh's records answered, and a call reaches the module on the machine named.
|
||||
func TestTheConsoleListsAndCallsOverHTTP(t *testing.T) {
|
||||
mesh := mt.New(t)
|
||||
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": "ghost"}}}, nil
|
||||
})
|
||||
defer stopCat()
|
||||
controller := connect(t, "mesh-controller", "")
|
||||
stopSeat, _ := controller.HandleSubject("mesh.seat.mesh-controller.tool.tools", func(json.RawMessage) (any, error) {
|
||||
return map[string]any{"seats": []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{}},
|
||||
}}}}, nil
|
||||
})
|
||||
defer stopSeat()
|
||||
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"
|
||||
|
||||
init := post(t, endpoint, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{}}}`)
|
||||
if !strings.Contains(init["result"].(map[string]any)["instructions"].(string), "reached as desk.node-tools") {
|
||||
t.Errorf("initialize: %v", init)
|
||||
}
|
||||
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) {
|
||||
names = append(names, x.(map[string]any)["name"].(string))
|
||||
}
|
||||
if got := strings.Join(names, ","); got != "alpha.one,alpha.two,beta.five,beta.four,beta.three,node-shelf.clear,node-shelf.list" {
|
||||
t.Errorf("listed %s", got)
|
||||
}
|
||||
if got := listed["_meta"].(map[string]any)["notAnswering"]; len(got.([]any)) != 1 || got.([]any)[0] != "ghost" {
|
||||
t.Errorf("not answering: %v", got)
|
||||
}
|
||||
for _, x := range listed["tools"].([]any) {
|
||||
tool := x.(map[string]any)
|
||||
if tool["name"] == "node-shelf.list" {
|
||||
schema := tool["inputSchema"].(map[string]any)
|
||||
if req, _ := schema["required"].([]any); len(req) != 1 || req[0] != "node" {
|
||||
t.Errorf("a node seat's verb does not require node: %v", schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
called := post(t, endpoint, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"alpha.one","arguments":{"node":"desk"}}}`)
|
||||
content := called["result"].(map[string]any)["content"].([]any)
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal([]byte(content[0].(map[string]any)["text"].(string)), &got)
|
||||
if got["alpha"] != float64(1) || content[1].(map[string]any)["text"] != "answered by desk" {
|
||||
t.Errorf("called: %v", called)
|
||||
}
|
||||
seat := post(t, endpoint, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"node-shelf.list","arguments":{"node":"desk"}}}`)
|
||||
if text := seat["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string); !strings.Contains(text, `"a"`) {
|
||||
t.Errorf("seat verb: %v", seat)
|
||||
}
|
||||
refused := post(t, endpoint, `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"node-shelf.list","arguments":{}}}`)
|
||||
if refused["error"] == nil {
|
||||
t.Errorf("a node seat's verb was called without its machine: %v", refused)
|
||||
}
|
||||
absent := post(t, endpoint, `{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ghost.boo","arguments":{}}}`)
|
||||
result := absent["result"].(map[string]any)
|
||||
if result["isError"] != true || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), "nothing serves ghost.boo") {
|
||||
t.Errorf("an absent tool: %v", absent)
|
||||
}
|
||||
res, err := http.Post(endpoint, "application/json", strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`))
|
||||
if err != nil || res.StatusCode != 202 {
|
||||
t.Errorf("a notification: %v %v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheConsoleListensOnLoopbackAndNowhereElse(t *testing.T) {
|
||||
if _, err := Serve(NewSurface(nil, "x"), "0.0.0.0:0"); err == nil || !strings.Contains(err.Error(), "loopback and nowhere else") {
|
||||
t.Errorf("a non-loopback console was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/wire"
|
||||
)
|
||||
|
||||
// bodyLimit is the most a request body may be.
|
||||
const bodyLimit = 1 << 20
|
||||
|
||||
var loopback = map[string]bool{"127.0.0.1": true, "::1": true, "localhost": true, "[::1]": true}
|
||||
|
||||
// Listening is a console that listens: where, with the port the machine gave, and how to stop it.
|
||||
type Listening struct {
|
||||
Address string
|
||||
Close func() error
|
||||
}
|
||||
|
||||
// Serve listens on host:port, refused unless the host is loopback — said before binding, so a
|
||||
// console that would open to a network is a startup failure (ADR 0152).
|
||||
func Serve(s *Surface, listen string) (*Listening, error) {
|
||||
at := strings.LastIndex(listen, ":")
|
||||
if at < 0 {
|
||||
return nil, fmt.Errorf("%q is not host:port", listen)
|
||||
}
|
||||
host, portText := listen[:at], listen[at+1:]
|
||||
if !loopback[host] {
|
||||
return nil, fmt.Errorf(`the console listens on loopback and nowhere else (novox/hq ADR 0152): %q is not this `+
|
||||
"machine's own address — whoever is on the machine owns the mesh there, and nobody else may reach this", host)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port < 0 || port > 65535 {
|
||||
return nil, fmt.Errorf("%q is not a port", portText)
|
||||
}
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort(strings.Trim(host, "[]"), portText))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { route(w, r, s) })}
|
||||
go func() { _ = server.Serve(ln) }()
|
||||
bound := ln.Addr().(*net.TCPAddr).Port
|
||||
return &Listening{Address: host + ":" + strconv.Itoa(bound), Close: func() error { return server.Shutdown(context.Background()) }}, nil
|
||||
}
|
||||
|
||||
func route(w http.ResponseWriter, r *http.Request, s *Surface) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
w.Header().Set("content-type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(w, "the mesh's console: MCP over HTTP at POST /mcp (novox/hq design 34)\n")
|
||||
return
|
||||
case "/mcp":
|
||||
default:
|
||||
writeJSON(w, 404, map[string]any{"error": "the console serves /mcp and nothing else"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
case http.MethodDelete:
|
||||
w.WriteHeader(204) // no session to end
|
||||
return
|
||||
default:
|
||||
w.Header().Set("allow", "POST, DELETE")
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, bodyLimit+1))
|
||||
if err != nil || len(body) > bodyLimit {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("the request is larger than %d bytes", bodyLimit)
|
||||
}
|
||||
writeJSON(w, 413, map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32600, "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var batch []Request
|
||||
if err := json.Unmarshal(body, &batch); err != nil {
|
||||
writeJSON(w, 400, map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32700, "message": "the body is not JSON"}})
|
||||
return
|
||||
}
|
||||
replies := []*Reply{}
|
||||
for _, req := range batch {
|
||||
if reply := s.Handle(req); reply != nil {
|
||||
replies = append(replies, reply)
|
||||
}
|
||||
}
|
||||
if len(replies) == 0 {
|
||||
w.WriteHeader(202)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, replies)
|
||||
return
|
||||
}
|
||||
var req Request
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeJSON(w, 400, map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32700, "message": "the body is not JSON"}})
|
||||
return
|
||||
}
|
||||
reply := s.Handle(req)
|
||||
if reply == nil {
|
||||
w.WriteHeader(202)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, reply)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
text, err := wire.Marshal(body)
|
||||
if err != nil {
|
||||
text, _ = json.Marshal(map[string]any{"error": errors.New("unencodable answer").Error()})
|
||||
}
|
||||
w.Header().Set("content-type", "application/json; charset=utf-8")
|
||||
w.Header().Set("content-length", strconv.Itoa(len(text)))
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(text)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// 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"
|
||||
"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
|
||||
}
|
||||
|
||||
// NewSurface is the surface over a connection, as `who`.
|
||||
func NewSurface(conn *bus.Conn, who string) *Surface { return &Surface{conn: conn, who: who} }
|
||||
|
||||
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": "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>.",
|
||||
})
|
||||
case "notifications/initialized":
|
||||
return nil
|
||||
case "ping":
|
||||
if notification {
|
||||
return nil
|
||||
}
|
||||
return answer(r.ID, map[string]any{})
|
||||
case "tools/list":
|
||||
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)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user