A launched bundle's mesh/subscribe binds the module's own durable consumer — EVENTS, <node>_<module>, by name as the module's own runtime bound it, so nothing is lost or replayed in the move — and every event goes to each child of the module that subscribed as mesh/event, acknowledged only when all answered, negatively acknowledged after a short delay when one failed or died, terminated when it is not an event. mesh/ask calls a tool as the module. Every launched bundle is started again when it exits, with backoff, since long-running code waits for no call. Requires SDK 0.1.6.
512 lines
14 KiB
Go
512 lines
14 KiB
Go
// Package launch starts a bundle the runtime serves and speaks MCP over stdio to it (novox/hq ADR
|
|
// 0188, ADR 0193): `initialize`, `tools/list` once, `tools/call` per call. A Go binary, a Python
|
|
// script and a Node launcher are the same thing here: an executable that answers those. The runtime
|
|
// knows no language; it starts the path it is given.
|
|
package launch
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/wire"
|
|
)
|
|
|
|
// Protocol is the MCP version spoken to a bundle.
|
|
const Protocol = "2025-03-26"
|
|
|
|
// How long a child has for its handshake, and a call before the caller is told it is slow.
|
|
var (
|
|
HandshakeTimeout = 10 * time.Second
|
|
CallTimeout = 30 * time.Second
|
|
)
|
|
|
|
// Tool is one tool a launched bundle listed, and how to call it.
|
|
type Tool struct {
|
|
Name string
|
|
Description string
|
|
Input json.RawMessage
|
|
Run func(args json.RawMessage) (json.RawMessage, error)
|
|
}
|
|
|
|
// Registration is the tools a bundle listed under one name: its module's, or a seat's.
|
|
type Registration struct {
|
|
Module string
|
|
Tools []Tool
|
|
}
|
|
|
|
// Bus is what a launched bundle reaches the mesh through (novox/hq ADR 0193, ADR 0198): the runtime
|
|
// publishes, asks and subscribes on the module's behalf. Subscribe is asked each time the module's
|
|
// code subscribes; the runtime binds the module's consumer once and calls deliver for every event,
|
|
// acknowledging it on the bus when deliver returns nil.
|
|
type Bus interface {
|
|
Publish(params json.RawMessage) error
|
|
Ask(params json.RawMessage) (json.RawMessage, error)
|
|
Subscribe(deliver func(envelope json.RawMessage) error) error
|
|
}
|
|
|
|
// EventTimeout bounds how long a bundle has to handle one event before it is offered again.
|
|
var EventTimeout = 2 * time.Minute
|
|
|
|
// Restart backoff: a bundle that exits is started again at once, then after growing pauses while it
|
|
// keeps exiting, back to at once once it has run a while.
|
|
var (
|
|
RestartFirst = 500 * time.Millisecond
|
|
RestartMost = 30 * time.Second
|
|
RestartSettle = time.Minute
|
|
)
|
|
|
|
// Launched is a running bundle: what it registered, and how to stop it.
|
|
type Launched struct {
|
|
Registrations []Registration
|
|
Stop func()
|
|
}
|
|
|
|
// Executable says whether an entrypoint can be started: a bundle the runtime serves is executable,
|
|
// and one that is not was not built to be served (ADR 0193).
|
|
func Executable(path string) bool {
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.IsDir() {
|
|
return false
|
|
}
|
|
return info.Mode().Perm()&0o111 != 0
|
|
}
|
|
|
|
type message struct {
|
|
JSONRPC string `json:"jsonrpc,omitempty"`
|
|
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 {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
type child struct {
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
writeMu sync.Mutex
|
|
mu sync.Mutex
|
|
pending map[int64]chan message
|
|
next int64
|
|
dead chan struct{}
|
|
why error
|
|
}
|
|
|
|
func (c *child) write(m any) error {
|
|
b, err := wire.Marshal(m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.writeMu.Lock()
|
|
defer c.writeMu.Unlock()
|
|
_, err = c.stdin.Write(append(b, '\n'))
|
|
return err
|
|
}
|
|
|
|
var stackLine = regexp.MustCompile(`^\s+at\s`)
|
|
|
|
// Start launches one bundle and learns its tools. It fails when the child cannot be started or does
|
|
// not complete the handshake. A child that exits later is started again on its next call.
|
|
func Start(module, entry string, env []string, mesh Bus, logf func(string, ...any)) (*Launched, error) {
|
|
var mu sync.Mutex
|
|
var current *child
|
|
// The child that last subscribed is the one events are handed to: a restarted child subscribes
|
|
// again as its code is imported, and from then on the module's events are its.
|
|
var subscriber *child
|
|
stopped := false
|
|
deliver := func(envelope json.RawMessage) error {
|
|
mu.Lock()
|
|
c := subscriber
|
|
mu.Unlock()
|
|
if c == nil {
|
|
return errors.New(module + "'s bundle is not running to take its events")
|
|
}
|
|
_, err := c.ask(module, "mesh/event", map[string]any{"envelope": envelope}, EventTimeout)
|
|
return err
|
|
}
|
|
var restart func(after time.Duration)
|
|
var startedAt time.Time
|
|
pause := RestartFirst
|
|
|
|
start := func() (*child, error) {
|
|
cmd := exec.Command(entry)
|
|
cmd.Env = env
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, err
|
|
}
|
|
c := &child{cmd: cmd, stdin: stdin, pending: map[int64]chan message{}, next: 1, dead: make(chan struct{})}
|
|
var lastSaid string
|
|
var saidMu sync.Mutex
|
|
stderrDone := make(chan struct{})
|
|
go func() {
|
|
defer close(stderrDone)
|
|
scan := bufio.NewScanner(stderr)
|
|
scan.Buffer(make([]byte, 64*1024), 1<<20)
|
|
for scan.Scan() {
|
|
line := scan.Text()
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
logf("[%s] %s", module, line)
|
|
if !stackLine.MatchString(line) && !strings.HasPrefix(line, "Node.js v") {
|
|
saidMu.Lock()
|
|
lastSaid = strings.TrimSpace(line)
|
|
saidMu.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
scan := bufio.NewScanner(stdout)
|
|
scan.Buffer(make([]byte, 64*1024), 16<<20)
|
|
for scan.Scan() {
|
|
line := strings.TrimSpace(scan.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var m message
|
|
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
|
if len(line) > 120 {
|
|
line = line[:120]
|
|
}
|
|
logf("[mesh-tools] %s's bundle said something that is not a reply: %s", module, line)
|
|
continue
|
|
}
|
|
// The bundle asks the runtime (ADR 0193, ADR 0198): to emit, to call a tool, to hand it the
|
|
// module's events. Each on the module's behalf, answered when done; nothing else.
|
|
if m.Method != "" {
|
|
go func(m message) {
|
|
var result any = map[string]any{}
|
|
var err error
|
|
switch m.Method {
|
|
case "mesh/publish":
|
|
err = mesh.Publish(m.Params)
|
|
case "mesh/ask":
|
|
var asked json.RawMessage
|
|
if asked, err = mesh.Ask(m.Params); err == nil {
|
|
result = asked
|
|
}
|
|
case "mesh/subscribe":
|
|
mu.Lock()
|
|
subscriber = c
|
|
mu.Unlock()
|
|
err = mesh.Subscribe(deliver)
|
|
default:
|
|
if len(m.ID) > 0 {
|
|
_ = c.write(map[string]any{"jsonrpc": "2.0", "id": m.ID,
|
|
"error": map[string]any{"code": -32601, "message": "the runtime answers no " + m.Method + " from a bundle"}})
|
|
}
|
|
return
|
|
}
|
|
if len(m.ID) == 0 {
|
|
return
|
|
}
|
|
if err != nil {
|
|
_ = c.write(map[string]any{"jsonrpc": "2.0", "id": m.ID,
|
|
"error": map[string]any{"code": -32000, "message": err.Error()}})
|
|
return
|
|
}
|
|
_ = c.write(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": result})
|
|
}(m)
|
|
continue
|
|
}
|
|
var id int64
|
|
if json.Unmarshal(m.ID, &id) != nil {
|
|
continue
|
|
}
|
|
c.mu.Lock()
|
|
ch := c.pending[id]
|
|
delete(c.pending, id)
|
|
c.mu.Unlock()
|
|
if ch != nil {
|
|
ch <- m
|
|
}
|
|
}
|
|
<-stderrDone
|
|
err := cmd.Wait()
|
|
code := "0"
|
|
if exit := (*exec.ExitError)(nil); errors.As(err, &exit) {
|
|
if status, ok := exit.Sys().(syscall.WaitStatus); ok && status.Signaled() {
|
|
code = unix.SignalName(status.Signal())
|
|
} else {
|
|
code = fmt.Sprint(exit.ExitCode())
|
|
}
|
|
}
|
|
saidMu.Lock()
|
|
why := fmt.Sprintf("%s's bundle exited (%s)", module, code)
|
|
if lastSaid != "" {
|
|
why += ": " + lastSaid
|
|
}
|
|
saidMu.Unlock()
|
|
c.mu.Lock()
|
|
c.why = errors.New(why)
|
|
c.mu.Unlock()
|
|
close(c.dead)
|
|
mu.Lock()
|
|
// Only a child that was serving is brought back; one that died in its own handshake was
|
|
// never accepted, and whoever started it was told why.
|
|
wasServing := current == c
|
|
if current == c {
|
|
current = nil
|
|
}
|
|
if subscriber == c {
|
|
subscriber = nil
|
|
}
|
|
wasStopped := stopped
|
|
// Started again at once if it ran a while; after a growing pause while it keeps exiting.
|
|
wait := RestartFirst
|
|
if time.Since(startedAt) < RestartSettle {
|
|
wait = pause
|
|
if pause *= 2; pause > RestartMost {
|
|
pause = RestartMost
|
|
}
|
|
} else {
|
|
pause = RestartFirst
|
|
}
|
|
mu.Unlock()
|
|
if !wasStopped && wasServing {
|
|
// Every bundle stays up (ADR 0198): code that runs long — a handler, a provisioner —
|
|
// is not waiting for a call to bring it back, and a tool bundle back early costs nothing.
|
|
logf("[mesh-tools] %s; started again in %s", why, wait.Round(100*time.Millisecond))
|
|
restart(wait)
|
|
}
|
|
}()
|
|
if _, err := c.ask(module, "initialize", map[string]any{"protocolVersion": Protocol, "capabilities": map[string]any{},
|
|
"clientInfo": map[string]any{"name": "node-tools", "version": "1"}}, HandshakeTimeout); err != nil {
|
|
_ = cmd.Process.Kill()
|
|
return nil, err
|
|
}
|
|
_ = c.write(map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
return c, nil
|
|
}
|
|
|
|
asking := func(method string, params any, timeout time.Duration) (json.RawMessage, error) {
|
|
mu.Lock()
|
|
c := current
|
|
mu.Unlock()
|
|
if c == nil {
|
|
fresh, err := start()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mu.Lock()
|
|
if current == nil {
|
|
current = fresh
|
|
startedAt = time.Now()
|
|
} else {
|
|
_ = fresh.cmd.Process.Signal(syscall.SIGTERM)
|
|
fresh = current
|
|
}
|
|
c = fresh
|
|
mu.Unlock()
|
|
}
|
|
return c.ask(module, method, params, timeout)
|
|
}
|
|
|
|
restart = func(after time.Duration) {
|
|
go func() {
|
|
time.Sleep(after)
|
|
mu.Lock()
|
|
if stopped || current != nil {
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
mu.Unlock()
|
|
fresh, err := start()
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if err != nil {
|
|
if !stopped {
|
|
wait := pause
|
|
if pause *= 2; pause > RestartMost {
|
|
pause = RestartMost
|
|
}
|
|
logf("[mesh-tools] %s's bundle did not start again: %v; trying in %s", module, err, wait)
|
|
go restart(wait)
|
|
}
|
|
return
|
|
}
|
|
if stopped {
|
|
_ = fresh.cmd.Process.Signal(syscall.SIGTERM)
|
|
return
|
|
}
|
|
if current == nil {
|
|
current = fresh
|
|
startedAt = time.Now()
|
|
} else {
|
|
_ = fresh.cmd.Process.Signal(syscall.SIGTERM)
|
|
}
|
|
}()
|
|
}
|
|
|
|
first, err := start()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mu.Lock()
|
|
current = first
|
|
startedAt = time.Now()
|
|
mu.Unlock()
|
|
|
|
raw, err := asking("tools/list", map[string]any{}, HandshakeTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var listed struct {
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
InputSchema json.RawMessage `json:"inputSchema"`
|
|
} `json:"tools"`
|
|
}
|
|
if err := json.Unmarshal(raw, &listed); err != nil {
|
|
return nil, fmt.Errorf("%s's bundle listed its tools in a shape that is not MCP's: %w", module, err)
|
|
}
|
|
order := []string{}
|
|
groups := map[string][]Tool{}
|
|
for _, t := range listed.Tools {
|
|
under, name := module, t.Name
|
|
if dot := strings.Index(t.Name, "."); dot >= 0 {
|
|
under, name = t.Name[:dot], t.Name[dot+1:]
|
|
}
|
|
full := t.Name
|
|
input := t.InputSchema
|
|
if len(input) == 0 || string(input) == "null" {
|
|
input = json.RawMessage("{}")
|
|
}
|
|
tool := Tool{Name: name, Description: t.Description, Input: input,
|
|
Run: func(args json.RawMessage) (json.RawMessage, error) {
|
|
if len(args) == 0 || string(args) == "null" {
|
|
args = json.RawMessage("{}")
|
|
}
|
|
res, err := asking("tools/call", map[string]any{"name": full, "arguments": args}, CallTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var called struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
IsError bool `json:"isError"`
|
|
}
|
|
_ = json.Unmarshal(res, &called)
|
|
text := ""
|
|
for _, c := range called.Content {
|
|
if c.Type == "text" {
|
|
text = c.Text
|
|
break
|
|
}
|
|
}
|
|
if called.IsError {
|
|
if text == "" {
|
|
text = module + "." + full + " failed"
|
|
}
|
|
return nil, errors.New(text)
|
|
}
|
|
// The bundle's answer is JSON as text; handed back as the value it encodes.
|
|
if json.Valid([]byte(text)) && text != "" {
|
|
return json.RawMessage(text), nil
|
|
}
|
|
b, _ := json.Marshal(text)
|
|
return b, nil
|
|
}}
|
|
if _, seen := groups[under]; !seen {
|
|
order = append(order, under)
|
|
}
|
|
groups[under] = append(groups[under], tool)
|
|
}
|
|
out := &Launched{Stop: func() {
|
|
mu.Lock()
|
|
stopped = true
|
|
c := current
|
|
current = nil
|
|
mu.Unlock()
|
|
if c != nil && c.cmd.Process != nil {
|
|
_ = c.cmd.Process.Signal(syscall.SIGTERM)
|
|
}
|
|
}}
|
|
for _, under := range order {
|
|
out.Registrations = append(out.Registrations, Registration{Module: under, Tools: groups[under]})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *child) ask(module, method string, params any, timeout time.Duration) (json.RawMessage, error) {
|
|
c.mu.Lock()
|
|
if c.why != nil {
|
|
c.mu.Unlock()
|
|
return nil, c.why
|
|
}
|
|
id := c.next
|
|
c.next++
|
|
ch := make(chan message, 1)
|
|
c.pending[id] = ch
|
|
c.mu.Unlock()
|
|
if err := c.write(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil {
|
|
c.mu.Lock()
|
|
delete(c.pending, id)
|
|
c.mu.Unlock()
|
|
select {
|
|
case <-c.dead:
|
|
return nil, c.deathReason()
|
|
case <-time.After(100 * time.Millisecond):
|
|
return nil, err
|
|
}
|
|
}
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case m := <-ch:
|
|
if m.Error != nil {
|
|
msg := m.Error.Message
|
|
if msg == "" {
|
|
msg = "the bundle refused the request"
|
|
}
|
|
return nil, errors.New(msg)
|
|
}
|
|
return m.Result, nil
|
|
case <-c.dead:
|
|
return nil, c.deathReason()
|
|
case <-timer.C:
|
|
c.mu.Lock()
|
|
delete(c.pending, id)
|
|
c.mu.Unlock()
|
|
return nil, fmt.Errorf("%s's bundle did not answer %s in %ds", module, method, int(timeout/time.Second))
|
|
}
|
|
}
|
|
|
|
func (c *child) deathReason() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.why != nil {
|
|
return c.why
|
|
}
|
|
return errors.New("the bundle exited")
|
|
}
|