Skip to main content
Glama

What is this?

Thousands of command-line tools — ffmpeg, jq, ripgrep, git, curl, your own scripts — and almost none have a Model Context Protocol server, so an AI assistant can't drive them. Hand-writing one per CLI is repetitive busywork.

Portage generates it from the CLI's own documentation:

 ┌──────────┐   --help / -h   ┌────────┐   ┌──────────┐   ┌──────────────┐   ┌─────────────┐
 │  <tool>  │ ───(+ man)────▶ │ parse  │─▶ │  CLI IR  │─▶ │ JSON Schema  │─▶ │  MCP server │─▶ AI
 └──────────┘                 └────────┘   └──────────┘   │ + arg specs  │   │ stdio / HTTP│
                                                          └──────────────┘   └──────┬──────┘
                                                                                    ▼
                                              validate → value policy → allow-list → sandbox → run

One code path handles every CLI — there is no if tool == "git" anywhere, and a test enforces it.

portage-mcp 0.1.0 is on PyPI. Discovery, --help and man-page parsing, schema generation, the stdio/HTTP MCP server, the execution engine and the full safety layer are done and CI-verified — 368 unit tests + 16 integration against real jq / ripgrep / curl / git / ffmpeg, ~91 % coverage, ruff + mypy --strict clean. Docker + Fly.io deploy recipes included; fly deploy / wrangler deploy are the only manual steps left.


Related MCP server: mcp-cli-catalog

⚡ Quick start

pip install portage-mcp
# Claude Code
claude mcp add portage -- portage serve jq ripgrep curl git

# Claude Desktop — claude_desktop_config.json
{ "mcpServers": { "portage": { "command": "portage",
                               "args": ["serve", "jq", "ripgrep", "curl", "git"] } } }

Restart the client — the generated tools show up. Out of the box a tool call returns a structured preview (validation + authorization + the exact argv) and runs nothing; real execution is a per-CLI opt-in (Configuration).

From source: git clone https://github.com/jayaprakash2207/Portage-_MCP && cd Portage-_MCP && pip install -e ".[dev]"


🎬 See it in action

ripgrep describes itself:

$ rg --help
    -A, --after-context <NUM>   Show NUM lines after each match.
    -e, --regexp <PATTERN>      A pattern to search for. This option can be provided multiple times...
    -i, --ignore-case          When this flag is provided, all patterns will be searched case insensitively.
    ...

Portage turns that into an MCP tool — no config, no code:

$ portage generate rg
{
  "name": "rg",
  "description": "ripgrep 15.x  recursively searches for lines matching a regex",
  "input_schema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "after_context": { "type": "integer", "description": "Show NUM lines after each match." },
      "regexp":        { "type": "array", "items": { "type": "string" },
                         "description": "A pattern to search for. ...provided multiple times..." },
      "ignore_case":   { "type": "boolean", "description": "...searched case insensitively..." },
      "pattern":       { "type": "string" }
    },
    "required": ["pattern"]
  }
}

Integers stay integers, --regexp (documented as repeatable) becomes an array, enums keep their choices, and every field carries the CLI's own prose so the model uses it correctly. portage inspect rg shows the whole chain — discovered help, parsed IR, generated tools, and a dry-run — without executing anything.


🤔 Why Portage?

Auto-generating MCP servers for existing apps splits three ways:

Approach

Ecosystem (2026)

OpenAPI → MCP

mature, crowded — FastMCP, AWS Labs, many others

not this

Browser automation → MCP

mature, crowded — Playwright MCP, Skyvern, Stagehand

not this

CLI (--help / man) → MCP

wide open — prior attempts are tiny, one-language, or need hand-written YAML per tool; none parse man pages; no reference implementation

this

Portage fills that gap with a safety model built in from the start, not bolted on.


🧭 How it works

flowchart TD
    A["CLI on your machine"] -->|"--help / -h"| B["discovery"]
    A -.->|"man tool"| B
    B --> C["parser<br/>sections · usage · options<br/>commands · type inference"]
    C --> D["normalized CLI IR (CliProgram)"]
    D --> E["schema generator"]
    E --> F["MCP tool defs<br/>JSON Schema + arg_specs"]
    F --> G["stdio / HTTP MCP server"]
    G <--> H["AI assistant"]
    H -->|"tools/call"| I["safety pipeline"]
    I --> J["schema validation"]
    J --> K["per-value policy"]
    K --> L["allow-list — default deny"]
    L --> M["safe argv build — no shell"]
    M --> N["sandbox + rlimits + timeout"]
    N --> O["subprocess"]
    O --> P["structured result + audit event"]

