mcp-cli
Rust crates for command-line tools that agents can also call. mcp-cli-core runs one typed command either as a CLI that prints a JSON envelope or as a tool on an MCP server over stdio. Four companion crates add the rest of what such a tool needs: configuration, self-update, feedback reporting and a daemon with a shared cache.
Nine of the tools on a.skh.am/tools depend on at least one of them. Each crate is consumed as a Git dependency; the package is mcp-cli-core because mcp-cli on crates.io is an unrelated project, but the library keeps the name mcp_cli.
mcp-cli-core
One command, two surfaces
inputSchema; the result becomes the same envelope on both surfaces.The output below comes from a small host in this repository (scripts/site/demo): three tools, math_add, math_divide, which refuses to divide by zero, and crash, which panics.
fn router() -> ToolRouter<()> {
let mut r = ToolRouter::new();
r.add_typed_tool("math_add", "Add two integers.", |(), p: Pair| add(p));
r.add_typed_tool("math_divide", "Divide one integer by another.", |(), p: Pair| divide(p));
r.add_typed_tool("crash", "Panic, to show how a failing tool is reported.", |(), _: Pair| -> Result<Answer, MathError> {
panic!("this tool always panics")
});
r
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let pair = |a: &[String]| Pair { lhs: a[0].parse().unwrap(), rhs: a[1].parse().unwrap() };
match args.first().map(String::as_str) {
Some("add") => write_json_result(std::io::stdout(), add(pair(&args[1..]))).unwrap(),
Some("divide") => write_json_result(std::io::stdout(), divide(pair(&args[1..]))).unwrap(),
Some("mcp") => {
let config = StdioServerConfig { server_name: "demo".to_owned(), server_version: "0.1.0".to_owned() };
McpServer::new(config, router()).serve_stdio(&()).unwrap();
}
_ => eprintln!("usage: demo add A B | demo divide A B | demo mcp"),
}
}
As a CLI
demo add 2 3
{"status":"success","meta":{"schema_version":1},"data":{"value":5}}
demo divide 1 0
{"status":"error","meta":{"schema_version":1},"error":{"category":"validation","code":"division_by_zero","message":"cannot divide by zero"}}
# exit status 0: the host decides what an error envelope exits with
Errors are data
A handler's error implements StructuredError: a category, a stable code and a message. Categories are fixed: validation, unsupported_capability, missing_permission, target_not_found, platform_adapter_failure, execution_failure, config_error, serialization_error and timeout, so a client can act on the category without parsing messages.
As an MCP server
One session with the same binary, demo mcp, recorded frame by frame: each line sent (→) and the reply (←). The tools/list reply is shortened to tool names, and each tools/call reply's text block, which repeats the envelope, is elided.
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"site","version":"1"}}}
← {"id":1,"jsonrpc":"2.0","result":{"capabilities":{"tools":{"listChanged":false}},"protocolVersion":"2025-06-18","serverInfo":{"name":"demo","version":"0.1.0"}}}
→ {"jsonrpc":"2.0","method":"notifications/initialized"}
← (a notification: no reply)
→ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
← {"id":2,"jsonrpc":"2.0","result":{"tools":["math_add …","math_divide …","crash …"]}}
→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"math_add","arguments":{"lhs":2,"rhs":3}}}
← {"id":3,"jsonrpc":"2.0","result":{"content":["… the same envelope as text …"],"isError":false,"structuredContent":{"data":{"value":5},"meta":{"command":"math_add","schema_version":1},"status":"success"}}}
→ {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"math_divide","arguments":{"lhs":1,"rhs":0}}}
← {"id":4,"jsonrpc":"2.0","result":{"content":["… the same envelope as text …"],"isError":true,"structuredContent":{"error":{"category":"validation","code":"division_by_zero","message":"cannot divide by zero"},"meta":{"command":"math_divide","schema_version":1},"status":"error"}}}
→ {not json
← {"error":{"code":-32700,"message":"parse error: key must be a string at line 1 column 2"},"id":null,"jsonrpc":"2.0"}
→ {"jsonrpc":"2.0","id":5,"method":"resources/list"}
← {"error":{"code":-32601,"message":"unsupported MCP method `resources/list`"},"id":5,"jsonrpc":"2.0"}
→ {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"math_add","arguments":{"lhs":"two"}}}
← {"id":6,"jsonrpc":"2.0","result":{"content":["… the same envelope as text …"],"isError":true,"structuredContent":{"error":{"category":"validation","code":"invalid_tool_arguments","message":"invalid tool arguments: invalid type: string \"two\", expected i64"},"meta":{"command":"math_add","schema_version":1},"status":"error"}}}
→ {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"crash","arguments":{"lhs":0,"rhs":0}}}
← {"id":7,"jsonrpc":"2.0","result":{"content":["… the same envelope as text …"],"isError":true,"structuredContent":{"error":{"category":"execution_failure","code":"tool_panicked","message":"tool `crash` panicked: this tool always panics"},"meta":{"command":"crash","schema_version":1},"status":"error"}}}
→ {"jsonrpc":"2.0","id":8,"method":"ping"}
← {"id":8,"jsonrpc":"2.0","result":{}}
Bad input keeps the session alive
| a frame that is not JSON | JSON-RPC -32700, id: null |
|---|---|
| an unknown method | -32601 |
| params that don't deserialize | -32602 |
| arguments of the wrong type | a tool error, invalid_tool_arguments |
| a handler's own error | isError: true with its code |
| a handler that panics | tool_panicked; later requests still served |
| a frame over 16 MiB | discarded to the next newline, -32700 |
| a JSON array | a batch: one array of replies, notifications omitted |
Frames are newline-delimited JSON, and the server negotiates protocol versions 2024-11-05, 2025-03-26 and 2025-06-18. Tool registration panics at startup on an empty or duplicate name, or an input type whose schema is not an object, because those are programming errors better caught before any client connects; try_add_tool reports them as values for tool sets assembled at run time.
stdout belongs to the protocol. Anything else that reaches it, such as a println! in a handler, a logger on its default sink or a panic hook redirected to stdout, arrives at the client as a corrupt frame, and many clients drop the session. Send everything else to stderr; the crate cannot enforce it without unsafe, which it forbids.
The companion crates
configurable-cli: one config file, done properly
A host provides one serde and schemars type and a small ConfigSpec. The crate finds ~/.config/<app>/config.yaml by a fixed precedence (an explicit path, $APP_CONFIG, $XDG_CONFIG_HOME, then $HOME), fills defaults for absent or partial files, validates, and writes owner-only (0600) files atomically, following relative or multi-hop symlinks to replace the real target. It supplies the config show | path | status | init | validate | schema | export | import subcommands and matching MCP tools; the ones that change files refuse unless called with confirmed: true.
updatable-cli: replacing a running binary
For tools shipped as binaries through GitHub releases. update finds the newest release that actually has an asset for this platform, stages it as <tool>_next and checks its sha256; a hook early in main promotes it on the next start. Promotion is treated as a transaction, because replacing the executable destroys the only known-good copy on the machine:
feedback-cli: errors and timings, back to the project
A host builds the crate in and exposes a FeedbackConfig in its own configuration; the configured strategy decides where events go, so a project can switch backends without changing code: webhook (POST each event as JSON, optionally with a bearer token read from an environment variable, synchronously or in the background), caco_cli (the local Cacophony CLI), file (JSON lines), stderr (the default) or disabled. Any StructuredError can be reported in one call.
remote-cli: one daemon owns the data
For tools that read from a slow or rate-limited API from many processes. One daemon collects and publishes typed snapshots; every CLI or MCP process subscribes, so only one process talks to the upstream API.
Request headers and bodies have separate limits (16 KiB and 1 MiB by default): oversized headers get 431, an oversized declared body 413 before it is read, and authentication is checked before the rest of a body is accepted. A delayed writer can never overwrite a newer revision. The flake exports lib.mkDaemonModules, which generates matching NixOS, nix-darwin and Nix-on-Droid service modules for any host following the crate's command-line contract.
Tests and use
| Crate | Passed | Failed | Commit |
|---|---|---|---|
mcp-cli | 55 | 0 | 81e5523 |
configurable-cli | 9 | 0 | 7d2234a |
updatable-cli | 64 | 0 | 115eff1 |
feedback-cli | 25 | 0 | 5cfeded |
remote-cli | 26 | 0 | 0d05051 |
Each crate's suite was run at the commit shown. Add the crates as Git dependencies:
[dependencies]
mcp-cli-core = { git = "https://github.com/harryaskham/mcp-cli" }
configurable-cli = { git = "https://github.com/harryaskham/configurable-cli" }
updatable-cli = { git = "https://github.com/harryaskham/updatable-cli" }
feedback-cli = { git = "https://github.com/harryaskham/feedback-cli" }
remote-cli = { git = "https://github.com/harryaskham/remote-cli" }