2 Commits

Author SHA1 Message Date
Claude (noether) d576886737 contrib/lmcp-tool: --json for machine consumers
A client that drives lmcp through sic — rather than holding a bearer token
and reaching a port itself — needs two things this script discarded:

  1. inputSchema. `list` printed the name and the first line of the
     description only. Without the schema a model guesses parameter names.
  2. The full result of a call: content[] together with isError. Only the
     text was printed, and a non-zero exit stood in for isError. MCP has it
     the other way round — the content is authoritative even when isError
     is set, and belongs in front of the model.

Both live behind --json. The existing output is byte-identical, because
humans and shell scripts read it that way.

    lmcp-tool list --json         -> {"tools":[{name,description,inputSchema},…]}
    lmcp-tool --json <tool> k=v   -> {"content":[…],"isError":false}

Exit codes under --json: 0 as soon as a valid JSON-RPC result exists —
including isError, which is a tool result and not a transport failure. 1
stays reserved for transport and protocol errors, message on stderr.

Verified against a live lmcp server with 31 tools: old form unchanged
(31 lines, exit 0), both JSON forms parse, unknown tool exits 1 in both
forms, inputSchema present on every entry.
2026-08-07 17:12:00 +02:00
Markus Fritsche ef1d6b517d README: document nesting — the nested-container-hop feature (V2)
The README covered only single-host use; the differentiator from a shell helper
— reaching into containers several hops deep with the argv frame preserved at
every layer — was undocumented. Add a Nesting section (host/guest/svc syntax,
/etc/sic/hosts.toml nest stanzas, and the quoting-hell contrast with the ssh
equivalent), surface it in the intro line, and link docs/design-nested.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
2026-07-24 09:22:24 +02:00
2 changed files with 62 additions and 7 deletions
+35 -1
View File
@@ -1,6 +1,7 @@
# sic
Run a command on a remote host without a shell re-parsing its arguments.
Run a command on a remote host — or inside a container several hops deep —
without a shell re-parsing its arguments.
`ssh host touch 'a b'` creates two files: `ssh` joins its arguments with spaces
and hands the result to the remote login shell, which splits it again. `sic host
@@ -50,6 +51,38 @@ sic host1 echo '$HOME' # literal, no expansion
sic --sh host1 'echo hi | wc -c'
```
## Nesting
The frame survives being wrapped again, so `sic` reaches *into* containers with no
shell anywhere in the path, at any depth. A target is a host followed by container
hops separated by `/`:
```
sic host1/app cat /etc/os-release # into the 'app' guest on host1
sic host2/ct/svc touch 'a b' # host2 -> ct -> svc, ONE file, three layers deep
```
Each host's hop runtimes come from `/etc/sic/hosts.toml`, one stanza per host:
```toml
[host1]
nest = ["incus"] # host1/app -> incus exec app -- <argv>
[host2]
nest = ["pct", "docker"] # host2/ct/svc -> pct exec ct -- docker exec svc -- <argv>
```
This is what separates `sic` from a shell helper script. The `ssh` equivalent —
```
ssh host2 "pct exec ct -- docker exec svc -- touch \"a b\""
```
nests quotes inside quotes inside quotes, and every layer re-splits the arguments; a
body containing a space, `$()`, a pipe, or a newline breaks a different layer each time.
`sic` frames the argv once and re-frames it at each hop, so `touch 'a b'` is one file
whether it runs on the host or three containers deep. No escaping, at any level.
## Build
```
@@ -88,5 +121,6 @@ sic host echo hello world
## See also
- `docs/design.md` — wire format, exec modes, transport and security, prior art.
- `docs/design-nested.md` — how nested container hops resolve.
- `demos/quoting-hell.py` — six cases run both ways, plain `ssh` versus `sic`.
- `SKILL.md`, `SKILL-bg.md` — agent skill definitions (foreground and background).
+27 -6
View File
@@ -6,6 +6,8 @@
# remote host's MCP tools without a persistent MCP session. Three forms:
#
# lmcp-tool list # DISCOVERY: available tools + descriptions
# lmcp-tool list --json # same, but full tools/list incl. inputSchema
# lmcp-tool --json <tool> key=value ... # full result object (content[] + isError)
# lmcp-tool <tool> key=value [key=value ...] # args as pairs — NO json-in-shell (apostrophe-safe)
# lmcp-tool fetch_url https://example.com # a bare http(s):// arg is auto-mapped to url=
# lmcp-tool <tool> '{"k":"v"}' # raw JSON (single arg starting with '{')
@@ -16,9 +18,14 @@
# 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.
# --json darf vorne stehen (maschinelle Verbraucher) oder bei `list` hinten.
json_out=0
if [ "$1" = "--json" ]; then json_out=1; shift; fi
tool="$1"
[ -n "$tool" ] || { echo "usage: lmcp-tool <tool> [ key=value ... | '{json}' ] | lmcp-tool list" >&2; exit 2; }
[ -n "$tool" ] || { echo "usage: lmcp-tool [--json] <tool> [ key=value ... | '{json}' ] | lmcp-tool list [--json]" >&2; exit 2; }
shift
[ "$1" = "--json" ] && { json_out=1; shift; }
host="$LMCP_HOST"; port="$LMCP_PORT"; tok="$LMCP_TOKEN"
env=$(systemctl show lmcp.service -p Environment 2>/dev/null | tr ' ' '\n')
@@ -46,10 +53,17 @@ 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))
'
res = d.get("result", {})
if sys.argv[1] == "1":
# Vollstaendig, inklusive inputSchema - das braucht ein Modell, um die
# Parameter richtig zu setzen. Die Prosa-Beschreibung allein reicht nicht.
json.dump(res, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
else:
for t in res.get("tools", []):
desc = (t.get("description") or "").splitlines()[0] if t.get("description") else ""
print("%-16s %s" % (t.get("name","?"), desc))
' "$json_out"
exit $?
fi
@@ -83,6 +97,13 @@ 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", {})
if sys.argv[1] == "1":
# MCP-Semantik: der Inhalt ist auch bei isError massgeblich und gehoert
# an das Modell durchgereicht. Rueckgabewert 0, sobald eine gueltige
# Antwort vorliegt - 1 bleibt dem Transportfehler vorbehalten.
json.dump(r, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
sys.exit(0)
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)
'
' "$json_out"