npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sammorrowdrums/tool-cli

v0.6.1

Published

CLI and server for progressive MCP tool discovery via JSON-RPC

Downloads

89

Readme

npm

tool-cli

Experimental. Part of the mcpi-ext experiment.

npm install -g @sammorrowdrums/tool-cli

A glowing briefcase marked 'tool-cli' being passed between hands in a dark corridor, trailing sparks of shell commands

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 tool

Shell 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.json

Errors 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-readable

Behaviour notes:

  • Multi-server: list / templates with no --server query every connected server and group the output by server, mirroring how bare tool-cli lists servers.
  • Server resolution for read: if exactly one server is connected, --server is 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 base64 blob is never written to stdout — read errors (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 --out for large tool results.
  • skill:// URIs are hidden from resource list and refused by resource read — skills are a separate channel; read rejects a skill:// 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:

  1. start() binds to a random available port and generates a 32-byte session token
  2. Returns { port, token } — the caller sets these as TOOL_CLI_PORT and TOOL_CLI_TOKEN env vars for agent subprocesses
  3. 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 operations

Key implementation notes

  • Bind to 127.0.0.1 only — the server should not be exposed to the network without authentication
  • TOOL_CLI_PORT env var — the CLI reads this to find the server
  • structuredContent — if the MCP tool returns structured output, include it alongside content. The CLI prefers it for JSON piping
  • Error responses — use JSON-RPC error codes: -32602 for invalid params, -32601 for unknown methods, -32603 for internal errors
  • Tool annotations — include readOnlyHint, destructiveHint etc. in describeTool responses. 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