A handler takes an event by returning and asks for it again by throwing; any mesh/event answer but an error is taken. Written down in the README where event handling is, with what follows for a handler: throw only when the work was not done, and be safe to run twice. Both SDKs added the handler before asking the runtime and kept it when the runtime refused, so a module that asks again until it is taken (every Go consumer does) ran its handler once per attempt for every event. A refused subscribe now removes its handler; one the Go runtime did not answer in time is kept, since the runtime may have bound it.
420 lines
14 KiB
Go
420 lines
14 KiB
Go
// Package stdio is the Novox Mesh SDK for Go (novox/hq ADR 0188, ADR 0193).
|
|
//
|
|
// A tools bundle is a process the node's runtime launches and speaks MCP over stdio to:
|
|
// `initialize`, `tools/list` once, `tools/call` per call, newline-framed JSON-RPC on stdin and
|
|
// stdout. This package is the loop, so a bundle writes its tools and nothing else. A tool named
|
|
// `<seat>.<verb>` is the module's implementation of that seat's verb; any other name is the
|
|
// module's own.
|
|
//
|
|
// No transport here: the bus, the subjects and the memberships are the runtime's (ADR 0039, 0175).
|
|
// What a bundle asks of the bus — emitting an event — it asks of the runtime over the same channel,
|
|
// and the runtime does it as the module (ADR 0193). stdout is the protocol; log to stderr.
|
|
package stdio
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Protocol is the MCP version this speaks; the runtime says the same.
|
|
const Protocol = "2025-03-26"
|
|
|
|
// ServedAs is the word the runtime sets for a bundle it launches: the module it serves it as
|
|
// (ADR 0193). A bundle served under that name lists its own tools by their names.
|
|
const ServedAs = "MESH_SERVED_MODULE"
|
|
|
|
// Tool is one tool a bundle serves.
|
|
type Tool struct {
|
|
Name string
|
|
Description string
|
|
// Input is the JSON schema of the arguments; nil is a tool that takes nothing.
|
|
Input map[string]any
|
|
Run func(args map[string]any) (any, error)
|
|
}
|
|
|
|
type message struct {
|
|
ID *json.RawMessage `json:"id,omitempty"`
|
|
Method string `json:"method,omitempty"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
// channel is the one conversation with the runtime: what this bundle writes, serialised, and what
|
|
// it asked and waits for.
|
|
type channel struct {
|
|
mu sync.Mutex
|
|
enc *json.Encoder
|
|
next int
|
|
waiting map[string]chan message
|
|
listeners []listener
|
|
// subscribed numbers the listeners, so one whose subscribe failed is the one removed.
|
|
subscribed int
|
|
// watches are this bundle's state watches by the id each was asked with (ADR 0201).
|
|
watches map[string]func(StateChange) error
|
|
}
|
|
|
|
type listener struct {
|
|
n int
|
|
pattern string
|
|
handle func(Envelope) error
|
|
}
|
|
|
|
// Envelope is one event as the mesh carries it: its type, the machine it came from, its body as JSON,
|
|
// and the ADR 0042 headers (x-event-id, x-source, x-node, x-time …).
|
|
type Envelope struct {
|
|
Key string `json:"key"`
|
|
Node string `json:"node"`
|
|
Body json.RawMessage `json:"body"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
}
|
|
|
|
func (c *channel) say(m map[string]any) {
|
|
m["jsonrpc"] = "2.0"
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
_ = c.enc.Encode(m)
|
|
}
|
|
|
|
// ask sends the runtime a request and waits for its answer.
|
|
func (c *channel) ask(method string, params any, timeout time.Duration) (json.RawMessage, error) {
|
|
return c.askAs(c.newID(), method, params, timeout)
|
|
}
|
|
|
|
func (c *channel) newID() string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.next++
|
|
return "bundle-" + strconv.Itoa(c.next)
|
|
}
|
|
|
|
// askAs is ask under an id the caller chose, for a request whose id something is registered under
|
|
// before it is sent.
|
|
func (c *channel) askAs(id, method string, params any, timeout time.Duration) (json.RawMessage, error) {
|
|
c.mu.Lock()
|
|
answer := make(chan message, 1)
|
|
c.waiting[id] = answer
|
|
c.mu.Unlock()
|
|
c.say(map[string]any{"id": id, "method": method, "params": params})
|
|
select {
|
|
case m := <-answer:
|
|
if m.Error != nil {
|
|
return nil, &refused{m.Error.Message}
|
|
}
|
|
return m.Result, nil
|
|
case <-time.After(timeout):
|
|
c.mu.Lock()
|
|
delete(c.waiting, id)
|
|
c.mu.Unlock()
|
|
return nil, fmt.Errorf("the runtime did not answer %s in %s", method, timeout)
|
|
}
|
|
}
|
|
|
|
// refused is the runtime's error answer, as opposed to no answer: what it refused, it did not do.
|
|
type refused struct{ message string }
|
|
|
|
func (r *refused) Error() string { return r.message }
|
|
|
|
var (
|
|
current *channel
|
|
currentMu sync.Mutex
|
|
)
|
|
|
|
// Serve answers the runtime on stdin and stdout until stdin closes. An empty name serves as the
|
|
// module the runtime named in MESH_SERVED_MODULE.
|
|
func Serve(name string, tools []Tool) error {
|
|
return ServeOn(name, tools, os.Stdin, os.Stdout)
|
|
}
|
|
|
|
// ServeOn is Serve over any pair of streams, for a test.
|
|
func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
|
|
if name == "" {
|
|
name = os.Getenv(ServedAs)
|
|
}
|
|
if name == "" {
|
|
name = "bundle"
|
|
}
|
|
byName := map[string]Tool{}
|
|
for _, t := range tools {
|
|
byName[t.Name] = t
|
|
}
|
|
c := &channel{enc: json.NewEncoder(out), waiting: map[string]chan message{}, watches: map[string]func(StateChange) error{}}
|
|
currentMu.Lock()
|
|
current = c
|
|
currentMu.Unlock()
|
|
scanner := bufio.NewScanner(in)
|
|
scanner.Buffer(make([]byte, 1<<20), 1<<24)
|
|
for scanner.Scan() {
|
|
var m message
|
|
if err := json.Unmarshal(scanner.Bytes(), &m); err != nil {
|
|
continue
|
|
}
|
|
// The runtime's answer to something this bundle asked.
|
|
if m.Method == "" && m.ID != nil {
|
|
var key string
|
|
if json.Unmarshal(*m.ID, &key) == nil {
|
|
c.mu.Lock()
|
|
answer, ok := c.waiting[key]
|
|
delete(c.waiting, key)
|
|
c.mu.Unlock()
|
|
if ok {
|
|
answer <- m
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
id := any(nil)
|
|
if m.ID != nil {
|
|
id = *m.ID
|
|
}
|
|
switch m.Method {
|
|
case "initialize":
|
|
c.say(map[string]any{"id": id, "result": map[string]any{"protocolVersion": Protocol,
|
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
|
"serverInfo": map[string]any{"name": name, "version": "1"}}})
|
|
case "notifications/initialized":
|
|
case "ping":
|
|
if id != nil {
|
|
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
|
}
|
|
case "tools/list":
|
|
listed := make([]map[string]any, 0, len(tools))
|
|
for _, t := range tools {
|
|
schema := t.Input
|
|
if schema == nil {
|
|
schema = map[string]any{"type": "object", "properties": map[string]any{}}
|
|
} else if _, isSchema := schema["type"]; !isSchema {
|
|
schema = map[string]any{"type": "object", "properties": schema}
|
|
}
|
|
listed = append(listed, map[string]any{"name": t.Name, "description": t.Description, "inputSchema": schema})
|
|
}
|
|
c.say(map[string]any{"id": id, "result": map[string]any{"tools": listed}})
|
|
case "tools/call":
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments map[string]any `json:"arguments"`
|
|
}
|
|
_ = json.Unmarshal(m.Params, &p)
|
|
t, ok := byName[p.Name]
|
|
if !ok {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": fmt.Sprintf("%s has no tool %s", name, p.Name)}})
|
|
continue
|
|
}
|
|
// Beside the loop, never in it: a tool that emits waits for the runtime's answer, which
|
|
// arrives on the line this loop reads next — and calls do not queue behind a slow one.
|
|
go func(id any, t Tool, args map[string]any) {
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
result, err := t.Run(args)
|
|
if err != nil {
|
|
c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": err.Error()}}, "isError": true}})
|
|
return
|
|
}
|
|
text, _ := json.Marshal(result)
|
|
c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": string(text)}}}})
|
|
}(id, t, p.Arguments)
|
|
case "mesh/event":
|
|
// One event the runtime read from this module's consumer (ADR 0198), answered once every
|
|
// handler it is for has taken it; an error is answered as one, so the runtime does not
|
|
// acknowledge it and the bus delivers it again. Beside the loop, like a tool call.
|
|
var p struct {
|
|
Envelope *Envelope `json:"envelope"`
|
|
}
|
|
if json.Unmarshal(m.Params, &p) != nil || p.Envelope == nil || p.Envelope.Key == "" {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/event carries no envelope"}})
|
|
continue
|
|
}
|
|
c.mu.Lock()
|
|
matching := []listener{}
|
|
for _, l := range c.listeners {
|
|
if TopicMatches(l.pattern, p.Envelope.Key) {
|
|
matching = append(matching, l)
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
go func(id any, env Envelope, ls []listener) {
|
|
for _, l := range ls {
|
|
if err := l.handle(env); err != nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
|
return
|
|
}
|
|
}
|
|
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
|
}(id, *p.Envelope, matching)
|
|
case "mesh/state":
|
|
// One change to a state this bundle watches (ADR 0201), named by the watch's id, answered
|
|
// once its handler has taken it. Beside the loop, like a tool call.
|
|
var p struct {
|
|
Watch string `json:"watch"`
|
|
Change *StateChange `json:"change"`
|
|
}
|
|
_ = json.Unmarshal(m.Params, &p)
|
|
c.mu.Lock()
|
|
handle := c.watches[p.Watch]
|
|
c.mu.Unlock()
|
|
if handle == nil || p.Change == nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/state names no watch of this bundle"}})
|
|
continue
|
|
}
|
|
go func(id any, change StateChange) {
|
|
if err := handle(change); err != nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
|
return
|
|
}
|
|
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
|
}(id, *p.Change)
|
|
default:
|
|
if id != nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32601, "message": "no " + m.Method}})
|
|
}
|
|
}
|
|
}
|
|
return scanner.Err()
|
|
}
|
|
|
|
func live() (*channel, error) {
|
|
currentMu.Lock()
|
|
defer currentMu.Unlock()
|
|
if current == nil {
|
|
return nil, errors.New("before Serve: a bundle reaches the bus through the runtime that launched it")
|
|
}
|
|
return current, nil
|
|
}
|
|
|
|
// Subscribe asks the runtime for this module's events whose type matches the pattern (`*` one
|
|
// segment, `#` any), and hands each to handle (ADR 0198).
|
|
//
|
|
// **Taking an event** (see the repository's README): handle returning nil takes the event — the
|
|
// runtime acknowledges it once every matching handler did; an error is the one way to have it offered
|
|
// again, after a few seconds and a bounded number of times, after which the bus gives up and the
|
|
// controller raises a condition. So return an error only when the work was not done, never for an
|
|
// answer that went right but read oddly; and since delivery is at-least-once, make the work safe to
|
|
// do twice, or skip an event whose x-event-id was already done.
|
|
//
|
|
// A subscribe the runtime refused leaves no handler behind, so asking again — as a module does until
|
|
// the runtime takes it — does not hand one event to the same handler twice. (One the runtime did not
|
|
// answer in time is kept, as the runtime may have bound it.) Call it from a goroutine
|
|
// started before Serve, or from a tool, once Serve runs.
|
|
func Subscribe(pattern string, handle func(Envelope) error) error {
|
|
c, err := live()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.mu.Lock()
|
|
c.subscribed++
|
|
n := c.subscribed
|
|
c.listeners = append(c.listeners, listener{n: n, pattern: pattern, handle: handle})
|
|
c.mu.Unlock()
|
|
_, err = c.ask("mesh/subscribe", map[string]any{"pattern": pattern}, 30*time.Second)
|
|
// Removed only when the runtime answered that it refused: one that did not answer in time may
|
|
// have bound the consumer, and a handler removed then would have its events taken unhandled.
|
|
var no *refused
|
|
if errors.As(err, &no) {
|
|
c.mu.Lock()
|
|
for i, l := range c.listeners {
|
|
if l.n == n {
|
|
c.listeners = append(c.listeners[:i:i], c.listeners[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Ask calls a tool on the mesh as this bundle's module, through the runtime (ADR 0198), and returns
|
|
// its result as JSON. The key is `<module>.<tool>` as the runtime calls it.
|
|
func Ask(key string, body any) (json.RawMessage, error) {
|
|
c, err := live()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.ask("mesh/ask", map[string]any{"key": key, "body": body}, 60*time.Second)
|
|
}
|
|
|
|
// TopicMatches says whether an event's type matches a pattern: `*` is one dot-separated segment, `#`
|
|
// (or `**`) any number, as the TypeScript SDK matches.
|
|
func TopicMatches(pattern, key string) bool {
|
|
return matchFrom(strings.Split(pattern, "."), 0, strings.Split(key, "."), 0)
|
|
}
|
|
|
|
func matchFrom(p []string, pi int, k []string, ki int) bool {
|
|
if pi == len(p) {
|
|
return ki == len(k)
|
|
}
|
|
if p[pi] == "#" || p[pi] == "**" {
|
|
for skip := ki; skip <= len(k); skip++ {
|
|
if matchFrom(p, pi+1, k, skip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if ki == len(k) || (p[pi] != "*" && p[pi] != k[ki]) {
|
|
return false
|
|
}
|
|
return matchFrom(p, pi+1, k, ki+1)
|
|
}
|
|
|
|
// EmitOptions is the provenance an emitter may attach (ADR 0042).
|
|
type EmitOptions struct {
|
|
CausationID string
|
|
Schema string
|
|
}
|
|
|
|
// Emit asks the runtime to publish an event as this bundle's module, and returns once the bus has
|
|
// accepted it (ADR 0193). The envelope and headers are the TypeScript SDK's exactly: the source and
|
|
// node come from MESH_MODULE and MESH_NODE, which the runtime sets.
|
|
func Emit(eventType string, body any, opts ...EmitOptions) error {
|
|
currentMu.Lock()
|
|
c := current
|
|
currentMu.Unlock()
|
|
if c == nil {
|
|
return errors.New("emit before Serve: a bundle emits through the runtime that launched it")
|
|
}
|
|
source, node := os.Getenv("MESH_MODULE"), os.Getenv("MESH_NODE")
|
|
if source == "" {
|
|
source = "unknown"
|
|
}
|
|
if node == "" {
|
|
node = "unknown"
|
|
}
|
|
headers := map[string]string{
|
|
"x-event-id": eventID(), "x-source": source, "x-node": node,
|
|
"x-time": time.Now().UTC().Format(time.RFC3339Nano), "content-type": "application/json",
|
|
}
|
|
for _, o := range opts {
|
|
if o.CausationID != "" {
|
|
headers["x-causation-id"] = o.CausationID
|
|
}
|
|
if o.Schema != "" {
|
|
headers["x-schema"] = o.Schema
|
|
}
|
|
}
|
|
_, err := c.ask("mesh/publish", map[string]any{"key": eventType, "node": node, "body": body, "headers": headers}, 30*time.Second)
|
|
return err
|
|
}
|
|
|
|
// eventID is a random UUID, version 4, as the TypeScript SDK's randomUUID.
|
|
func eventID() string {
|
|
var b [16]byte
|
|
_, _ = rand.Read(b[:])
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
h := hex.EncodeToString(b[:])
|
|
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
|
|
}
|