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

One command, two surfaces How one typed command reaches both a person and an agent. The host registers a handler that takes a typed input and returns a typed output or a structured error. On the command line, write_json_result prints the result as a JSON envelope. Through the ToolRouter, the same handler becomes an MCP tool: McpServer answers tools/list with its name and a JSON Schema derived from the input type, and tools/call with the same envelope as structured content. your handlerInput → Result<Output, E>E: StructuredError CLIwrite_json_result ToolRouterschema from Input JsonEnvelope on stdoutstatus · meta · data | error McpServer (stdio)tools/list · tools/callstructuredContent = envelope a person an agent
The handler is written once. The input type's JSON Schema becomes the tool's 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

What the server does, as in the session above
a frame that is not JSONJSON-RPC -32700, id: null
an unknown method-32601
params that don't deserialize-32602
arguments of the wrong typea tool error, invalid_tool_arguments
a handler's own errorisError: true with its code
a handler that panicstool_panicked; later requests still served
a frame over 16 MiBdiscarded to the next newline, -32700
a JSON arraya 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

The five crates How the crates fit together. A host CLI depends on mcp-cli-core, which provides the JSON envelope, structured errors, the tool router and the MCP stdio server. configurable-cli, updatable-cli and feedback-cli build on it: each adds one concern and registers matching MCP tools on the host's router (config_status and friends, self_update_status, check and run, feedback_status and feedback_report). remote-cli does not depend on mcp-cli-core; it is used alongside it, giving a host a daemon that owns a typed cache and serves it to the host's CLI and MCP processes. your CLIone binary: CLI + MCP configurable-cliconfig show · init · importconfig_* tools updatable-cliupdate · statusself_update_* tools feedback-clierrors and timingsfeedback_* tools remote-clidaemon · cache · SSEalongside the core mcp-cli-coreenvelope · StructuredError · ToolRouter · McpServer
Same commands on both surfaces. The three crates built on the core register MCP tools that mirror their CLI subcommands or API.

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:

Replacing a running binary safely What updatable-cli's startup hook does with a staged update. It launches the candidate once with a validation argument and a timeout, and refuses it if it cannot run. It copies the current executable to tool_prev and records its sha256; if that copy fails, it refuses the update. It then swaps the candidate into place and re-executes. If the new binary fails to start, tool_prev is restored and re-hashed to prove the original bytes are back. A failure to restore is reported separately from a failure to start, with the preserved path for manual recovery. tool_nextstaged, sha256 ok validaterun once, timeout preservecopy to tool_prev promoteswap, re-exec runningnew version refusedold binary untouched restore tool_prevre-hash to prove it won't run can't copy fails to start
Never trade a working binary for one that cannot be undone. On Windows, where the running executable is locked, the update stays staged and the hook prints what to do.

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.

One owner of the data, many readers remote-cli's shape. In the daemon, the host's collector fetches from the upstream API and commits each result to a shared snapshot with a monotonic revision, persisted by atomic, owner-only replacement of a cache file. The daemon serves the snapshot over HTTP or a Unix socket, always with a bearer token: GET /snapshot, a Server-Sent Events stream of new revisions, GET /health, POST /refresh and an optional POST /command for confirmed mutations. CLI and MCP processes subscribe as clients and write the received revisions through to their own cache. If the daemon is gone for longer than a timeout, one client takes a lease file and collects in its place until the daemon returns with a newer revision. upstream API collectorhost code SharedSnapshotrevision n cache fileatomic, 0600 server/snapshot · /events (SSE)/health · /refresh · /command CLI and MCP clientssubscribe, write throughbearer token, any transport fallback leaseone client collects SSE revisions daemon gone
The same protocol over a socket or the network. Bearer authentication applies on Unix sockets too, so moving from local to remote changes only an address.

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

cargo test --all-features, run by scripts/site/capture.py
CratePassedFailedCommit
mcp-cli55081e5523
configurable-cli907d2234a
updatable-cli640115eff1
feedback-cli2505cfeded
remote-cli2600d05051

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" }