pub struct Verdict {
pub code: u8,
pub text: String,
}
const REFUSED: u8 = 2;
const FAILED: u8 = 1;
impl Verdict {
pub fn ok(text: String) -> Self {
Self { code: 0, text }
}
pub fn refused(what: String) -> Self {
Self {
code: REFUSED,
text: format!("thrall: {what}\n\n{}", usage()),
}
}
pub fn failed(what: String) -> Self {
Self {
code: FAILED,
text: format!("thrall: {what}"),
}
}
}
pub enum Decided {
Say(Verdict),
Serve,
Pin {
server: Vec<String>,
},
Bridge {
tool: String,
server: Vec<String>,
},
}
pub fn version() -> String {
format!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
}
pub fn usage() -> String {
format!(
"{}
thrall is the foot: a tool-execution client for a yog server. It dials in,
advertises what this box offers, waits for work, and posts the captures back.
It never listens and it never speaks first.
usage: thrall run
thrall mcp pin -- <server argv...>
thrall mcp <tool> -- <server argv...>
thrall [--example-tools | --version | --help]
run serve every channel this box is provisioned for: present
what it offers, wait for work, run it, post the captures
back. It does not return while a channel is up. A channel
that drops is dialled again, with a backoff that settles;
a channel that cannot be served at all is an exit naming
it, and restarting the process belongs to this machine's
own supervision.
mcp pin run an MCP server once, and print every tool of its catalog
as a complete tools.json entry for you to paste. It writes
nothing: the document is yours, and pasting the entries you
vouch for is the allowlist. The server's own hints about each
tool go to standard error, for the policy row on the engine
side; they are never advertised. `pin` is therefore a tool
name this verb cannot bridge — rename such a tool in the
entry, which is an edit the paste already invites.
mcp <tool> call one tool on an MCP server this box can spawn, as an
ordinary tool command: the invocation's input JSON on stdin,
the tool's content on stdout, the exit code the verdict. The
server runs for this one call and is torn down after it. It
is not a verb to type by hand — it is what a `command` in
tools.json names, so a bridged tool is a plain entry and this
box's document gains no key.
--example-tools print a complete example tool document, to edit into place:
thrall --example-tools > <data root>/tools.json. A starting
point and never a default — every entry names a command this
box does not have until you write one — and it writes nothing.
-V, --version print the name and version
-h, --help print this
What it reads, both provisioned by hand and never by thrall: the tool document
at <data root>/tools.json, and one channel per directory under <data
root>/wire/workspaces/. The data root is $XDG_DATA_HOME/thrall, or
$HOME/.local/share/thrall.
See docs/DESIGN.md for the role and the module map, and yog's docs/REMOTE.md
for the protocol thrall implements against.",
version()
)
}
fn owned(words: &[&str]) -> Vec<String> {
words.iter().map(|word| (*word).to_owned()).collect()
}
pub fn run(args: Vec<String>) -> Decided {
let words: Vec<&str> = args.iter().map(String::as_str).collect();
match words.as_slice() {
["run"] => Decided::Serve,
["mcp", "pin", "--", server @ ..] if !server.is_empty() => Decided::Pin {
server: owned(server),
},
["mcp", tool, "--", server @ ..] if !server.is_empty() => Decided::Bridge {
tool: (*tool).to_owned(),
server: owned(server),
},
["mcp", ..] => Decided::Say(Verdict::refused(
"mcp takes a tool name — or `pin` — then `--`, then the argv of a \
server this box can spawn: thrall mcp fetch -- uvx mcp-server-fetch"
.to_string(),
)),
["--example-tools"] => {
Decided::Say(Verdict::ok(crate::config::EXAMPLE.trim_end().to_owned()))
}
["--version" | "-V"] => Decided::Say(Verdict::ok(version())),
["--help" | "-h"] => Decided::Say(Verdict::ok(usage())),
[] => Decided::Say(Verdict::refused(
"nothing to do — `thrall run` is the verb".to_string(),
)),
other => Decided::Say(Verdict::refused(format!(
"unrecognised argument: {}",
other.join(" ")
))),
}
}
#[cfg(test)]
mod tests;