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,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"
|
||||
Reference in New Issue
Block a user