Each stage is its own module and depends only on the shared data model — parsing, schema generation, protocol handling and execution never import one another.

Layer

Module

What it does

Discovery

discovery.py

Resolve a CLI on PATH, capture --help-h safely (timeouts, help-on-stderr, non-zero exit, truncation).

Parse

parser/

Layered --help + man-page parser → CliProgram IR. Reports ParseConfidence; keeps anything it can't classify as an UnknownConstruct instead of guessing.

Merge

merge.py

Deterministically fold the man-page IR into the --help IR — richer descriptions win, --help stays authoritative for structure.

Schema

schema.py

IR → draft-2020-12 JSON Schema (additionalProperties: false), deterministic names (git remote addgit_remote_add), collision-safe, plus arg_specs reconstruction metadata.

Serve

service.py · server.py

Transport-agnostic registry + an mcp 2.x adapter. stdio and streamable HTTP with optional bearer auth.

Execute

executor.py

Structured argv builder + shell=False, stdin-closed, timeout-bounded runner. POSIX setrlimit.

Safety

validation · value_policy · authorization · sandbox · audit · pipeline

The one path from a tool call to a process.


✨ Features

Parsing that doesn't lie

  • GNU / POSIX / BSD option styles, --opt=VAL, --opt[=WHEN], --[no-]flag

  • usage-line alternations ([-p | --paginate | -P]) → individual options

  • enums from {a,b,c} / <a|b|c> / quoted "one of" lists

  • repeatable options → arrays, --arg NAME VALUE → arity-2

  • documented defaults, positionals, variadics, nested subcommands

  • ParseConfidence per program / command / option

  • unclassifiable fragments preserved, never invented

Execution you can trust

  • no code path builds a command string — ever

  • every value → distinct argv elements; flag & value never joined

  • default deny: nothing runs without an explicit allow-list

  • schema validation → per-value policy → allow-list → sandbox

  • bubblewrap / firejail / docker sandbox (fail-closed)

  • POSIX rlimits + wall-clock timeout + output cap

  • audit events record argument names, never values

  • dry-run preview of the exact argv

Two documentation sources

  • --help first, man <tool> where available

  • overstrike / ANSI cleanup, boilerplate-tail trimming

  • deterministic merge; conflicts surfaced, not dropped

  • recursive subcommand discovery (git remote addgit_remote_add)

Local first, remote ready

  • stdio for Claude Desktop / Code

  • portage serve --http → Starlette/uvicorn, bearer-auth on /mcp, open /healthz

  • Cloudflare Worker front door + Docker/Fly.io Engine recipes (deploy/)


🔒 Security model

Portage lets an AI assistant run real commands, so the execution path is the product. Every tools/call passes through, in order:

#

Gate

Guarantee

1

Schema validation

Arguments checked against the generated draft-2020-12 schema. Unknown fields, wrong types, bad enums, missing required → structured rejection.

2

Per-value policy

Optional value_rules: max length, required / forbidden regex, "path must resolve under". Broken patterns fail closed.

3

Allow-list — default deny

Nothing runs unless a CliConfig sets execution_enabled: true and an allowed_commands prefix matches and every emitted flag is in allowed_options. An empty rule never matches.

4

Safe argv construction

Values become individual argv elements from arg_specs. No shell. No --flag=value joining. No string interpolation. Verified inert against ; | && $() backticks newlines quotes redirection path-traversal.

5

Sandbox (opt-in)

bubblewrap / firejail / docker: read-only root, private /tmp, no network by default. mode: require refuses to run if no launcher is present.

6

Resource limits + timeout

POSIX setrlimit (CPU / memory / file size / nproc); every run bounded and killed on overrun; output capped.

7

Audit

A structured AuditEvent per call — timestamp, tool, executable, command path, validation & authorization results, mode, exit code, duration. Argument names only.

