From 78817c949eaf997280a5c587a637a0538721896c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 20:34:19 +0200 Subject: [PATCH] A tools bundle speaks MCP over stdio: the loop in TypeScript, and skeleton SDKs for Python, Go, Rust and C (hq ADR 0187) The node's runtime launches a tools bundle as a process and speaks MCP over stdio to it, so a tool may be written in any language and the transport stays in the runtime (ADR 0039's refusal, kept). Each SDK is the loop and the tool type and nothing else: `@novox/mesh-sdk/stdio` (serveStdio, and serveRegisteredOverStdio for a bundle that already registers the in-process way), python/, go/, rust/ and c/ beside it, each with an example answering `greet` and the seat verb `node-lamp.on`. Skeletons, by the operator's direction: the bare minimum one bundle per language needs to be built and answer, proven here by each example answering tools/list and tools/call on stdio. --- c/.gitignore | 1 + c/Makefile | 8 ++ c/README.md | 9 ++ c/example.c | 27 ++++ c/mesh_sdk.c | 83 ++++++++++++ c/mesh_sdk.h | 29 +++++ go/README.md | 8 ++ go/example/main.go | 29 +++++ go/go.mod | 3 + go/stdio.go | 108 ++++++++++++++++ package.json | 5 +- python/README.md | 8 ++ python/example.py | 13 ++ python/mesh_sdk/__init__.py | 77 ++++++++++++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 5871 bytes python/pyproject.toml | 9 ++ rust/.gitignore | 2 + rust/Cargo.toml | 8 ++ rust/README.md | 8 ++ rust/examples/greet.rs | 19 +++ rust/src/lib.rs | 80 ++++++++++++ src/stdio/index.ts | 118 ++++++++++++++++++ test/fixtures/stdio-bundle.mjs | 8 ++ test/stdio.test.ts | 41 ++++++ 24 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 c/.gitignore create mode 100644 c/Makefile create mode 100644 c/README.md create mode 100644 c/example.c create mode 100644 c/mesh_sdk.c create mode 100644 c/mesh_sdk.h create mode 100644 go/README.md create mode 100644 go/example/main.go create mode 100644 go/go.mod create mode 100644 go/stdio.go create mode 100644 python/README.md create mode 100755 python/example.py create mode 100644 python/mesh_sdk/__init__.py create mode 100644 python/mesh_sdk/__pycache__/__init__.cpython-314.pyc create mode 100644 python/pyproject.toml create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.toml create mode 100644 rust/README.md create mode 100644 rust/examples/greet.rs create mode 100644 rust/src/lib.rs create mode 100644 src/stdio/index.ts create mode 100644 test/fixtures/stdio-bundle.mjs create mode 100644 test/stdio.test.ts diff --git a/c/.gitignore b/c/.gitignore new file mode 100644 index 0000000..33a9488 --- /dev/null +++ b/c/.gitignore @@ -0,0 +1 @@ +example diff --git a/c/Makefile b/c/Makefile new file mode 100644 index 0000000..acd63f1 --- /dev/null +++ b/c/Makefile @@ -0,0 +1,8 @@ +CFLAGS ?= -O2 -Wall -Wextra +JANSSON := $(shell pkg-config --cflags --libs jansson) + +example: example.c mesh_sdk.c mesh_sdk.h + $(CC) $(CFLAGS) -o $@ example.c mesh_sdk.c $(JANSSON) + +clean: + rm -f example diff --git a/c/README.md b/c/README.md new file mode 100644 index 0000000..015785b --- /dev/null +++ b/c/README.md @@ -0,0 +1,9 @@ +# mesh-sdk for C + +The skeleton (novox/hq ADR 0187): `mesh_serve(name, tools, count)` speaks MCP over stdio to the +node's tool runtime. A `mesh_tool` is a name, a description, an argument schema and a function +returning JSON. A tool named `.` is the seat's implementation. JSON is jansson's, the +one dependency. + +What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no +client, nothing of any module's. `make` and pipe a `tools/list` request into `./example`. diff --git a/c/example.c b/c/example.c new file mode 100644 index 0000000..49a5773 --- /dev/null +++ b/c/example.c @@ -0,0 +1,27 @@ +/* A tools bundle in C: one tool of its own and one seat verb, served over stdio. */ +#include "mesh_sdk.h" +#include + +static json_t *greet(json_t *args, char **error) { + (void)error; + const char *who = json_string_value(json_object_get(args, "who")); + char greeting[128]; + snprintf(greeting, sizeof greeting, "hello %s", who ? who : "world"); + return json_pack("{s:s,s:s}", "greeting", greeting, "language", "c"); +} + +static json_t *on(json_t *args, char **error) { + (void)args; (void)error; + return json_pack("{s:b,s:s}", "on", 1, "language", "c"); +} + +int main(void) { + json_t *who = json_pack("{s:{s:s,s:s}}", "who", "type", "string", "description", "whom"); + const mesh_tool tools[] = { + {"greet", "say hello", who, greet}, + {"node-lamp.on", "the seat's verb", NULL, on}, + }; + int rc = mesh_serve("c-example", tools, 2); + json_decref(who); + return rc; +} diff --git a/c/mesh_sdk.c b/c/mesh_sdk.c new file mode 100644 index 0000000..7f3e000 --- /dev/null +++ b/c/mesh_sdk.c @@ -0,0 +1,83 @@ +#include "mesh_sdk.h" +#include +#include +#include + +static void say(json_t *m) { + json_object_set_new(m, "jsonrpc", json_string("2.0")); + char *text = json_dumps(m, JSON_COMPACT); + fprintf(stdout, "%s\n", text); + fflush(stdout); + free(text); + json_decref(m); +} + +static json_t *as_schema(json_t *given) { + if (!given || !json_is_object(given) || json_object_size(given) == 0) + return json_pack("{s:s,s:{}}", "type", "object", "properties"); + if (json_object_get(given, "type") || json_object_get(given, "properties")) + return json_incref(given); + return json_pack("{s:s,s:O}", "type", "object", "properties", given); +} + +int mesh_serve(const char *name, const mesh_tool *tools, size_t count) { + char *line = NULL; + size_t cap = 0; + ssize_t got; + while ((got = getline(&line, &cap, stdin)) >= 0) { + json_error_t err; + json_t *req = json_loads(line, 0, &err); + if (!req) continue; + json_t *id = json_object_get(req, "id"); + const char *method = json_string_value(json_object_get(req, "method")); + json_t *params = json_object_get(req, "params"); + if (!method) { json_decref(req); continue; } + if (strcmp(method, "initialize") == 0) { + say(json_pack("{s:O,s:{s:s,s:{s:{}},s:{s:s,s:s}}}", "id", id ? id : json_null(), "result", + "protocolVersion", MESH_SDK_PROTOCOL, "capabilities", "tools", + "serverInfo", "name", name, "version", "1")); + } else if (strcmp(method, "notifications/initialized") == 0) { + } else if (strcmp(method, "ping") == 0) { + if (id) say(json_pack("{s:O,s:{}}", "id", id, "result")); + } else if (strcmp(method, "tools/list") == 0) { + json_t *listed = json_array(); + for (size_t i = 0; i < count; i++) + json_array_append_new(listed, json_pack("{s:s,s:s,s:o}", "name", tools[i].name, + "description", tools[i].description, "inputSchema", as_schema(tools[i].input))); + say(json_pack("{s:O,s:{s:o}}", "id", id ? id : json_null(), "result", "tools", listed)); + } else if (strcmp(method, "tools/call") == 0) { + const char *wanted = params ? json_string_value(json_object_get(params, "name")) : NULL; + const mesh_tool *tool = NULL; + for (size_t i = 0; wanted && i < count; i++) + if (strcmp(tools[i].name, wanted) == 0) tool = &tools[i]; + if (!tool) { + char message[256]; + snprintf(message, sizeof message, "%s has no tool %s", name, wanted ? wanted : ""); + say(json_pack("{s:O,s:{s:i,s:s}}", "id", id ? id : json_null(), "error", "code", -32602, "message", message)); + } else { + json_t *args = params ? json_object_get(params, "arguments") : NULL; + json_t *empty = args ? NULL : json_object(); + char *error = NULL; + json_t *result = tool->run(args ? args : empty, &error); + if (empty) json_decref(empty); + if (result) { + char *text = json_dumps(result, JSON_COMPACT); + say(json_pack("{s:O,s:{s:[{s:s,s:s}]}}", "id", id ? id : json_null(), "result", "content", "type", "text", "text", text)); + free(text); + json_decref(result); + } else { + say(json_pack("{s:O,s:{s:[{s:s,s:s}],s:b}}", "id", id ? id : json_null(), "result", "content", + "type", "text", "text", error ? error : "the tool failed", "isError", 1)); + free(error); + } + } + } else if (id) { + char message[256]; + snprintf(message, sizeof message, "no %s", method); + say(json_pack("{s:O,s:{s:i,s:s}}", "id", id, "error", "code", -32601, "message", message)); + } + json_decref(req); + } + free(line); + return ferror(stdin) ? -1 : 0; +} diff --git a/c/mesh_sdk.h b/c/mesh_sdk.h new file mode 100644 index 0000000..52fd103 --- /dev/null +++ b/c/mesh_sdk.h @@ -0,0 +1,29 @@ +/* The Novox Mesh SDK for C — the skeleton (novox/hq ADR 0187). + * + * 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 is the loop, so a bundle writes its tools and nothing else. A tool named + * `.` is the module's implementation of that seat's verb; any other name is the + * module's own. JSON is jansson's. + * + * No transport here: the bus, the subjects and the memberships are the runtime's (ADR 0039, 0175). + * stdout is the protocol; log to stderr. */ +#ifndef MESH_SDK_H +#define MESH_SDK_H +#include + +#define MESH_SDK_PROTOCOL "2025-03-26" + +/* One tool. `run` returns a new JSON value (the answer) or NULL with *error set to a message the + * caller frees. `input` is the schema of the arguments, or NULL for a tool that takes nothing. */ +typedef struct { + const char *name; + const char *description; + json_t *input; + json_t *(*run)(json_t *args, char **error); +} mesh_tool; + +/* Serve these tools on stdin/stdout until stdin closes. Returns 0, or -1 on an I/O fault. */ +int mesh_serve(const char *name, const mesh_tool *tools, size_t count); + +#endif diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..3c608b3 --- /dev/null +++ b/go/README.md @@ -0,0 +1,8 @@ +# mesh-sdk for Go + +The skeleton (novox/hq ADR 0187): `stdio.Serve(name, tools)` speaks MCP over stdio to the node's +tool runtime. A `Tool` is a name, a description, an argument schema and a function. A tool named +`.` is the seat's implementation. Standard library only. + +What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no +client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. diff --git a/go/example/main.go b/go/example/main.go new file mode 100644 index 0000000..283854e --- /dev/null +++ b/go/example/main.go @@ -0,0 +1,29 @@ +// A tools bundle in Go: one tool of its own and one seat verb, served over stdio. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + err := stdio.Serve("go-example", []stdio.Tool{ + {Name: "greet", Description: "say hello", Input: map[string]any{"who": map[string]any{"type": "string", "description": "whom"}}, + Run: func(a map[string]any) (any, error) { + who, _ := a["who"].(string) + if who == "" { + who = "world" + } + return map[string]any{"greeting": "hello " + who, "language": "go"}, nil + }}, + {Name: "node-lamp.on", Description: "the seat's verb", Run: func(map[string]any) (any, error) { + return map[string]any{"on": true, "language": "go"}, nil + }}, + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..f900ceb --- /dev/null +++ b/go/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-sdk/go + +go 1.22 diff --git a/go/stdio.go b/go/stdio.go new file mode 100644 index 0000000..942ec3b --- /dev/null +++ b/go/stdio.go @@ -0,0 +1,108 @@ +// Package stdio is the Novox Mesh SDK for Go — the skeleton (novox/hq ADR 0187). +// +// 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 +// `.` 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). +// stdout is the protocol; log to stderr. +package stdio + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" +) + +// Protocol is the MCP version this speaks; the runtime says the same. +const Protocol = "2025-03-26" + +// 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 request struct { + ID *json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` +} + +// Serve answers the runtime on stdin and stdout until stdin closes. +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 { + byName := map[string]Tool{} + for _, t := range tools { + byName[t.Name] = t + } + enc := json.NewEncoder(out) + say := func(m map[string]any) { m["jsonrpc"] = "2.0"; _ = enc.Encode(m) } + scanner := bufio.NewScanner(in) + scanner.Buffer(make([]byte, 1<<20), 1<<24) + for scanner.Scan() { + var req request + if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { + continue + } + id := any(nil) + if req.ID != nil { + id = *req.ID + } + switch req.Method { + case "initialize": + 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 { + 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}) + } + say(map[string]any{"id": id, "result": map[string]any{"tools": listed}}) + case "tools/call": + t, ok := byName[req.Params.Name] + if !ok { + say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": fmt.Sprintf("%s has no tool %s", name, req.Params.Name)}}) + continue + } + result, err := t.Run(req.Params.Arguments) + if err != nil { + say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": err.Error()}}, "isError": true}}) + continue + } + text, _ := json.Marshal(result) + say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": string(text)}}}}) + default: + if id != nil { + say(map[string]any{"id": id, "error": map[string]any{"code": -32601, "message": "no " + req.Method}}) + } + } + } + return scanner.Err() +} diff --git a/package.json b/package.json index b1461f4..3ca13b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@novox/mesh-sdk", - "version": "0.1.1", + "version": "0.1.2", "description": "The stable spine a Novox Mesh module's own code builds against.", "type": "module", "exports": { @@ -10,7 +10,8 @@ "./tools": "./dist/tools/index.js", "./messaging": "./dist/messaging/index.js", "./events": "./dist/events/index.js", - "./primitives": "./dist/primitives/index.js" + "./primitives": "./dist/primitives/index.js", + "./stdio": "./dist/stdio/index.js" }, "files": [ "dist" diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..657c740 --- /dev/null +++ b/python/README.md @@ -0,0 +1,8 @@ +# mesh-sdk for Python + +The skeleton (novox/hq ADR 0187): `mesh_sdk.serve(name, tools)` speaks MCP over stdio to the node's +tool runtime. A `Tool` is a name, a description, an argument schema and a function. A tool named +`.` is the seat's implementation. No dependencies beyond the standard library. + +What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no +client, nothing of any module's. Run `./example.py` and type a `tools/list` request to see it answer. diff --git a/python/example.py b/python/example.py new file mode 100755 index 0000000..ab483c3 --- /dev/null +++ b/python/example.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# A tools bundle in Python: one tool of its own and one seat verb, served over stdio. +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from mesh_sdk import Tool, serve # noqa: E402 + +serve("python-example", [ + Tool("greet", "say hello", lambda a: {"greeting": f"hello {a.get('who', 'world')}", "language": "python"}, + {"who": {"type": "string", "description": "whom"}}), + Tool("node-lamp.on", "the seat's verb", lambda a: {"on": True, "language": "python"}), +]) diff --git a/python/mesh_sdk/__init__.py b/python/mesh_sdk/__init__.py new file mode 100644 index 0000000..8484937 --- /dev/null +++ b/python/mesh_sdk/__init__.py @@ -0,0 +1,77 @@ +"""The Novox Mesh SDK for Python — the skeleton (novox/hq ADR 0187). + +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 module is +the loop, so a bundle writes its tools and nothing else. A tool named `.` 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). +stdout is the protocol; log to stderr. +""" +import json +import sys +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List + +PROTOCOL = "2025-03-26" + + +@dataclass +class Tool: + name: str + description: str + run: Callable[[Dict[str, Any]], Any] + # The JSON schema of the arguments, or the bare property map the catalogue's modules write. + input: Dict[str, Any] = field(default_factory=dict) + + +def as_schema(given: Dict[str, Any]) -> Dict[str, Any]: + if not given: + return {"type": "object", "properties": {}} + if given.get("type") == "object" or "properties" in given: + return given + return {"type": "object", "properties": given} + + +def serve(name: str, tools: List[Tool]) -> None: + """Serve these tools to the runtime over stdio until stdin closes.""" + by_name = {t.name: t for t in tools} + out = sys.stdout + + def say(message: Dict[str, Any]) -> None: + out.write(json.dumps(message) + "\n") + out.flush() + + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + request = json.loads(line) + except ValueError: + continue + rid = request.get("id") + method = request.get("method") + params = request.get("params") or {} + if method == "initialize": + say({"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": PROTOCOL, "capabilities": {"tools": {}}, "serverInfo": {"name": name, "version": "1"}}}) + elif method == "notifications/initialized": + pass + elif method == "ping": + if rid is not None: + say({"jsonrpc": "2.0", "id": rid, "result": {}}) + elif method == "tools/list": + say({"jsonrpc": "2.0", "id": rid, "result": {"tools": [ + {"name": t.name, "description": t.description, "inputSchema": as_schema(t.input)} for t in tools]}}) + elif method == "tools/call": + tool = by_name.get(str(params.get("name", ""))) + if tool is None: + say({"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"{name} has no tool {params.get('name')}"}}) + continue + try: + result = tool.run(params.get("arguments") or {}) + say({"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": json.dumps(result)}]}}) + except Exception as err: # the tool failed: a tool error, not a protocol one + say({"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": str(err)}], "isError": True}}) + elif rid is not None: + say({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"no {method}"}}) diff --git a/python/mesh_sdk/__pycache__/__init__.cpython-314.pyc b/python/mesh_sdk/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0956f1f7e9fea1c818dac7f6dc731f5957d51843 GIT binary patch literal 5871 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 BHr@aL literal 0 HcmV?d00001 diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..5c0d3f0 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "novox-mesh-sdk" +version = "0.1.0" +description = "The Novox Mesh SDK for Python: a tools bundle over stdio (novox/hq ADR 0187)" +requires-python = ">=3.9" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..9a5c1a0 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "mesh_sdk" +version = "0.1.0" +edition = "2021" +description = "The Novox Mesh SDK for Rust: a tools bundle over stdio (novox/hq ADR 0187)" + +[dependencies] +serde_json = "1" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..14df0e7 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,8 @@ +# mesh-sdk for Rust + +The skeleton (novox/hq ADR 0187): `mesh_sdk::serve(name, &tools)` speaks MCP over stdio to the +node's tool runtime. A `Tool` is a name, a description, an argument schema and a function. A tool +named `.` is the seat's implementation. One dependency, `serde_json`. + +What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no +client, nothing of any module's. `cargo build --example greet` and pipe a `tools/list` request in. diff --git a/rust/examples/greet.rs b/rust/examples/greet.rs new file mode 100644 index 0000000..bc8d1a6 --- /dev/null +++ b/rust/examples/greet.rs @@ -0,0 +1,19 @@ +// A tools bundle in Rust: one tool of its own and one seat verb, served over stdio. +use mesh_sdk::{serve, Tool}; +use serde_json::{json, Map, Value}; + +fn greet(a: &Map) -> Result { + let who = a.get("who").and_then(Value::as_str).unwrap_or("world"); + Ok(json!({"greeting": format!("hello {}", who), "language": "rust"})) +} + +fn on(_: &Map) -> Result { + Ok(json!({"on": true, "language": "rust"})) +} + +fn main() -> std::io::Result<()> { + serve("rust-example", &[ + Tool { name: "greet", description: "say hello", input: json!({"who": {"type": "string", "description": "whom"}}), run: greet }, + Tool { name: "node-lamp.on", description: "the seat's verb", input: json!({}), run: on }, + ]) +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..b5b7bc0 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,80 @@ +//! The Novox Mesh SDK for Rust — the skeleton (novox/hq ADR 0187). +//! +//! 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 crate is the loop, so a bundle writes its tools and nothing else. A tool named +//! `.` 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). stdout is the protocol; log to stderr. +use serde_json::{json, Map, Value}; +use std::io::{self, BufRead, Write}; + +/// The MCP version this speaks; the runtime says the same. +pub const PROTOCOL: &str = "2025-03-26"; + +/// One tool a bundle serves. `input` is the JSON schema of the arguments, or the bare property +/// map; `run` answers with any JSON or an error message. +pub struct Tool { + pub name: &'static str, + pub description: &'static str, + pub input: Value, + pub run: fn(&Map) -> Result, +} + +fn as_schema(given: &Value) -> Value { + match given.as_object() { + None => json!({"type": "object", "properties": {}}), + Some(m) if m.is_empty() => json!({"type": "object", "properties": {}}), + Some(m) if m.contains_key("type") || m.contains_key("properties") => given.clone(), + Some(_) => json!({"type": "object", "properties": given}), + } +} + +/// Serve these tools to the runtime over stdio until stdin closes. +pub fn serve(name: &str, tools: &[Tool]) -> io::Result<()> { + let stdin = io::stdin(); + let mut out = io::stdout(); + let mut say = |m: Value| -> io::Result<()> { + let mut m = m; + m["jsonrpc"] = json!("2.0"); + writeln!(out, "{}", m)?; + out.flush() + }; + for line in stdin.lock().lines() { + let line = line?; + let req: Value = match serde_json::from_str(line.trim()) { + Ok(v) => v, + Err(_) => continue, + }; + let id = req.get("id").cloned().unwrap_or(Value::Null); + let method = req.get("method").and_then(Value::as_str).unwrap_or(""); + let params = req.get("params").cloned().unwrap_or(json!({})); + match method { + "initialize" => say(json!({"id": id, "result": {"protocolVersion": PROTOCOL, + "capabilities": {"tools": {}}, "serverInfo": {"name": name, "version": "1"}}}))?, + "notifications/initialized" => {} + "ping" => { if !id.is_null() { say(json!({"id": id, "result": {}}))? } } + "tools/list" => { + let listed: Vec = tools.iter().map(|t| json!({ + "name": t.name, "description": t.description, "inputSchema": as_schema(&t.input)})).collect(); + say(json!({"id": id, "result": {"tools": listed}}))? + } + "tools/call" => { + let wanted = params.get("name").and_then(Value::as_str).unwrap_or(""); + let empty = Map::new(); + let args = params.get("arguments").and_then(Value::as_object).unwrap_or(&empty); + match tools.iter().find(|t| t.name == wanted) { + None => say(json!({"id": id, "error": {"code": -32602, "message": format!("{} has no tool {}", name, wanted)}}))?, + Some(t) => match (t.run)(args) { + Ok(v) => say(json!({"id": id, "result": {"content": [{"type": "text", "text": v.to_string()}]}}))?, + Err(e) => say(json!({"id": id, "result": {"content": [{"type": "text", "text": e}], "isError": true}}))?, + }, + } + } + _ => { if !id.is_null() { say(json!({"id": id, "error": {"code": -32601, "message": format!("no {}", method)}}))? } } + } + } + Ok(()) +} diff --git a/src/stdio/index.ts b/src/stdio/index.ts new file mode 100644 index 0000000..bd3c1c2 --- /dev/null +++ b/src/stdio/index.ts @@ -0,0 +1,118 @@ +/** + * A tools bundle as a process the node's runtime launches (novox/hq ADR 0187). + * + * The runtime speaks MCP over stdio to it: `initialize`, `tools/list` once, `tools/call` per call, + * newline-framed JSON-RPC on stdin and stdout. This is the whole of what a tools bundle in any + * language must do, and this file is the TypeScript SDK's share of it: the loop, so a module writes + * its tools and nothing else. A tool named `.` is the module's implementation of that + * seat's verb; any other name is the module's own tool. + * + * **No transport here.** The bus, the subjects, the memberships are the runtime's (ADR 0039, 0175); + * a bundle knows only stdin and stdout, which is why a bus change rebuilds no bundle in any language. + * + * stdout is the protocol. A tool that wants to log writes to stderr, which the runtime keeps. + */ +import type { ToolDefinition } from "../contracts/index.js"; +import { collectTools } from "../tools/index.js"; + +/** The protocol version this speaks; the runtime says the same. */ +export const PROTOCOL = "2025-03-26"; + +interface Request { + jsonrpc: string; + id?: number | string | null; + method: string; + params?: Record; +} + +/** A module's declared input as a JSON schema: the bare property map the catalogue's modules write + * is wrapped, a schema is passed through, nothing is an empty object. */ +export function asSchema(input: unknown): Record { + if (!input || typeof input !== "object" || Array.isArray(input)) return { type: "object", properties: {} }; + const given = input as Record; + if (given.type === "object" || "properties" in given) return given; + if (Object.keys(given).length === 0) return { type: "object", properties: {} }; + return { type: "object", properties: given }; +} + +/** + * Serve these tools to the runtime over stdio until stdin closes. `name` is the module's; a tool + * whose name is `.` is served as the seat's. Resolves when the runtime hangs up. + */ +export async function serveStdio(name: string, tools: ToolDefinition[]): Promise { + const byName = new Map(tools.map((t) => [t.name, t])); + const say = (message: unknown): void => { + process.stdout.write(`${JSON.stringify(message)}\n`); + }; + for await (const line of lines()) { + let request: Request; + try { + request = JSON.parse(line) as Request; + } catch { + continue; // not framed; nobody to answer + } + const notification = request.id === undefined || request.id === null; + switch (request.method) { + case "initialize": + say({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name, version: "1" } } }); + break; + case "notifications/initialized": + break; + case "ping": + if (!notification) say({ jsonrpc: "2.0", id: request.id, result: {} }); + break; + case "tools/list": + say({ + jsonrpc: "2.0", id: request.id, + result: { tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: asSchema(t.input) })) }, + }); + break; + case "tools/call": { + const wanted = String(request.params?.name ?? ""); + const tool = byName.get(wanted); + if (!tool) { + say({ jsonrpc: "2.0", id: request.id, error: { code: -32602, message: `${name} has no tool ${wanted}` } }); + break; + } + try { + const result = await tool.run((request.params?.arguments as Record | undefined) ?? {}); + say({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: JSON.stringify(result ?? null) }] } }); + } catch (err) { + // The tool failed; said as a tool error, not a protocol one, because the call was well-formed. + say({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], isError: true } }); + } + break; + } + default: + if (!notification) say({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: `no ${request.method}` } }); + } + } +} + +/** + * Serve everything registered with `registerModuleTools` over stdio: the one-line entry for a bundle + * that already registers the way the in-process shortcut expects, so one source serves both ways. + */ +export async function serveRegisteredOverStdio(): Promise { + const groups = collectTools(); + const name = groups[0]?.module ?? "bundle"; + const tools: ToolDefinition[] = []; + for (const g of groups) { + for (const t of g.tools) tools.push(g.module === name ? t : { ...t, name: `${g.module}.${t.name}` }); + } + await serveStdio(name, tools); +} + +async function* lines(): AsyncGenerator { + let buffered = ""; + for await (const chunk of process.stdin) { + buffered += (chunk as Buffer).toString("utf8"); + let at: number; + while ((at = buffered.indexOf("\n")) >= 0) { + const line = buffered.slice(0, at).trim(); + buffered = buffered.slice(at + 1); + if (line !== "") yield line; + } + } + if (buffered.trim() !== "") yield buffered.trim(); +} diff --git a/test/fixtures/stdio-bundle.mjs b/test/fixtures/stdio-bundle.mjs new file mode 100644 index 0000000..f80ae2b --- /dev/null +++ b/test/fixtures/stdio-bundle.mjs @@ -0,0 +1,8 @@ +// A tools bundle written against the protocol: the module's own tool, a seat's verb, and one that fails. +import { serveStdio } from "../../dist/stdio/index.js"; + +await serveStdio("lamp", [ + { name: "brightness", description: "set it", input: { level: { type: "number", description: "0 to 10" } }, run: async (a) => ({ level: a.level }) }, + { name: "node-lamp.on", description: "the seat's verb", input: {}, run: async () => ({ on: true }) }, + { name: "fail", description: "it throws", input: {}, run: async () => { throw new Error("the bulb is gone"); } }, +]); diff --git a/test/stdio.test.ts b/test/stdio.test.ts new file mode 100644 index 0000000..05938f4 --- /dev/null +++ b/test/stdio.test.ts @@ -0,0 +1,41 @@ +// A tools bundle as a process (novox/hq ADR 0187): the stdio loop answers initialize, tools/list and +// tools/call the way the node's runtime asks, and a tool that throws is a tool error, not a hang-up. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +test("a bundle on stdio lists its tools and answers a call, a seat's verb among them", async () => { + const child = spawn(process.execPath, [fileURLToPath(new URL("./fixtures/stdio-bundle.mjs", import.meta.url))], { stdio: ["pipe", "pipe", "inherit"] }); + const replies: any[] = []; + let buffered = ""; + child.stdout.on("data", (d) => { + buffered += d.toString(); + let at: number; + while ((at = buffered.indexOf("\n")) >= 0) { + replies.push(JSON.parse(buffered.slice(0, at))); + buffered = buffered.slice(at + 1); + } + }); + const ask = async (id: number, method: string, params?: unknown) => { + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + for (let i = 0; i < 100 && !replies.some((r) => r.id === id); i++) await new Promise((r) => setTimeout(r, 20)); + return replies.find((r) => r.id === id); + }; + try { + assert.equal((await ask(1, "initialize", { protocolVersion: "2025-03-26", capabilities: {} })).result.serverInfo.name, "lamp"); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n"); + const listed = (await ask(2, "tools/list")).result.tools; + assert.deepEqual(listed.map((t: any) => t.name), ["brightness", "node-lamp.on", "fail"]); + assert.deepEqual(listed[0].inputSchema, { type: "object", properties: { level: { type: "number", description: "0 to 10" } } }); + const called = (await ask(3, "tools/call", { name: "brightness", arguments: { level: 7 } })).result; + assert.deepEqual(JSON.parse(called.content[0].text), { level: 7 }); + const failed = (await ask(4, "tools/call", { name: "fail", arguments: {} })).result; + assert.equal(failed.isError, true); + assert.equal(failed.content[0].text, "the bulb is gone"); + assert.equal((await ask(5, "tools/call", { name: "nothing" })).error.code, -32602); + } finally { + child.stdin.end(); + child.kill(); + } +});