cli2mcp-gateway
v1.0.1
Published
Wrap any CLI as a Streamable HTTP MCP server with bearer / OAuth 2.1 / Auth0 auth, CORS, and rate limiting.
Downloads
356
Maintainers
Readme
cli2mcp-gateway
Wrap any CLI as a Streamable HTTP MCP server with bearer / OAuth 2.1 / Auth0 auth, CORS, and rate limiting. Dual transport: same CLI, exposed as either stdio MCP (for local agents) or HTTP MCP (for remote / team use).
What it does
cli2mcp-gateway wraps any CLI as an MCP server. On startup it runs <cli> --help and then walks nested command trees with <cli> <path...> --help until it reaches a leaf command, infers a JSON Schema, and exposes one MCP tool per leaf command.
| Transport | Use case | Entry point |
|---|---|---|
| stdio | Local agents (Claude Code, Codex, Cursor) spawning one process per session | cli2mcp-gateway serve |
| HTTP | Remote agents hitting a shared bearer / OAuth / Auth0 endpoint | cli2mcp-gateway serve --http |
The business layer (createServer) is shared between transports. Only the transport plumbing differs.
The CLI is spawned directly via execa() — no nested MCP client, no extra process hop. Schema inference lives in src/help-parser.js.
Windows note: the published command uses a small Node launcher so
cli2mcp-gatewayruns as a program instead of openingsrc/server.mjsin an editor.
Example: parsehub becomes 4 MCP tools
parsehub_parse ← matches `parsehub parse <url>`
parsehub_download ← matches `parsehub download <url> -o <path>`
parsehub_platforms ← matches `parsehub platforms`
parsehub_set ← matches `parsehub set <sub-subcommand> ...`Each tool's inputSchema is auto-derived from the corresponding --help output: positional arguments become a positional string array; flags with arg hints (e.g. -o PATH) become typed strings; boolean flags stay boolean.
Install
git clone https://github.com/drtx32/cli2mcp-gateway.git
cd cli2mcp-gateway
npm install
cp .env.example .env # then edit .envRequires Node.js ≥ 20.
Quick start — HTTP mode
The default. Lets any MCP-aware HTTP client reach your CLI remotely.
Option A: local install + .env file
git clone https://github.com/drtx32/cli2mcp-gateway.git
cd cli2mcp-gateway
npm install
cp .env.example .env # then edit .env
npm startOption B: global install + env vars on the command line (no .env file)
After the first npm release:
npm install -g cli2mcp-gatewayBefore the npm package is bootstrapped, install directly from GitHub:
npm install -g drtx32/cli2mcp-gatewayThen start it with env vars inline:
# one-shot start with env inline:
MCP_TOKEN=ph_token_12345678 \
CLI_COMMAND=parsehub \
CLI_CWD=/tmp/parsehub-downloads \
HOST=127.0.0.1 \
PORT=3101 \
PUBLIC_ENDPOINT=http://127.0.0.1:3101 \
cli2mcp-gateway serve --httpOr export once into your shell profile (~/.zshrc / ~/.bashrc):
export MCP_TOKEN=ph_token_12345678
export CLI_COMMAND=parsehub
export CLI_CWD=/tmp/parsehub-downloads
export HOST=127.0.0.1
export PORT=3101
export PUBLIC_ENDPOINT=http://127.0.0.1:3101
cli2mcp-gateway serve --httpserve flags
serve accepts only these mode switches:
--httpto use HTTP transport--stdioto force stdio transport
If you pass neither flag, serve defaults to stdio. winapp is not a supported subcommand.
The .env file is purely a convenience — every variable it can hold can be passed via the shell instead.
Sanity check:
curl http://127.0.0.1:3101/health
# → {"ok":true,"authMode":"bearer","toolNames":["parsehub_parse","parsehub_download","parsehub_platforms","parsehub_set"],"cliResolved":true}Full MCP handshake from any HTTP client (curl example):
# 1) initialize → get session id
SID=$(curl -s -i -X POST http://127.0.0.1:3101/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $MCP_TOKEN" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"x","version":"0"}},"id":1}' \
| grep -i "^mcp-session-id:" | awk '{print $2}' | tr -d '\r')
# 2) ack
curl -X POST http://127.0.0.1:3101/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $MCP_TOKEN" -H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3) call tools
curl -X POST http://127.0.0.1:3101/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $MCP_TOKEN" -H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":2}'Releases
CI runs the test suite on Node.js 22 and 24 for pull requests and pushes to main.
Pushing a v* tag runs the release checks and publishes the matching package version
to npm through GitHub Actions trusted publishing (OIDC). The initial npm publication
and trusted-publisher setup are documented in docs/releasing.md.
The npm publish workflow does not require a GitHub Deployment or a long-lived npm token.
Quick start — stdio mode
For local agents that spawn one MCP process per session. No HTTP, no token — the parent agent is trusted.
npm run stdioOr via Claude Code / Codex MCP config:
{
"mcpServers": {
"my-cli": {
"command": "node",
"args": ["/path/to/cli2mcp-gateway/bin/cli2mcp-gateway.cjs", "serve"]
}
}
}Configuration (env vars)
All config flows through environment variables. See .env.example for the full list. Key ones:
| Var | Default | Notes |
|---|---|---|
| HOST | 127.0.0.1 | 0.0.0.0 to expose on LAN |
| PORT | 3100 | HTTP listen port |
| AUTH_MODE | bearer | bearer | oauth | both |
| MCP_TOKEN | (random per boot) | Static bearer token. Set in production. |
| CLI_COMMAND | rg | Downstream CLI binary on $PATH. Must support --help. |
| CLI_CWD | process cwd | Working directory passed to the CLI |
| CLI_SUBCOMMANDS | (auto) | Comma-separated list. Empty = auto-discover from <cli> --help. |
| CLI_TIMEOUT_MS | 60000 | Per-call CLI timeout (execa). |
| PUBLIC_ENDPOINT | http://$HOST:$PORT | Required https://... when OAuth enabled |
| OAUTH_USERNAME / OAUTH_PASSWORD | admin / change-me | Replace before any non-local use |
| AUTH0_ISSUER / AUTH0_AUDIENCE | (empty) | Enables Auth0 JWT verification |
| ALLOWED_HOSTS | (empty) | Host header allowlist (anti-DNS-rebinding) |
| MCP_ALLOWED_IPS | (empty) | Source-IP allowlist. Comma-separated. Accepts bare IP (1.2.3.4), IP:port (1.2.3.4:3101), CIDR (10.0.0.0/8), and IPv6. Empty = allow all. |
| CORS_ORIGINS | (empty) | Comma-separated allowlist; empty = browser clients blocked |
| RATE_LIMIT_RPS / RATE_LIMIT_BURST | 20 / 40 | Per-IP token bucket |
| STDIO_BOOT_TIMEOUT_SEC | 0 | 0 = wait forever for <cli> --help (recommended) |
| STDIO_IDLE_TIMEOUT_SEC | 0 | 0 = no idle exit (stdio only) |
Auth modes
bearer— single staticMCP_TOKEN. Easiest for personal/team use.oauth— full OAuth 2.1 with PKCE, dynamic client registration, refresh tokens. Use this for ChatGPT / Claude Desktop / browser clients.both— accept either. Handy during migration.- Auth0 — add
AUTH0_ISSUER+AUTH0_AUDIENCEto verify Auth0 JWTs alongside the others.
OAuth is required to be served over HTTPS in non-localhost deployments; the gateway refuses to start otherwise.
Multi-service box.yaml mode
For multiple upstreams, use a box.yaml file. The gateway supports these
inbound adapters:
cli— a non-MCP CLI. The gateway can exposehelp + run(tool_mode: dual) orhelp + recursively discovered leaf commands(the default).mcp-stdio— an MCP server launched as a stdio child process. For a CLI whose MCP entry point is a subcommand, put that subcommand inargs, for exampleargs: [mcp].mcp-http— an upstream Streamable HTTP MCP server. Useheadersfor bearer tokens or other gateway-to-gateway headers;auth.tokencan generate anAuthorization: Bearer ...header.auto— probe the command as MCP stdio first (including a conventionalmcpsubcommand), then fall back to the help-drivencliadapter.
Tool exposure mode (tool_mode)
Each cli / auto / mcp-stdio / mcp-http service can declare how the
gateway exposes it. Pick one:
| tool_mode | Tools exposed | workspace.path |
|--------------|----------------------------------------------------------------------------------------------|------------------|
| normal | All recursively-discovered leaf tools + synthetic help. Optionally adds <cli>_workspace if workspace.path is set. | optional |
| dual | Only <cli>_help and <cli>_run. workspace.path is ignored even if configured. | ignored |
| triple | Exactly <cli>_help, <cli>_run, and <cli>_workspace. workspace.path is required — boot fails with a clear error otherwise. | required |
Legacy dual_tool_mode: true still works and is equivalent to
tool_mode: dual. When both flags are present, the explicit tool_mode
wins.
The names in the table are per-service tool names. With the default multi-service
aggregation, the public name also includes the service name and __ separator;
the example below shows the resulting names.
Managed workspace
When workspace.path is configured and tool_mode is not dual, the
gateway exposes a synthetic <cli>_workspace tool that gives the agent
read-only access to a single canonical absolute directory. Subcommands:
<cli>_workspace(subcommand="dir")— returns the canonical absolute path so the agent can pass it directly to downstream CLI flags like--output,-o,--save.<cli>_workspace(subcommand="list", path?, recursive?)— bounded recursive listing.max_list_entriescaps the result.<cli>_workspace(subcommand="stat", path)— metadata for one entry.<cli>_workspace(subcommand="read", path)— returns the file as a typed MCP content block: image content for PNG / JPEG / GIF / WebP, text content for UTF-8 / JSON / markdown / etc., and a structured error for anything binary or oversized.
The workspace is strictly read-only: no write / delete / move / copy /
execute support, and the gateway never fetches URLs into the workspace.
.., absolute paths outside the configured root, symlinks that resolve
outside the root, and hidden files (leading .) are all rejected with a
clear actionable error before any read. follow_symlinks defaults to
false; when set to true, every descent still verifies containment.
max_read_bytes enforces a hard cap on file reads — the gateway never
silently truncates file content.
Relative paths in workspace.path resolve against the directory holding
box.yaml, not against process cwd. The directory is auto-created at
boot if missing.
Example:
name: my-box
services:
local_cli:
adapter: cli
command: my-cli
tool_mode: triple
workspace:
path: ./workspace-local
max_read_bytes: 16777216
max_list_entries: 200
follow_symlinks: false
allow_hidden_files: false
pure_mcp:
adapter: mcp-stdio
command: npx
args: [-y, my-mcp-server]
remote_mcp:
adapter: mcp-http
url: https://mcp.example.com/mcp
auth:
type: bearer
token: ${UPSTREAM_MCP_TOKEN}
transport:
type: http
host: 127.0.0.1
port: 3100In triple mode the agent sees three tools: local_cli__my-cli_help,
local_cli__my-cli_run, and local_cli__my-cli_workspace with the example
above and default naming.
The agent is expected to call
local_cli__my-cli_workspace(subcommand="dir")
to discover the canonical absolute workspace root, and then pass it to the
downstream CLI's output / save flags. The downstream CLI does not need to
read any CLI2MCP_* environment variable.
Local runtime management
serve remains the foreground entrypoint. For Windows-friendly background
management, register HTTP boxes with the local runtime daemon:
cli2mcp-gateway create --name my-box --config .\box.yaml --start
cli2mcp-gateway list
cli2mcp-gateway get <id>
cli2mcp-gateway logs <id> --tail 100
cli2mcp-gateway restart <id>
cli2mcp-gateway stop <id>
cli2mcp-gateway start <id>
cli2mcp-gateway update <id> --config .\box-v2.yaml
cli2mcp-gateway rename <id> my-box-v2
cli2mcp-gateway delete <id>The daemon and managed gateway processes are detached from the terminal and
use a per-user state directory (%LOCALAPPDATA%\cli2mcp-gateway on Windows,
or $XDG_STATE_HOME/cli2mcp-gateway on other platforms). get includes the
registered instance metadata and the current YAML text. Runtime management is
intended for http/sse boxes; use serve directly for stdio mode.
How a downstream tool call flows
HTTP client gateway downstream CLI
│ │ │
│ POST /mcp (Bearer + JSON-RPC) │ │
├───────────────────────────────────▶│ │
│ │ execa(cmd, [sub, --flag value]) │
│ ├─────────────────────────────────▶│
│ │ │ spawn $CLI_COMMAND
│ │ │ cwd = $CLI_CWD
│ │ │ args = buildArgv(shape, args)
│ │◀─────────────────────────────────┤
│ │ CLI stdout / stderr │
│◀───────────────────────────────────┤ │
│ 200 + SSE / JSON │ │buildArgv(shape, args) translates the MCP tools/call.arguments object into argv:
- Each flag defined in the inferred schema becomes
--<long> <value>when truthy / non-empty. - Positionals come from
arguments.positional(a string array) in declared order.
Concrete example
Calling parsehub_download to fetch a Xiaohongshu note:
{
"name": "parsehub_download",
"arguments": {
"positional": ["https://www.xiaohongshu.com/discovery/item/6a5ccd97..."],
"o": "/tmp/downloads"
}
}becomes:
parsehub download https://www.xiaohongshu.com/discovery/item/6a5ccd97... -o /tmp/downloadsThe CLI's stdout (file list, JSON, etc.) is returned verbatim as the MCP tool result.
Why a custom gateway?
Wrapping a CLI as an MCP tool has its own set of pain points beyond what the raw CLI provides. This gateway adds:
- HTTP transport with bearer / OAuth 2.1 / Auth0 (raw CLIs are stdio only)
- Per-subcommand tool discovery from
--helpoutput (no manual schema authoring) - Per-IP rate limiting
- Graceful shutdown with drain timeout
- stdio lifecycle hardening (parent watchdog, idle timeout, boot timeout)
- Centralized env-driven config with sensible defaults
Files
cli2mcp-gateway/
├── src/
│ ├── server.mjs # dual-transport entry + HTTP server
│ ├── box-config.mjs # YAML box loader + zod schema
│ ├── tool-aggregator.mjs # per-service → flat tool list merger
│ ├── workspace.mjs # managed single-root read-only workspace runtime
│ ├── help-parser.js # argparse-style --help → JSON Schema inference
│ ├── service-env.mjs # per-service env merging (strips task tokens)
│ └── adapters/ # mcp-stdio / mcp-http upstream adapters
├── test/ # node --test specs
├── test-fixtures/ # stub MCP servers for e2e tests
├── .env.example
├── .gitignore
├── package.json
├── README.md
└── LICENSEThe CLI runs in whatever directory you point it at via CLI_CWD — no sandbox/ is created by default. Create that path yourself if you want a sandboxed workspace. For multi-service box.yaml deployments, configure a workspace.path per service instead — see Managed workspace.
TODO
- [ ] Normalize generated MCP tool names to identifier-safe names: convert
-and other non-identifier characters in CLI command path segments to_for the MCP-facing tool name, while preserving the original CLI command path for dispatch. Example:ashare jygs industrial-chains→ MCP toolashare_jygs__industrial_chains, with dispatch path['jygs', 'industrial-chains']. - [ ] Add regression tests covering nested commands with hyphens to ensure tool discovery,
tools/list, andtools/callremain compatible across MCP clients that map tool names to language-level function identifiers.
License
MIT © drtx32
