universal-cli-mcp-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. cli_mcp/__init__.py +32 -0
  2. cli_mcp/cli/__init__.py +7 -0
  3. cli_mcp/cli/main.py +410 -0
  4. cli_mcp/config/__init__.py +31 -0
  5. cli_mcp/config/loader.py +133 -0
  6. cli_mcp/config/settings.py +171 -0
  7. cli_mcp/core.py +133 -0
  8. cli_mcp/detect.py +143 -0
  9. cli_mcp/discovery/__init__.py +49 -0
  10. cli_mcp/discovery/base.py +129 -0
  11. cli_mcp/discovery/engine.py +238 -0
  12. cli_mcp/discovery/grammars.py +170 -0
  13. cli_mcp/discovery/help_parser.py +627 -0
  14. cli_mcp/discovery/introspect.py +276 -0
  15. cli_mcp/discovery/manifest.py +161 -0
  16. cli_mcp/discovery/node_reflection.py +126 -0
  17. cli_mcp/discovery/pipeline.py +108 -0
  18. cli_mcp/discovery/python_reflection.py +54 -0
  19. cli_mcp/discovery/reflection_payload.py +92 -0
  20. cli_mcp/discovery/registry.py +124 -0
  21. cli_mcp/errors.py +37 -0
  22. cli_mcp/executor/__init__.py +19 -0
  23. cli_mcp/executor/approval.py +64 -0
  24. cli_mcp/executor/argv.py +63 -0
  25. cli_mcp/executor/runner.py +384 -0
  26. cli_mcp/executor/sandbox.py +31 -0
  27. cli_mcp/mcp/__init__.py +22 -0
  28. cli_mcp/mcp/server.py +80 -0
  29. cli_mcp/mcp/tool_adapter.py +120 -0
  30. cli_mcp/models.py +213 -0
  31. cli_mcp/plugins/__init__.py +41 -0
  32. cli_mcp/schema/__init__.py +31 -0
  33. cli_mcp/schema/docgen.py +104 -0
  34. cli_mcp/schema/jsonschema.py +104 -0
  35. cli_mcp/schema/openapi.py +57 -0
  36. cli_mcp/schema/validate.py +84 -0
  37. cli_mcp/security/__init__.py +26 -0
  38. cli_mcp/security/policy.py +91 -0
  39. cli_mcp/security/readonly.py +173 -0
  40. cli_mcp/transport/__init__.py +49 -0
  41. cli_mcp/version.py +3 -0
  42. universal_cli_mcp_server-0.1.0.dist-info/METADATA +381 -0
  43. universal_cli_mcp_server-0.1.0.dist-info/RECORD +46 -0
  44. universal_cli_mcp_server-0.1.0.dist-info/WHEEL +4 -0
  45. universal_cli_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
  46. universal_cli_mcp_server-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,91 @@
