Turn any command-line tool into an MCP server — by reading its own --help. No per-tool code.
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,
--helpand 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 realjq/ripgrep/curl/git/ffmpeg, ~91 % coverage,ruff+mypy --strictclean). 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
|
Execution you can trust
|
|
Two documentation sources
|
Local first, remote ready
|
🔒 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 ("
--outputand 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 withtsc, and the whole chain is verified locally (test-local.ps1/test-local.sh): health route,401without the bearer token, a real MCPinitializeproxied 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
🗺 Roadmap
- CLI discovery +
--helpcapture - Layered
--helpparser → 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 checkpass; trusted-publishing release workflow) -
wrangler deployto the edge (needs a hosted Engine URL) - Container / restricted-user isolation actually exercised on a Linux host
-
tbl-formatted man tables; a livedocker-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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.