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:
@@ -0,0 +1 @@
|
||||
example
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+108
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
Executable
+13
@@ -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"}),
|
||||
])
|
||||
@@ -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.
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
Cargo.lock
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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 },
|
||||
])
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+8
@@ -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"); } },
|
||||
]);
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user