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.
sic
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 touch 'a b' creates one file named a b. sic sends the argument vector to a
daemon on the host as length-prefixed fields, and the daemon calls execvp on
it. No shell parses the arguments.
Components
sic— client. Takes argv on the command line, frames it as netstrings, pipes the frame oversshtosicd.sicd— daemon on the target host. Reads netstrings from stdin and runs the command.
Both are single static Go binaries (cmd/sic, cmd/sicd). A Python reference
implementation of the daemon is in gateway/sicd.
Wire format
Each field is a netstring: <length>:<bytes>,. A frame is a mode field, zero or
more argument fields, and an empty terminator:
<mode> <arg>* 0:,
argv = ["touch", "a b"] is 4:exec,5:touch,3:a b,0:,. The length prefix means
no byte needs escaping and no delimiter can collide with the payload.
| mode | selector | behaviour |
|---|---|---|
exec (default) |
4:exec, |
execvp(argv), no shell. Arguments are passed through unchanged. |
sh |
2:sh, |
one field passed to sh -c. Pipes, redirects, and globs work. |
Usage
sic <host> <command> [arg ...]
sic --sh <host> '<shell string>'
sic <host> -- <command> [arg ...] # explicit separator
sic host1 echo hello world
sic host1 touch 'a b' # one file named "a b"
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:
[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
go build -o bin/sic ./cmd/sic
go build -o bin/sicd ./cmd/sicd
Install
Client:
cp bin/sic ~/bin/sic
Daemon on each target host. sicd runs what it is sent, so it must only be
reachable over an authenticated transport. Pin it to a dedicated ssh key so the
target can run nothing else with that key:
scp bin/sicd host:/tmp/sicd
ssh host 'sudo install -m755 /tmp/sicd /usr/local/bin/sicd'
# ~/.ssh/authorized_keys on the target:
# command="sicd",no-port-forwarding,no-pty ssh-ed25519 AAAA...
Do not listen on a TCP or message-bus port.
Test
printf '4:exec,2:id,0:,' | ssh host sicd
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, plainsshversussic.SKILL.md,SKILL-bg.md— agent skill definitions (foreground and background).