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 0000000..0956f1f Binary files /dev/null and b/python/mesh_sdk/__pycache__/__init__.cpython-314.pyc differ 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(); + } +});