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.
This commit is contained in:
jochen
2026-10-02 20:34:19 +02:00
parent 163f85ac35
commit 78817c949e
24 changed files with 699 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
example
+8
View File
@@ -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
+9
View File
@@ -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 `<seat>.<verb>` 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`.
+27
View File
@@ -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 <stdio.h>
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;
}
+83
View File
@@ -0,0 +1,83 @@
#include "mesh_sdk.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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;
}
+29
View File
@@ -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
* `<seat>.<verb>` 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 <jansson.h>
#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
+8
View File
@@ -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
`<seat>.<verb>` 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.
+29
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-sdk/go
go 1.22
+108
View File
@@ -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
// `<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).
// 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()
}
+3 -2
View File
@@ -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"
+8
View File
@@ -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
`<seat>.<verb>` 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.
+13
View File
@@ -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"}),
])
+77
View File
@@ -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 `<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).
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}"}})
Binary file not shown.
+9
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
target/
Cargo.lock
+8
View File
@@ -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"
+8
View File
@@ -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 `<seat>.<verb>` 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.
+19
View File
@@ -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<String, Value>) -> Result<Value, String> {
let who = a.get("who").and_then(Value::as_str).unwrap_or("world");
Ok(json!({"greeting": format!("hello {}", who), "language": "rust"}))
}
fn on(_: &Map<String, Value>) -> Result<Value, String> {
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 },
])
}
+80
View File
@@ -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
//! `<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). 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<String, Value>) -> Result<Value, String>,
}
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<Value> = 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(())
}
+118
View File
@@ -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 `<seat>.<verb>` 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<string, unknown>;
}
/** 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<string, unknown> {
if (!input || typeof input !== "object" || Array.isArray(input)) return { type: "object", properties: {} };
const given = input as Record<string, unknown>;
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 `<seat>.<verb>` is served as the seat's. Resolves when the runtime hangs up.
*/
export async function serveStdio(name: string, tools: ToolDefinition[]): Promise<void> {
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<string, unknown> | 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<void> {
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<string> {
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();
}
+8
View File
@@ -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"); } },
]);
+41
View File
@@ -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();
}
});