Skip to main content

Portage — every CLI, one command from your AI assistant

Turn any command-line tool into an MCP server — by reading its own --help. No per-tool code.

CI tests integration coverage mypy ruff python mcp license

Quick start · How it works · Security · CLI · Config · Remote · Roadmap


What is this?

There are thousands of command-line tools — ffmpeg, jq, ripgrep, git, curl, your own scripts — and almost none of them have a Model Context Protocol server, so an AI assistant can't use them. Writing one by hand for every CLI is repetitive busywork.

Portage does it automatically. Point it at a CLI, and it:

<tool> --help  ──▶  parse (sections · usage · options · commands · type inference)
               ──▶  normalized CLI IR  ──▶  JSON Schema / MCP tool definitions
               ──▶  stdio (or HTTP) MCP server  ──▶  your AI assistant
                                              └──▶  safe, allow-listed execution

One generic pipeline handles every CLI. There is no if tool == "git" anywhere — a test enforces it.

Status — local MVP complete. Discovery, --help and man-page parsing, schema generation, the stdio/HTTP MCP server, the execution engine, and the full safety layer are implemented and tested (360 unit tests, 16 integration against real jq / ripgrep / curl / git / ffmpeg, ~91 % coverage, ruff + mypy --strict clean). The remote front door is scaffolded and verified locally; edge deploy is a manual one-liner.


⚡ Quick start

git clone https://github.com/jayaprakash2207/Portage-_MCP.git
cd Portage-_MCP
python -m venv .venv && . .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install -e ".[dev]"

portage doctor                     # environment check
portage inspect jq                 # discovery + parsed IR + generated tools, as JSON

Connect it to Claude

Claude Code

claude mcp add portage -- portage serve jq ripgrep curl git

Claude Desktop — add to claude_desktop_config.json:

{
  "mcpServers": {
    "portage": { "command": "portage", "args": ["serve", "jq", "ripgrep", "curl", "git"] }
  }
}

Restart the client. The generated tools appear in the tool list. With the default config a tool call returns a structured preview (validation + authorization + the exact argv) and runs nothing — enabling real execution is a deliberate, per-CLI opt-in (see Configuration).


🧭 How it works

flowchart TD
    A["CLI on your machine"] -->|"help capture"| 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 add → git_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.
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
  • enums from {a,b,c} / <a|b|c> / quoted "one of" lists
  • repeatable options, documented defaults, --arg NAME VALUE (arity 2)
  • positional args, 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
  • JSON Schema validation → per-value policy → allow-list → sandbox
  • POSIX rlimits + wall-clock timeout + output cap
  • structured audit events (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

Local first, remote ready

  • stdio for Claude Desktop / Code
  • portage serve --http → Starlette/uvicorn at /mcp
  • Cloudflare Worker front door (deploy/), verified end-to-end via wrangler dev

🔒 Security model

Portage lets an AI assistant run real commands, so the execution path is the product. Every tools/call goes 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) Wrap 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.

What is not yet covered
  • Container / restricted-user isolation on the deployed engine (the 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] Run the MCP server (stdio, or streamable HTTP at /mcp).
$ portage inspect jq --no-man
{
  "discovery": { "status": "ok", "help": { "command_display": "jq --help", ... } },
  "ir": { "name": "jq", "confidence": "high", "global_options": [ ... ], "positionals": [{ "name": "filter", "required": true }] },
  "tools": [ { "name": "jq", "input_schema": { "type": "object", "additionalProperties": false, ... } } ]
}

⚙ 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 } }
  ]
}

A ready-to-adapt version is in deploy/portage.example.json.


🌐 Remote deployment

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

MCP client ──HTTP──▶ Portage-Protocol (Cloudflare Worker: auth + reverse proxy)
                          │
                          ▼  HTTPS
                     Portage-Engine (this package: portage serve --http)
                          │
                          ▼
                     the real CLI
  • Engine: anywhere that runs Python 3.10+ and the CLIs — portage serve --config … --http --host 0.0.0.0 --port 8080.
  • Worker: deploy/cloudflare-worker/ — type-checks with tsc, and the whole chain is verified locally (test-local.ps1 / test-local.sh): health route, 401 without the bearer token, a real MCP initialize proxied through. Edge deploy: npx wrangler secret put PORTAGE_ENGINE_TOKEN && npx wrangler deploy.

Details in deploy/README.md.


🧪 Development

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

tests integ modules coverage loc


🗺 Roadmap

  • CLI discovery + --help capture
  • Layered --help parser → normalized IR
  • JSON Schema / MCP tool generation
  • stdio MCP server + tool discovery
  • Execution engine — structured argv, no shell
  • Safety layer — validation · value policy · default-deny allow-list · audit · dry-run
  • Man-page parsing + deterministic merge
  • End-to-end against 5 real CLIs, no per-tool code
  • POSIX resource limits + bubblewrap/firejail/docker sandbox (fail-closed)
  • Recursive subcommand discovery (git remote add → git_remote_add)
  • Streamable HTTP transport + Cloudflare Worker front door (verified locally)
  • Usage-line alternation decomposition ([-p | --paginate | -P] → options)
  • PyPI packaging (python -m build + twine check pass; trusted-publishing release workflow)
  • wrangler deploy to the edge (needs a hosted Engine URL)
  • Container / restricted-user isolation actually exercised on a Linux host
  • tbl-formatted man tables; a live docker-backend sandbox test
  • Cut the first GitHub Release → auto-publish to PyPI · submit to MCP 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; see CONTRIBUTING.md.

Metadata

Release files for portage-mcp 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for portage-mcp 0.1.0
File Size Uploaded Provenance Actions
portage_mcp-0.1.0.tar.gz 125.6 kB With provenance Details for portage_mcp-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for portage-mcp 0.1.0
File Interpreter ABI Platform Actions
portage_mcp-0.1.0-py3-none-any.whl Python 3 none any Details for portage_mcp-0.1.0-py3-none-any.whl

Total release size: 194.3 kB

Release files / portage_mcp-0.1.0.tar.gz

Download URL portage_mcp-0.1.0.tar.gz
Size 125.6 kB
Tags Source
SHA-256 checksum
How to use checksums
7dbd8bf6d6e402ffdaf5c0145b097e042bae4523589cad8fe955d64e1f1404e7
BLAKE2b-256 checksum
How to use checksums
3145a8209fa04d872084e85b8e3b8f61673b07087a34387d01bcae57210fdfd7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release files / portage_mcp-0.1.0-py3-none-any.whl

Download URL portage_mcp-0.1.0-py3-none-any.whl
Size 68.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8686a12b9298b81a143efc0086dec04fdae3e481210dddf98ca754d5f1ecfd63
BLAKE2b-256 checksum
How to use checksums
a008192c3b0274200f712b6247020cc066c5f4a044bdb5e8844d4722a0fce85f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Security for portage-mcp 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for portage_mcp-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page