The executable is chosen by Portage from the tool definition and passed to the engine as an absolute path — an MCP caller cannot select or redirect it.

  • Container / restricted-user isolation on the deployed engine (sandbox wrappers exist; a permitted command still runs as the engine's user).

  • Cross-argument policy (--output and the positional must share a dir).

  • rlimits are POSIX-only; on Windows only the timeout + output cap apply.

  • --version-style flags that bypass a required positional can't be modelled in JSON Schema, so such a call is rejected as missing-required.

Full list in TEST_REPORT.md.


🖥 CLI

Command

Does

portage doctor [tool]

Environment check — interpreter, mcp SDK, man, optional CLI lookup.

portage discover <tool>

Capture the CLI's help text; print the structured DiscoveryResult.

portage parse <tool>

Discover + parse → the normalized CliProgram IR as JSON.

portage generate <tool>

Discover + parse → the generated MCP tool definitions + schemas.

portage inspect <tool>

One-shot debug view: discovery + man status + IR + tools + an optional dry-run. Nothing executes.

portage call <tool> <cli> --json '{…}'

Run one tool through the safety pipeline (dry-run unless --execute and policy permits).

portage serve <cli>… [--config F] [--http] [--http-token …]

Run the MCP server (stdio, or streamable HTTP at /mcp).


⚙ Configuration

portage serve --config portage.json. Everything not listed stays denied.

{
  "server_name": "portage",
  "clis": [
    {
      "name": "git",
      "use_man_page": true,
      "discover_subcommands": true,
      "subcommand_depth": 2,
      "policy": {
        "execution_enabled": true,
        "allowed_commands": [["git", "status"], ["git", "log"], ["git", "show"]],
        "allowed_options": ["--oneline", "--stat", "--short", "-n"],
        "timeout_seconds": 20,
        "value_rules": [{ "json_name": "max_count", "pattern": "\\d{1,4}" }],
        "resource_limits": { "cpu_seconds": 15, "memory_mb": 512, "max_processes": 64 },
        "sandbox": { "mode": "auto", "backend": "bubblewrap", "allow_network": false }
      }
    },
    { "name": "jq", "policy": { "execution_enabled": false } }   // discovery-only
  ]
}

Ready-to-adapt: deploy/portage.example.json · deploy/engine/portage.json.


🌐 Remote deployment

Cloudflare Workers can't run native binaries, so the design is two parts:

MCP client ──HTTP──▶ Portage-Protocol (Cloudflare Worker: bearer auth + reverse proxy)
                          │  HTTPS
                          ▼
                     Portage-Engine (portage serve --http)  ──▶  the real CLI
  • Enginepip install portage-mcp, then portage serve --config … --http. PORTAGE_HTTP_TOKEN turns on bearer auth on /mcp; /healthz stays open. Ready-made Dockerfile + fly.tomlfly deploy and you're up.

  • Workerdeploy/cloudflare-worker/, type-checked with tsc and verified end-to-end via wrangler dev (test-local.ps1 / test-local.sh): health route, 401 without the token, a real MCP initialize proxied through.

Full recipes in deploy/README.md.


🧪 Development

ruff check .                                   # lint
mypy                                           # type-check (strict)
pytest -q                                      # 368 unit tests — no network, no CLIs
pytest -q --run-integration -m integration     # + real jq/rg/curl/git/ffmpeg (+ bubblewrap)
pytest -q --cov=portage                        # coverage

CI runs all of the above on Python 3.10 / 3.11 / 3.12. See CONTRIBUTING.md and SECURITY.md.


🗺 Roadmap

  • Discovery, layered --help + man-page parser → normalized IR

  • JSON Schema / MCP tool generation — deterministic, collision-safe, arg_specs

  • stdio and streamable-HTTP MCP server

  • Execution engine — structured argv, no shell

  • Safety layer — validation · value policy · default-deny allow-list · sandbox · audit · dry-run

  • End-to-end against 5 real CLIs, no per-tool code

  • POSIX rlimits + bubblewrap/firejail/docker sandbox (fail-closed)

  • Recursive subcommand discovery · usage-line alternation decomposition

  • Cloudflare Worker front door + Docker/Fly.io Engine recipes (verified locally)

  • Published to PyPIpip install portage-mcp

  • fly deploy the Engine + wrangler deploy the Worker

  • Container / restricted-user isolation exercised on a Linux host

  • tbl-formatted man tables · submit to the MCP server registries


Why "Portage"?

A portage is carrying a boat overland between two waterways — bridging things that don't otherwise connect. Portage carries CLI functionality across into the MCP waterway so AI assistants can use it.

License

MIT — see LICENSE. Contributions welcome.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Turns any shell command into an MCP server by defining command-line tools in simple YAML files. Enables AI agents to execute system commands, security scanners, DevOps tools, and CLI utilities directly from chat interfaces.
    4
    -
  • A
    license
    Not graded
    quality
    D
    maintenance
    An MCP server that publishes CLI tools on your machine for discoverability by LLMs
    7 npm
    1
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI assistants to call any CLI tool by scanning its help output and serving it as an MCP server.
    1
    GPL 3.0