@sammorrowdrums/tool-cli
v0.6.1
Published
CLI and server for progressive MCP tool discovery via JSON-RPC
Downloads
89
Readme
tool-cli
Experimental. Part of the mcpi-ext experiment.
npm install -g @sammorrowdrums/tool-cli
The Football is not a weapon. The Football is the authority to use weapons. Whoever holds it can reach any server, call any tool, chain any result — but they must do so deliberately, one command at a time.
Why tool-cli exists
MCP gives agents tools. But the way those tools are exposed — all at once, all their schemas dumped into context — creates a problem. The agent sees everything, pays for everything, and still has to guess which tool to call.
tool-cli is progressive discovery for MCP, with all the composability of bash.
The agent already has a shell — tool-cli turns that shell into a gateway to every connected MCP server. Discovery happens in steps: servers → tools → schemas → calls. Each step pays only the tokens it needs. And because it's just a CLI binary, it composes with pipes, jq, loops, xargs — the entire Unix toolkit.
This is the nuclear football. It bestows executive control to the holder — every tool on every server is one command away. But like the real nuclear football, there's a dual lock. The agent holds the briefcase, but the harness holds the launch authority. The harness can gate calls, log them, or add human-in-the-loop confirmation at a single choke point. No individual actor goes rogue.
That's the design: bestow executive control to the agent, but keep the safety in the infrastructure.
Usage
Discovery is progressive — each step reveals the next:
tool-cli # List connected MCP servers
tool-cli github # List tools on a server
tool-cli github search_code # Show schema for a tool
tool-cli github search_code '{"query":"auth"}' # Call a toolShell composability
This is a CLI. It composes like one.
tool-cli github search_code '{"query":"auth"}' | jq '.items[].path'
tool-cli myserver list_items '{}' | jq -r '.[0].id' | \
xargs -I{} tool-cli myserver get_item '{"id":"{}"}'
for city in London Tokyo Paris; do
echo "=== $city ==="
tool-cli weather check_weather '{"city":"'"$city"'"}'
done
tool-cli github list_issues '{"repo":"owner/repo"}' --out /tmp/issues.jsonErrors go to stderr with exit code 1 — safe for && chaining and set -e scripts.
Resources
MCP resources and resource templates are reachable through the resource
subcommand group, multi-server by default. (resource is a reserved word, so a
server literally named resource is unsupported — acceptable here.)
tool-cli resource list # List resources across ALL servers, grouped by server
tool-cli resource list --server github # List one server's resources
tool-cli resource templates # List resource templates (all servers)
tool-cli resource templates --server github # List one server's templates
tool-cli resource read --server github "file:///readme.md" # Print text to stdout (pipeable)
tool-cli resource read --server github "file:///readme.md" | grep foo # Stream + grep
tool-cli resource read --server github "asset://logo" --out logo.png # Write body to a file
tool-cli resource read --server github "asset://logo" --meta # Metadata only
tool-cli resource read --server github "file:///readme.md" --json # Machine-readableBehaviour notes:
- Multi-server:
list/templateswith no--serverquery every connected server and group the output by server, mirroring how baretool-clilists servers. - Server resolution for
read: if exactly one server is connected,--serveris optional; with multiple servers it is required (the error lists the connected server names). <uri>is positional (resource read --server <name> <uri>);--uri <uri>is still accepted as an alias.- Text streams to stdout by default — pipeable and greppable.
- Binary content requires
--out: a base64blobis never written to stdout —readerrors (exit 1) and tells you to pass--out <path>, which writes the decoded raw bytes. --out: writes the body to the file (text as-is, binary decoded) and prints a one-line metadata summary plus the path instead of the body — parallels--outfor large tool results.skill://URIs are hidden fromresource listand refused byresource read— skills are a separate channel;readrejects askill://URI without contacting the server.- Resources are read-only; no HITL gating is involved.
Providers that don't implement resources keep working as tools-only — the new
ToolProvider methods (listResources, listResourceTemplates,
readResource) are optional, and the server returns a friendly
"does not support resources" error when they're absent.
Security — The Dual Lock
They pass the Football to the terminal. It is heavy with potential. Every tool on every server is one command away — but to trigger the tool, the harness must allow it.
The server uses token-based authentication and dynamic port allocation:
start()binds to a random available port and generates a 32-byte session token- Returns
{ port, token }— the caller sets these asTOOL_CLI_PORTandTOOL_CLI_TOKENenv vars for agent subprocesses - Every request must include
Authorization: Bearer <token>— rejected with 401 otherwise
This means:
- Concurrent sessions work — each gets its own port + token
- Random processes can't call tools — they don't have the token
- Cross-session isolation — one agent can't reach another's tools
- No individual actor goes rogue — the agent has reach, the harness has authority. Both must agree for the launch to proceed
Resource discovery (#1) is supported — see Resources above.
Architecture — How the Harness Connection Works
The CLI doesn't connect to MCP servers directly. It speaks JSON-RPC to a lightweight HTTP server that runs inside the agent harness. This is not a separate tool with its own auth — it's the harness itself, exposing its MCP connections over localhost for shell access.
flowchart LR
subgraph Agent Shell
CLI["tool-cli\n(CLI binary)"]
end
subgraph Agent Harness
RPC["ToolCliServer\n(JSON-RPC over HTTP)"]
MCM["MCP Client Manager"]
HITL["HITL / Gating\n(annotations, confirmation)"]
RPC -->|"dispatch"| HITL
HITL -->|"allowed"| MCM
end
CLI -->|"Bearer token\nover localhost"| RPC
MCM --> S1["MCP Server (stdio)"]
MCM --> S2["MCP Server (stdio)"]
MCM --> S3["MCP Server (HTTP)"]Every call — whether from the CLI, from a programmatic client, or from sandboxed code — routes back through the harness. This gives you:
- Agent activity logging preserved — because calls flow through the harness, not around it, the existing agent audit trail captures every tool invocation. Nothing bypasses the log
- Human-in-the-loop at one point — the harness can check tool annotations (
readOnlyHint,destructiveHint) and gate destructive calls through user confirmation - No special setup — the harness already manages MCP connections. tool-cli just gives the agent shell access to them
const { port, token } = await server.start();
// Set env vars so agent-spawned bash/tool-cli can authenticate
pi.setEnv("TOOL_CLI_PORT", String(port));
pi.setEnv("TOOL_CLI_TOKEN", token);The CLI and rpcCall() client read both from environment automatically.
Sandboxed / cross-host setups
By default the server binds on 127.0.0.1 and the client connects to 127.0.0.1 — keeping everything on a single loopback. If you're running the agent (which invokes tool-cli) inside a container or VM while the harness server runs on the host, the two 127.0.0.1s refer to different network namespaces and the connection will fail. Two env vars override the host on each side:
| Var | Side | Default | Use when |
| ------------------- | ------ | ----------- | ----------------------------------------------------------------------------- |
| TOOL_CLI_BIND_HOST | server | 127.0.0.1 | the server needs to listen on a non-loopback interface (e.g. 0.0.0.0) |
| TOOL_CLI_HOST | client | 127.0.0.1 | the client needs to reach the server at a different address |
# Host: bind on all interfaces so the container can reach in
TOOL_CLI_BIND_HOST=0.0.0.0 your-harness
# Container: point the CLI/client at the host
TOOL_CLI_HOST=host.docker.internal \
TOOL_CLI_PORT=… \
TOOL_CLI_TOKEN=… \
tool-cli github search_code '{"query":"auth"}'The bearer-token check still applies on every request — exposing the bind host beyond the loopback only widens reachability, not the auth model. Make sure the surrounding network is appropriately sandboxed.
Package Structure
Three entry points, consumable independently:
// Everything (server + client + types)
import { ToolCliServer, rpcCall } from "@sammorrowdrums/tool-cli";
// Server only — for building a harness that serves tool-cli requests
import { ToolCliServer } from "@sammorrowdrums/tool-cli/server";
import type { ToolProvider } from "@sammorrowdrums/tool-cli/server";
// Client only — for calling a running tool-cli server programmatically
import { rpcCall } from "@sammorrowdrums/tool-cli/client";ToolProvider Interface
The server takes a ToolProvider — a simple interface anyone can implement to bridge tool-cli to their MCP client, agent harness, or tool registry.
import { ToolCliServer } from "@sammorrowdrums/tool-cli/server";
import type { ToolProvider } from "@sammorrowdrums/tool-cli/server";
const provider: ToolProvider = {
getServerNames() {
return ["my-server"];
},
getTools(server) {
return [
{
name: "search",
description: "Search documents",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
},
];
},
async callTool(server, tool, args) {
const result = await myMcpClient.callTool(server, tool, args);
return { content: result.content };
},
};
const server = new ToolCliServer(provider);
await server.start();The full interface:
interface ToolProvider {
getServerNames(): string[];
getTools(server: string): ToolInfo[];
callTool(
server: string,
tool: string,
args: Record<string, unknown>,
): Promise<CallToolResult>;
}
interface ToolInfo {
name: string;
description?: string;
inputSchema: Record<string, unknown>;
outputSchema?: Record<string, unknown>;
annotations?: Record<string, unknown>;
}
interface CallToolResult {
content: unknown[];
isError?: boolean;
structuredContent?: Record<string, unknown>;
}Implementing a Server
The ToolProvider interface is intentionally minimal — three methods. Here's guidance for different integration scenarios:
MCP SDK (TypeScript/JavaScript)
If you're using @modelcontextprotocol/sdk, the provider wraps your Client instances:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
class McpToolProvider implements ToolProvider {
private clients = new Map<string, { client: Client; tools: ToolInfo[] }>();
getServerNames() {
return [...this.clients.keys()];
}
getTools(server) {
return this.clients.get(server)?.tools ?? [];
}
async callTool(server, tool, args) {
const { client } = this.clients.get(server)!;
const result = await client.callTool({ name: tool, arguments: args });
return {
content: result.content as unknown[],
structuredContent: result.structuredContent as
| Record<string, unknown>
| undefined,
};
}
}Other languages — implement the JSON-RPC server directly
You don't need this package to run a tool-cli compatible server. The protocol is 4 JSON-RPC methods over HTTP. Implement them in any language:
Go:
func handleRPC(w http.ResponseWriter, r *http.Request) {
var req struct {
Method string `json:"method"`
Params json.RawMessage `json:"params"`
ID int `json:"id"`
}
json.NewDecoder(r.Body).Decode(&req)
switch req.method {
case "listServers": // return { servers: [...] }
case "listTools": // parse server from params, return tools
case "describeTool": // parse server+tool, return schema
case "callTool": // parse server+tool+arguments, call MCP, return result
}
}Python (Flask):
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/", methods=["POST"])
def rpc():
req = request.json
method = req["method"]
params = req.get("params", {})
if method == "listServers":
result = {"servers": [{"name": "my-server", "toolCount": 5, "examples": ["search"]}]}
elif method == "listTools":
result = {"server": params["server"], "tools": [...]}
elif method == "describeTool":
result = {"name": params["tool"], "description": "...", "inputSchema": {...}}
elif method == "callTool":
result = call_mcp_tool(params["server"], params["tool"], params.get("arguments", {}))
else:
return jsonify({"jsonrpc": "2.0", "error": {"code": -32601, "message": "Not found"}, "id": req["id"]})
return jsonify({"jsonrpc": "2.0", "result": result, "id": req["id"]})Rust:
// Use axum, actix-web, or any HTTP framework
// Parse JSON-RPC request, match on method, return JSON-RPC response
// The 4 methods map directly to your MCP client's list/describe/call operationsKey implementation notes
- Bind to
127.0.0.1only — the server should not be exposed to the network without authentication TOOL_CLI_PORTenv var — the CLI reads this to find the serverstructuredContent— if the MCP tool returns structured output, include it alongsidecontent. The CLI prefers it for JSON piping- Error responses — use JSON-RPC error codes:
-32602for invalid params,-32601for unknown methods,-32603for internal errors - Tool annotations — include
readOnlyHint,destructiveHintetc. indescribeToolresponses. The harness can use these for HITL gating
Writing Clients in Other Languages
The JSON-RPC protocol is callable from any language. The CLI reads TOOL_CLI_PORT and TOOL_CLI_TOKEN from environment:
import os, requests
port = os.environ["TOOL_CLI_PORT"]
token = os.environ["TOOL_CLI_TOKEN"]
def tool_cli(method, **params):
r = requests.post(f"http://127.0.0.1:{port}", json={
"jsonrpc": "2.0", "method": method, "params": params, "id": 1
}, headers={"Authorization": f"Bearer {token}"})
result = r.json()
if "error" in result:
raise Exception(result["error"]["message"])
return result["result"]
servers = tool_cli("listServers")
tools = tool_cli("listTools", server="github")
result = tool_cli("callTool", server="github", tool="get_me", arguments={})JSON-RPC Protocol
The server binds to 127.0.0.1 on a dynamic port. The port and auth token are communicated via TOOL_CLI_PORT and TOOL_CLI_TOKEN environment variables.
| Method | Params | Returns |
| -------------- | ----------------------------- | ----------------------------------------------------------------- |
| listServers | — | { servers: [{ name, toolCount, examples }] } |
| listTools | { server } | { server, tools: [{ name, description, hasStructuredOutput }] } |
| describeTool | { server, tool } | { name, description, inputSchema, outputSchema?, annotations? } |
| callTool | { server, tool, arguments } | { content, isError?, structuredContent? } |
License
MIT
