contrib: sic companion helpers (lmcp-tool, sicwrite, sicedit)
lmcp-tool drives a host's lmcp (MCP) tools over a stateless tools/call via sic — list for discovery, key=value args so a model never embeds JSON in a shell (apostrophe-safe). sicwrite/sicedit pass file content as argv since sicd consumes stdin for the frame. References the lmcp server project: git.reauktion.de/marfrit/lmcp Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# contrib — companion helpers for driving remote tools through sic
|
||||||
|
|
||||||
|
Optional POSIX-sh helpers that pair `sic` with an **[lmcp](https://git.reauktion.de/marfrit/lmcp)**
|
||||||
|
server (a lightweight Lua MCP server). Install any of them on the hosts you reach with sic and
|
||||||
|
call them as `sic <host> <helper> ...`. None of these are required by sic itself.
|
||||||
|
|
||||||
|
## `lmcp-tool`
|
||||||
|
Call a host's lmcp (MCP) tools over a single stateless HTTP `tools/call`, with no persistent
|
||||||
|
MCP session:
|
||||||
|
|
||||||
|
```
|
||||||
|
sic <host> lmcp-tool list # discover the tools this host offers
|
||||||
|
sic <host> lmcp-tool web_search "query=today's news" # call one, args as key=value
|
||||||
|
sic <host> lmcp-tool fetch url="https://example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `key=value` form is the important bit: a model never has to embed JSON in a shell string,
|
||||||
|
so apostrophes (`today's`, `what's`) ride safely inside double quotes and `lmcp-tool` does the
|
||||||
|
JSON-encoding. Raw JSON (`lmcp-tool <tool> '{"k":"v"}'`) still works. Host/port/token are read
|
||||||
|
from `$LMCP_HOST/$LMCP_PORT/$LMCP_TOKEN` or the local `lmcp.service` systemd unit. See the
|
||||||
|
[lmcp project](https://git.reauktion.de/marfrit/lmcp) for the server side.
|
||||||
|
|
||||||
|
## `sicwrite` / `sicedit`
|
||||||
|
`sicd` consumes stdin for the netstring frame, so a remote command can't be fed content via a
|
||||||
|
pipe. These pass content as **argv** instead (which sic delivers losslessly):
|
||||||
|
|
||||||
|
```
|
||||||
|
sic <host> sicwrite <path> <content> # write a file
|
||||||
|
sic <host> sicedit <path> <old> <new> # replace one exact occurrence
|
||||||
|
```
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# lmcp-tool — call a LOCAL lmcp server's tool over a stateless HTTP tools/call.
|
||||||
|
#
|
||||||
|
# Companion client for lmcp servers (the Lua MCP server): https://git.reauktion.de/marfrit/lmcp
|
||||||
|
# Meant to be run through sic — `sic <host> lmcp-tool ...` — so a model drives a
|
||||||
|
# remote host's MCP tools without a persistent MCP session. Three forms:
|
||||||
|
#
|
||||||
|
# lmcp-tool list # DISCOVERY: available tools + descriptions
|
||||||
|
# lmcp-tool <tool> key=value [key=value ...] # args as pairs — NO json-in-shell (apostrophe-safe)
|
||||||
|
# lmcp-tool <tool> '{"k":"v"}' # raw JSON (single arg starting with '{')
|
||||||
|
#
|
||||||
|
# Prefer key=value: `sic host lmcp-tool web_search "query=today's news"` — an apostrophe
|
||||||
|
# inside double quotes is fine, and lmcp-tool JSON-encodes the pairs for you.
|
||||||
|
#
|
||||||
|
# host/port/token come from, in order: $LMCP_HOST/$LMCP_PORT/$LMCP_TOKEN → the lmcp.service
|
||||||
|
# systemd unit Environment= → the file it points at via LMCP_CONF (sudo -n if root-only;
|
||||||
|
# key godparticle|token|bearer). Defaults to 127.0.0.1:8080.
|
||||||
|
tool="$1"
|
||||||
|
[ -n "$tool" ] || { echo "usage: lmcp-tool <tool> [ key=value ... | '{json}' ] | lmcp-tool list" >&2; exit 2; }
|
||||||
|
shift
|
||||||
|
|
||||||
|
host="$LMCP_HOST"; port="$LMCP_PORT"; tok="$LMCP_TOKEN"
|
||||||
|
env=$(systemctl show lmcp.service -p Environment 2>/dev/null | tr ' ' '\n')
|
||||||
|
[ -z "$host" ] && host=$(printf '%s\n' "$env" | sed -n 's/^LMCP_HOST=//p' | head -1)
|
||||||
|
[ -z "$port" ] && port=$(printf '%s\n' "$env" | sed -n 's/^LMCP_PORT=//p' | head -1)
|
||||||
|
[ -z "$tok" ] && tok=$(printf '%s\n' "$env" | sed -n 's/^LMCP_TOKEN=//p' | head -1)
|
||||||
|
if [ -z "$tok" ]; then
|
||||||
|
conf=$(printf '%s\n' "$env" | sed -n 's/^LMCP_CONF=//p' | head -1)
|
||||||
|
[ -n "$conf" ] && tok=$( { cat "$conf" 2>/dev/null || sudo -n cat "$conf" 2>/dev/null; } \
|
||||||
|
| grep -iE 'godparticle|token|bearer' | grep -oE '[A-Za-z0-9_-]{30,}' | head -1)
|
||||||
|
fi
|
||||||
|
[ -n "$host" ] || host=127.0.0.1
|
||||||
|
[ -n "$port" ] || port=8080
|
||||||
|
|
||||||
|
post() {
|
||||||
|
curl -s -m 120 -X POST "http://${host}:${port}/mcp" \
|
||||||
|
-H "Authorization: Bearer ${tok}" -H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json, text/event-stream" -d "$1" | sed -n 's/^data: //p'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$tool" = "list" ] || [ "$tool" = "tools" ] || [ "$tool" = "--list" ]; then
|
||||||
|
post '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
raw = sys.stdin.read().strip()
|
||||||
|
if not raw: sys.stderr.write("lmcp-tool: empty response (unreachable or auth failed)\n"); sys.exit(1)
|
||||||
|
d = json.loads(raw.splitlines()[-1])
|
||||||
|
if "error" in d: sys.stderr.write("lmcp error: %s\n" % d["error"]); sys.exit(1)
|
||||||
|
for t in d.get("result", {}).get("tools", []):
|
||||||
|
desc = (t.get("description") or "").splitlines()[0] if t.get("description") else ""
|
||||||
|
print("%-16s %s" % (t.get("name","?"), desc))
|
||||||
|
'
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
# build the arguments JSON
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
args='{}'
|
||||||
|
elif [ $# -eq 1 ] && [ "${1#\{}" != "$1" ]; then
|
||||||
|
args="$1" # single arg starting with '{' = raw JSON
|
||||||
|
else
|
||||||
|
args=$(python3 -c 'import sys, json
|
||||||
|
print(json.dumps({a.partition("=")[0]: a.partition("=")[2] for a in sys.argv[1:]}))' "$@") \
|
||||||
|
|| { echo "lmcp-tool: could not build args from key=value pairs" >&2; exit 2; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
body=$(printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"%s","arguments":%s}}' "$tool" "$args")
|
||||||
|
post "$body" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
raw = sys.stdin.read().strip()
|
||||||
|
if not raw: sys.stderr.write("lmcp-tool: empty response (unreachable or auth failed)\n"); sys.exit(1)
|
||||||
|
d = json.loads(raw.splitlines()[-1])
|
||||||
|
if "error" in d:
|
||||||
|
sys.stderr.write("lmcp error: %s\n" % d["error"])
|
||||||
|
sys.stderr.write("hint: run lmcp-tool list to see valid tool names\n"); sys.exit(1)
|
||||||
|
r = d.get("result", {})
|
||||||
|
print("\n".join(c["text"] for c in r.get("content", []) if c.get("type") == "text"))
|
||||||
|
sys.exit(1 if r.get("isError") else 0)
|
||||||
|
'
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# sicedit <path> <old> <new> — replace the single exact occurrence of <old>
|
||||||
|
# with <new> in <path> (edit_file semantics). Args pass losslessly via sic.
|
||||||
|
[ $# -ge 3 ] || { echo "usage: sicedit <path> <old> <new>" >&2; exit 2; }
|
||||||
|
python3 - "$1" "$2" "$3" <<'PY'
|
||||||
|
import sys
|
||||||
|
p,o,n=sys.argv[1],sys.argv[2],sys.argv[3]
|
||||||
|
s=open(p).read()
|
||||||
|
c=s.count(o)
|
||||||
|
if c==0: sys.stderr.write("sicedit: old_string not found\n"); sys.exit(1)
|
||||||
|
if c>1: sys.stderr.write("sicedit: old_string not unique (%d matches)\n"%c); sys.exit(1)
|
||||||
|
open(p,'w').write(s.replace(o,n,1))
|
||||||
|
PY
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# sicwrite <path> <content> — write content (passed as one argv by sic, so any
|
||||||
|
# bytes survive) to path. Replaces the write_file tool for sic on any host.
|
||||||
|
[ $# -ge 2 ] || { echo "usage: sicwrite <path> <content>" >&2; exit 2; }
|
||||||
|
printf '%s' "$2" > "$1"
|
||||||
Reference in New Issue
Block a user