1
+ """Access policy: the single place that decides whether a command may run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from cli_mcp.config.settings import SecuritySettings
8
+ from cli_mcp.errors import SecurityViolation
9
+ from cli_mcp.models import CommandSpec
10
+ from cli_mcp.security.readonly import (
11
+ RiskAssessment,
12
+ allowed_by_override,
13
+ assess,
14
+ binary_basename,
15
+ is_blocked_binary,
16
+ matches_block_patterns,
17
+ scan_argv,
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Decision:
23
+ """Result of evaluating a request against the policy."""
24
+
25
+ allowed: bool
26
+ requires_approval: bool
27
+ reason: str | None = None
28
+
29
+
30
+ class AccessPolicy:
31
+ """Enforce the allow list, read-only mode and approval requirements."""
32
+
33
+ def __init__(self, settings: SecuritySettings) -> None:
34
+ """Bind the policy to its security settings."""
35
+ self.settings = settings
36
+
37
+ def authorize_binary(self, binary: str) -> None:
38
+ """Reject binaries that are never allowed or not on the allow list."""
39
+ name = binary_basename(binary)
40
+ if is_blocked_binary(binary):
41
+ raise SecurityViolation(f"binary {binary!r} is on the never-expose list")
42
+ if self.settings.allowlist and name not in self.settings.allowlist:
43
+ raise SecurityViolation(
44
+ f"binary {binary!r} is not on the allow list ({', '.join(self.settings.allowlist)})"
45
+ )
46
+
47
+ def evaluate(self, binary: str, spec: CommandSpec, argv: list[str]) -> Decision:
48
+ """Decide whether *argv* for *spec* may run, and whether approval is needed."""
49
+ assessment: RiskAssessment = assess(binary, spec)
50
+ blocked_reason = matches_block_patterns(argv, self.settings.block_patterns)
51
+ override = not self.settings.readonly and allowed_by_override(argv, self.settings.allow_patterns)
52
+ if blocked_reason and not override:
53
+ return Decision(False, False, blocked_reason)
54
+
55
+ dangerous = scan_argv(argv)
56
+ if dangerous and self.settings.readonly:
57
+ return Decision(False, False, f"read-only mode: {dangerous}")
58
+
59
+ if self.settings.readonly and assessment.destructive:
60
+ return Decision(False, False, f"read-only mode: {assessment.reason}")
61
+
62
+ if self.settings.readonly and argv not in self.settings.readonly_argv:
63
+ return Decision(False, False, "read-only mode: exact invocation is not in security.readonly_argv")
64
+
65
+ requires_approval = self.settings.approval or assessment.destructive
66
+ return Decision(True, requires_approval, None)
67
+
68
+ def assess(self, binary: str, spec: CommandSpec) -> RiskAssessment:
69
+ """Risk classification of a command, before arguments are built."""
70
+ return assess(binary, spec)
71
+
72
+ def authorize_execution(self, binary: str, spec: CommandSpec, argv: list[str]) -> None:
73
+ """Raise :class:`SecurityViolation` when the request is not allowed."""
74
+ decision = self.evaluate(binary, spec, argv)
75
+ if not decision.allowed:
76
+ raise SecurityViolation(decision.reason or "command rejected by security policy")
77
+
78
+ @property
79
+ def summary(self) -> str:
80
+ """Human-readable description of the active policy."""
81
+ parts = []
82
+ if self.settings.allowlist:
83
+ parts.append(f"allow list: {', '.join(self.settings.allowlist)}")
84
+ if self.settings.readonly:
85
+ parts.append(f"read-only ({len(self.settings.readonly_argv)} exact invocations)")
86
+ if self.settings.approval:
87
+ parts.append(f"approval: {self.settings.approval_mode}")
88
+ if self.settings.sandbox:
89
+ parts.append(f"sandbox: {self.settings.sandbox.backend} ({self.settings.sandbox.image})")
90
+ parts.append(f"timeout {self.settings.timeout_seconds:g}s")
91
+ return "; ".join(parts) or "no restrictions"
@@ -0,0 +1,173 @@
1
+ """Read-only rules: which binaries and verbs are considered dangerous.
2
+
3
+ The tables are intentionally explicit and overridable from configuration
4
+ (``security.block_patterns`` adds checks, ``security.allow_patterns`` re-admits
5
+ rules that would otherwise reject a legitimate command).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ from cli_mcp.models import CommandSpec
14
+
15
+ #: Binaries that must never be exposed as tools.
16
+ BLOCKED_BINARIES: frozenset[str] = frozenset(
17
+ {
18
+ "rm",
19
+ "rmdir",
20
+ "del",
21
+ "erase",
22
+ "rd",
23
+ "mkfs",
24
+ "format",
25
+ "diskpart",
26
+ "dd",
27
+ "shutdown",
28
+ "reboot",
29
+ "halt",
30
+ "poweroff",
31
+ "init",
32
+ "sudo",
33
+ "su",
34
+ "runas",
35
+ "kill",
36
+ "killall",
37
+ "pkill",
38
+ "chmod",
39
+ "chown",
40
+ "reg",
41
+ "netsh",
42
+ "iptables",
43
+ "systemctl",
44
+ "sc",
45
+ }
46
+ )
47
+
48
+ #: Subcommand verbs that mutate state and are rejected in read-only mode.
49
+ DESTRUCTIVE_VERBS: frozenset[str] = frozenset(
50
+ {
51
+ "rm",
52
+ "del",
53
+ "delete",
54
+ "remove",
55
+ "destroy",
56
+ "purge",
57
+ "prune",
58
+ "drop",
59
+ "uninstall",
60
+ "shutdown",
61
+ "reboot",
62
+ "reset",
63
+ "clean",
64
+ "push",
65
+ "apply",
66
+ "destroy-all",
67
+ "deregister",
68
+ "kill",
69
+ "stop",
70
+ "rm-rf",
71
+ }
72
+ )
73
+
74
+ #: Flags that turn a benign command into a destructive one.
75
+ DESTRUCTIVE_FLAGS: frozenset[str] = frozenset(
76
+ {
77
+ "--force",
78
+ "-f",
79
+ "--hard",
80
+ "--no-preserve-root",
81
+ "--destroy",
82
+ "--dangerously",
83
+ "--yes-all",
84
+ "-rf",
85
+ "-fr",
86
+ "--recurse-remove",
87
+ }
88
+ )
89
+
90
+ #: Regexes matched against the fully assembled argv.
91
+ DANGEROUS_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
92
+ re.compile(pattern, re.IGNORECASE)
93
+ for pattern in (
94
+ r"\brm\s+-[a-z]*[rf]",
95
+ r"\brm\b",
96
+ r"\bdd\b.*of=",
97
+ r"\bmkfs\b",
98
+ r"\bformat\b",
99
+ r"\bdiskpart\b",
100
+ r"\bshutdown\b",
101
+ r"\breboot\b",
102
+ r"of=/dev/(sd|hd|nvme)",
103
+ r"\bdel\b\s+[a-z]:",
104
+ r"\bchmod\b\s+-[a-z]*R",
105
+ )
106
+ )
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class RiskAssessment:
111
+ """Why a command is considered risky, if it is."""
112
+
113
+ destructive: bool
114
+ reason: str | None = None
115
+
116
+
117
+ def binary_basename(name: str) -> str:
118
+ """Strip paths and Windows extensions so allow lists can use plain names."""
119
+ cleaned = name.replace("\\", "/").split("/")[-1]
120
+ if cleaned.lower().endswith((".exe", ".cmd", ".bat", ".com")):
121
+ cleaned = cleaned[:-4]
122
+ return cleaned.lower()
123
+
124
+
125
+ def is_blocked_binary(name: str) -> bool:
126
+ """Whether the binary itself is on the never-expose list."""
127
+ return binary_basename(name) in BLOCKED_BINARIES
128
+
129
+
130
+ def is_destructive_verb(verb: str) -> bool:
131
+ """Whether a subcommand verb mutates state."""
132
+ return verb.lower().strip() in DESTRUCTIVE_VERBS
133
+
134
+
135
+ def scan_argv(argv: list[str]) -> str | None:
136
+ """Return a reason if the assembled argv matches a dangerous pattern."""
137
+ joined = " ".join(argv)
138
+ for pattern in DANGEROUS_PATTERNS:
139
+ if pattern.search(joined):
140
+ return f"matches dangerous pattern {pattern.pattern}"
141
+ return None
142
+
143
+
144
+ def assess(binary: str, spec: CommandSpec) -> RiskAssessment:
145
+ """Classify a discovered command for read-only and approval decisions."""
146
+ if is_blocked_binary(binary):
147
+ return RiskAssessment(True, f"binary {binary} is on the never-expose list")
148
+ for verb in spec.path:
149
+ if is_destructive_verb(verb):
150
+ return RiskAssessment(True, f"subcommand {verb!r} is a destructive verb")
151
+ for param in spec.parameters:
152
+ flag = param.cli_flag.lower()
153
+ short = f"-{param.short}".lower() if param.short else ""
154
+ if flag in DESTRUCTIVE_FLAGS or short in DESTRUCTIVE_FLAGS:
155
+ return RiskAssessment(True, f"parameter {param.cli_flag} is a destructive flag")
156
+ if spec.destructive:
157
+ return RiskAssessment(True, "the manifest marked this command as destructive")
158
+ return RiskAssessment(False, None)
159
+
160
+
161
+ def allowed_by_override(argv: list[str], allow_patterns: list[str]) -> bool:
162
+ """Whether a user-supplied allow pattern re-admits the command."""
163
+ joined = " ".join(argv)
164
+ return any(re.search(pattern, joined, re.IGNORECASE) for pattern in allow_patterns)
165
+
166
+
167
+ def matches_block_patterns(argv: list[str], block_patterns: list[str]) -> str | None:
168
+ """Return a reason if a user-supplied block pattern matches."""
169
+ joined = " ".join(argv)
170
+ for pattern in block_patterns:
171
+ if re.search(pattern, joined, re.IGNORECASE):
172
+ return f"matches security.block_patterns entry {pattern!r}"
173
+ return None
@@ -0,0 +1,49 @@
1
+ """Transport selection: stdio, Streamable HTTP, JSON-over-HTTP and SSE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp.server.mcpserver import MCPServer
6
+
7
+ from cli_mcp.config.settings import ServerSettings
8
+ from cli_mcp.errors import TransportError
9
+
10
+ SSE_MESSAGE_PATH = "/messages/"
11
+
12
+
13
+ def run_server(server: MCPServer, settings: ServerSettings) -> None:
14
+ """Start *server* on the configured transport and block until it exits."""
15
+ if settings.transport == "stdio":
16
+ server.run("stdio")
17
+ return
18
+ if settings.transport == "streamable-http":
19
+ server.run(
20
+ "streamable-http",
21
+ host=settings.host,
22
+ port=settings.port,
23
+ streamable_http_path=settings.path,
24
+ json_response=settings.json_response,
25
+ stateless_http=settings.stateless_http,
26
+ )
27
+ return
28
+ if settings.transport == "http":
29
+ # Plain HTTP clients get JSON responses; MCP clients use the same endpoint.
30
+ server.run(
31
+ "streamable-http",
32
+ host=settings.host,
33
+ port=settings.port,
34
+ streamable_http_path=settings.path,
35
+ json_response=True,
36
+ stateless_http=settings.stateless_http,
37
+ )
38
+ return
39
+ if settings.transport == "sse":
40
+ # Server-Sent Events is deprecated in the MCP spec; prefer streamable-http.
41
+ server.run(
42
+ "sse",
43
+ host=settings.host,
44
+ port=settings.port,
45
+ sse_path=settings.path,
46
+ message_path=SSE_MESSAGE_PATH,
47
+ )
48
+ return
49
+ raise TransportError(f"unknown transport {settings.transport!r}; use stdio, http, streamable-http or sse")
cli_mcp/version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version, kept in one place so tests and the CLI agree on it."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,381 @@
1
+ Metadata-Version: 2.5
2
+ Name: universal-cli-mcp-server
3
+ Version: 0.1.0
4
+ Summary: Swagger for command line tools — expose any CLI as a fully compliant MCP server
5
+ Project-URL: Homepage, https://github.com/AndreasHeine/cli-mcp
6
+ Project-URL: Repository, https://github.com/AndreasHeine/cli-mcp
7
+ Project-URL: Issues, https://github.com/AndreasHeine/cli-mcp/issues
8
+ Author: Andreas Heine
9
+ License: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: cli,discovery,json-schema,mcp,model-context-protocol,openapi
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Requires-Python: >=3.13
20
+ Requires-Dist: httpx>=0.27
21
+ Requires-Dist: mcp>=1.9
22
+ Requires-Dist: pydantic>=2.9
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: rich>=13.9
25
+ Requires-Dist: typer>=0.15
26
+ Requires-Dist: uvicorn>=0.30
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.13; extra == 'dev'
29
+ Requires-Dist: pre-commit>=4.0; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=6.0; extra == 'dev'
32
+ Requires-Dist: pytest>=8.3; extra == 'dev'
33
+ Requires-Dist: ruff>=0.8; extra == 'dev'
34
+ Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # cli-mcp
38
+
39
+ **Swagger for Command Line Tools**
40
+
41
+ `cli-mcp` exposes command-line applications through a
42
+ [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server — without you
43
+ writing a single line of MCP code.
44
+
45
+ Like Swagger/OpenAPI describes an HTTP API, cli-mcp describes CLI commands, their arguments,
46
+ and their results as structured tools. It also executes those tools with argument validation
47
+ and configured execution controls, and can export OpenAPI documentation.
48
+ Automatic discovery depends on the CLI's help format or supported framework; use a manifest
49
+ when explicit metadata is needed. Windows `.bat`/`.cmd` targets are not supported.
50
+
51
+ ```text
52
+ Today: CLI Tool -> custom MCP wrapper -> MCP client
53
+ With cli-mcp: CLI Tool -> cli-mcp -> MCP client
54
+ ```
55
+
56
+ ```bash
57
+ cli-mcp expose git
58
+ cli-mcp expose kubectl
59
+ cli-mcp expose docker
60
+ cli-mcp expose opcua-nodeset-linker
61
+ ```
62
+
63
+ ```text
64
+ ✓ Binary detected
65
+ ✓ Commands discovered
66
+ ✓ Schemas generated
67
+ ✓ MCP tools published
68
+ ✓ MCP server started
69
+ ```
70
+
71
+ Works with Claude, Copilot, Cursor, VS Code, Cline, Roo Code, Open WebUI and any other
72
+ MCP-compatible client.
73
+
74
+ ---
75
+
76
+ ## Install
77
+
78
+ Requires Python 3.13 or newer. CI covers Windows and Linux with Python 3.13 and 3.14.
79
+
80
+ ```bash
81
+ pip install cli-mcp
82
+ ```
83
+
84
+ or, for development:
85
+
86
+ ```bash
87
+ git clone https://github.com/AndreasHeine/cli-mcp
88
+ cd cli-mcp
89
+ uv sync --extra dev
90
+ uv run cli-mcp --help
91
+ ```
92
+
93
+ ## Quick start
94
+
95
+ Only expose trusted programs. These quick-start examples do **not** enable read-only mode
96
+ or sandboxing; discovery itself runs help commands on the host.
97
+
98
+ ```bash
99
+ # Discover commands and serve them over stdio (the default transport)
100
+ cli-mcp expose git
101
+
102
+ # Look at the generated tools without starting a server
103
+ cli-mcp inspect kubectl
104
+
105
+ # Serve over Streamable HTTP
106
+ cli-mcp serve docker --transport streamable-http --port 8801
107
+
108
+ # Export machine-readable artefacts
109
+ cli-mcp export git -o tools.md
110
+ cli-mcp export-schema git -o schemas.json
111
+ cli-mcp export-openapi git -o openapi.json
112
+
113
+ # Validate discovery quality
114
+ cli-mcp doctor git
115
+
116
+ # Generate a configuration template
117
+ cli-mcp init opcua-nodeset-linker -o cli-mcp.yaml
118
+ ```
119
+
120
+ Client configuration (`.mcp.json`, Claude Desktop, Cursor, VS Code, …):
121
+
122
+ The following is a common client configuration shape; VS Code and other clients may use
123
+ different keys. See [clients.md](docs/clients.md) for client-specific recipes.
124
+
125
+ ```json
126
+ {
127
+ "mcpServers": {
128
+ "git": {
129
+ "command": "cli-mcp",
130
+ "args": ["serve", "git"]
131
+ },
132
+ "docker": {
133
+ "url": "http://127.0.0.1:8801/mcp"
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Working directory and Windows shells
142
+
143
+ Some CLIs resolve paths inside their input configuration relative to the process working
144
+ directory, not relative to that configuration file. Configure an absolute existing directory
145
+ for both discovery and execution:
146
+
147
+ ```yaml
148
+ binary: my-cli
149
+ execution:
150
+ cwd: 'C:\projects\my-cli'
151
+ ```
152
+
153
+ Without `execution.cwd`, the launch directory is used. Use an absolute manifest path when
154
+ changing the discovery directory. Configuration and tool definitions are loaded at startup:
155
+ restart the server and reconnect the client after changes.
156
+
157
+ PowerShell:
158
+
159
+ ```powershell
160
+ $env:PYTHONDONTWRITEBYTECODE = '1'
161
+ & 'C:\projects\cli-mcp\.venv\Scripts\python.exe' -B -m cli_mcp.cli.main expose --config 'C:\projects\cli-mcp\cli-mcp.yaml'
162
+ ```
163
+
164
+ cmd.exe (run these as two separate commands):
165
+
166
+ ```bat
167
+ set PYTHONDONTWRITEBYTECODE=1
168
+ "C:\projects\cli-mcp\.venv\Scripts\python.exe" -B -m cli_mcp.cli.main expose --config "C:\projects\cli-mcp\cli-mcp.yaml"
169
+ ```
170
+
171
+ These examples assume an existing development environment at the shown location.
172
+
173
+ ---
174
+
175
+ ## How discovery works
176
+
177
+ `cli-mcp` tries discovery strategies in order. The first strategy that yields usable commands
178
+ wins; manifest entries then add tools or replace discovered tools with the same name.
179
+ The final include/exclude filters select which tools are published, not which help commands
180
+ may execute.
181
+
182
+ Import reflection is **disabled by default** and requires `--allow-reflection` or
183
+ `discovery.allow_import_reflection: true`. It may execute target code.
184
+ `discovery.max_depth` limits the recursive walk; `discovery.max_commands` limits both published
185
+ tools and uncached help invocations, including root queries, failures and fallback flags.
186
+ The help budget is checked before spawning processes; cache hits do not consume it.
187
+
188
+ | Strategy | How it works | Frameworks |
189
+ | --- | --- | --- |
190
+ | **Native reflection** | Inspects the real command objects in the target process | Python: argparse, click, typer · Node.js: commander.js |
191
+ | **Framework help grammars** | Parses framework-specific help output with type/default/required semantics | Go: cobra · Rust: clap · .NET: System.CommandLine · Node.js: yargs · click · argparse |
192
+ | **Generic help parsing** | Recursive `tool --help` / `tool <command> --help` parser | anything with `--help` |
193
+ | **Manifest file** | User-provided YAML metadata, authoritative for matching tool names | supported executables and scripts |
194
+
195
+ Every discovered command becomes an MCP tool:
196
+
197
+ ```json
198
+ {
199
+ "name": "reduce",
200
+ "description": "Reduce an OPC UA nodeset",
201
+ "inputSchema": {
202
+ "type": "object",
203
+ "properties": {
204
+ "input": { "type": "string", "description": "Input nodeset file" },
205
+ "output": { "type": "string", "description": "Reduced nodeset file" }
206
+ },
207
+ "required": ["input", "output"]
208
+ }
209
+ }
210
+ ```
211
+
212
+ Calling the tool executes the original CLI:
213
+
214
+ ```bash
215
+ linker reduce --input model.xml --output reduced.xml
216
+ ```
217
+
218
+ and returns:
219
+
220
+ ```json
221
+ {
222
+ "stdout": "...",
223
+ "stderr": "",
224
+ "exitCode": 0,
225
+ "executionTimeMs": 125,
226
+ "argv": ["linker", "reduce", "--input", "model.xml", "--output", "reduced.xml"],
227
+ "sandboxed": false,
228
+ "truncated": false
229
+ }
230
+ ```
231
+
232
+ The actual `argv` contains the resolved launch command, including interpreter prefixes where
233
+ needed. A connected client or completed MCP call does not prove CLI success: inspect `exitCode`
234
+ and `stderr`. Exitcode 0 does not establish side-effect freedom; `sandboxed: false` means the
235
+ target ran on the host.
236
+
237
+ ---
238
+
239
+ ## Security
240
+
241
+ Security is a first-class concern, not an afterthought.
242
+
243
+ ```yaml
244
+ binary: opcua-nodeset-linker
245
+
246
+ security:
247
+ allowlist: [git, kubectl, docker, opcua-nodeset-linker]
248
+ readonly: true # deny unless the complete argv is explicitly listed
249
+ readonly_argv: [] # configure verified exact invocations before use
250
+ approval: true # human confirmation required
251
+ timeout_seconds: 30
252
+ max_output_bytes: 200000
253
+ sandbox:
254
+ backend: docker
255
+ image: my-cli-image:latest
256
+ network: false
257
+ ```
258
+
259
+ - **Allow list** — a non-empty list restricts configured binaries by basename;
260
+ an empty list permits the configured binary. This is not an OS permission boundary.
261
+ - **Read-only mode** — only explicitly listed complete argv vectors may execute;
262
+ known risky commands and flags are additionally blocked. An empty `readonly_argv` blocks
263
+ every execution and triggers a startup warning; publishing tools does not authorize them.
264
+ - **Approval mode** — `auto`/`prompt` require a local human at a terminal; without a TTY
265
+ approval-required execution is refused. Clients cannot approve their own calls. Legacy
266
+ `explicit` refuses approval-required calls. Normal stdio clients do not provide a local TTY.
267
+ - **Sandboxing** — run the CLI inside Docker or Podman; the default working-directory
268
+ mount is writable. Discovery still runs on the host. Firecracker support planned.
269
+ - **Argument validation** — arguments are validated against the generated JSON schema
270
+ before the requested tool process is spawned. Arguments are not interpolated into
271
+ a shell string.
272
+
273
+ Read-only, approval and sandboxing are opt-in. Exact argv permissions do not prove that a
274
+ program, its environment or its input files are harmless. Discovery is outside tool execution
275
+ guards. Output limits bound retained stdout/stderr, not the target's own CPU or memory use.
276
+ HTTP/SSE defaults to loopback and does not configure authentication or TLS; do not expose it
277
+ publicly without appropriate protection.
278
+
279
+ Windows batch targets (`.bat`/`.cmd`) are rejected. Output is retained within a byte budget,
280
+ including timeouts. See [security.md](docs/security.md) for limitations, configuration migration,
281
+ and non-invasive Windows/Linux tests using a single loopback ping.
282
+
283
+ ---
284
+
285
+ ## Architecture
286
+
287
+ Clean architecture, one responsibility per package:
288
+
289
+ ```text
290
+ src/cli_mcp/
291
+ ├── cli/ # Typer entry point (expose/inspect/serve/export/doctor/init)
292
+ ├── discovery/ # provider protocol + strategies
293
+ ├── schema/ # JSON Schema, Markdown docs, OpenAPI
294
+ ├── executor/ # argv building, subprocess execution, approval, sandbox
295
+ ├── mcp/ # MCPServer assembly, tool adapters
296
+ ├── security/ # allow list, read-only rules, access policy
297
+ ├── config/ # pydantic settings + YAML loading
298
+ ├── transport/ # stdio, HTTP, Streamable HTTP, SSE
299
+ └── plugins/ # entry-point based provider discovery
300
+ ```
301
+
302
+ Dependencies point inward: the CLI depends on discovery/schema/executor/security; the MCP
303
+ layer depends on nothing framework-specific. See
304
+ [docs/architecture.md](docs/architecture.md).
305
+
306
+ ---
307
+
308
+ ## Plugin system
309
+
310
+ Third parties add discovery providers without touching core code:
311
+
312
+ ```python
313
+ from cli_mcp.discovery.base import DiscoveryContext, DiscoveryResult, TargetInfo
314
+
315
+
316
+ class MyProvider:
317
+ name = "my-framework"
318
+ strategy = "help"
319
+ frameworks = ("my-framework",)
320
+
321
+ def supports(self, target: TargetInfo) -> bool:
322
+ return target.framework == "my-framework"
323
+
324
+ def discover(self, target: TargetInfo, ctx: DiscoveryContext) -> DiscoveryResult | None:
325
+ ... # return CommandSpecs
326
+ ```
327
+
328
+ Register it:
329
+
330
+ ```toml
331
+ [project.entry-points."cli_mcp.discovery"]
332
+ my-framework = "my_pkg.provider:MyProvider"
333
+ ```
334
+
335
+ `cli-mcp providers` lists every registered provider.
336
+
337
+ ---
338
+
339
+ ## Development
340
+
341
+ ```bash
342
+ uv run ruff check .
343
+ uv run mypy src
344
+ uv run pytest # unit + integration + e2e, coverage gate at 90%
345
+ pre-commit run --all-files
346
+ ```
347
+
348
+ The full test suite creates temporary files and runs Git integration commands.
349
+ For non-invasive validation without test caches, coverage files or generated target scripts,
350
+ use the focused Windows/Linux commands in [security.md](docs/security.md).
351
+
352
+ GitHub Actions run lint, type checking, Windows/Linux tests (Python 3.13/3.14), and
353
+ distribution builds on pushes to `master`/`main` and pull requests. CI can also be started
354
+ manually from the Actions tab. Dependencies are installed from the checked-in lockfile.
355
+
356
+ Published GitHub releases (or a manual Release workflow run) validate and build distributions,
357
+ then upload wheels and source distributions as workflow artifacts. **PyPI publishing is
358
+ disabled** until a publishing account and trusted publisher are explicitly configured.
359
+
360
+ ---
361
+
362
+ ## Examples
363
+
364
+ - [`examples/cli-mcp.yaml`](examples/cli-mcp.yaml) — full configuration
365
+ - [`examples/sample-clis/`](examples/sample-clis) — small argparse/click/typer/commander apps used by the tests
366
+
367
+ ## Documentation
368
+
369
+ - [configuration.md](docs/configuration.md) — every config key and CLI flag
370
+ - [discovery.md](docs/discovery.md) — reflection, help parsing and manifests
371
+ - [security.md](docs/security.md) — allow list, read-only, approval, sandboxing
372
+ - [transports.md](docs/transports.md) — stdio, HTTP, Streamable HTTP, SSE
373
+ - [clients.md](docs/clients.md) — configuration recipes for Claude, VS Code, Cursor, Cline, Open WebUI
374
+ - [plugins.md](docs/plugins.md) — adding a discovery provider for a new framework
375
+ - [architecture.md](docs/architecture.md) — how the pipeline fits together
376
+
377
+ ---
378
+
379
+ ## License
380
+
381
+ Apache License 2.0 — see [LICENSE](LICENSE).