Files
mesh-sdk/go/stdio.go
T
jochen b95448be60 The Go SDK serves as the module the runtime names, answers calls concurrently, and emits through the runtime (hq ADR 0193)
What the TypeScript SDK's stdio loop does, in Go: an empty name serves as MESH_SERVED_MODULE; each
call runs beside the loop so a tool waiting on the runtime does not deadlock it; Emit asks the
runtime (mesh/publish) with the same envelope and headers. README says how to install it and how
releases are tagged.
2026-10-03 22:24:16 +02:00

249 lines
7.7 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"
"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
}
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) {
c.mu.Lock()
c.next++
id := "bundle-" + strconv.Itoa(c.next)
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, errors.New(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)
}
}
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{}}
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)
default:
if id != nil {
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32601, "message": "no " + m.Method}})
}
}
}
return scanner.Err()
}
// 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]
}