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.
- cli_mcp/__init__.py +32 -0
- cli_mcp/cli/__init__.py +7 -0
- cli_mcp/cli/main.py +410 -0
- cli_mcp/config/__init__.py +31 -0
- cli_mcp/config/loader.py +133 -0
- cli_mcp/config/settings.py +171 -0
- cli_mcp/core.py +133 -0
- cli_mcp/detect.py +143 -0
- cli_mcp/discovery/__init__.py +49 -0
- cli_mcp/discovery/base.py +129 -0
- cli_mcp/discovery/engine.py +238 -0
- cli_mcp/discovery/grammars.py +170 -0
- cli_mcp/discovery/help_parser.py +627 -0
- cli_mcp/discovery/introspect.py +276 -0
- cli_mcp/discovery/manifest.py +161 -0
- cli_mcp/discovery/node_reflection.py +126 -0
- cli_mcp/discovery/pipeline.py +108 -0
- cli_mcp/discovery/python_reflection.py +54 -0
- cli_mcp/discovery/reflection_payload.py +92 -0
- cli_mcp/discovery/registry.py +124 -0
- cli_mcp/errors.py +37 -0
- cli_mcp/executor/__init__.py +19 -0
- cli_mcp/executor/approval.py +64 -0
- cli_mcp/executor/argv.py +63 -0
- cli_mcp/executor/runner.py +384 -0
- cli_mcp/executor/sandbox.py +31 -0
- cli_mcp/mcp/__init__.py +22 -0
- cli_mcp/mcp/server.py +80 -0
- cli_mcp/mcp/tool_adapter.py +120 -0
- cli_mcp/models.py +213 -0
- cli_mcp/plugins/__init__.py +41 -0
- cli_mcp/schema/__init__.py +31 -0
- cli_mcp/schema/docgen.py +104 -0
- cli_mcp/schema/jsonschema.py +104 -0
- cli_mcp/schema/openapi.py +57 -0
- cli_mcp/schema/validate.py +84 -0
- cli_mcp/security/__init__.py +26 -0
- cli_mcp/security/policy.py +91 -0
- cli_mcp/security/readonly.py +173 -0
- cli_mcp/transport/__init__.py +49 -0
- cli_mcp/version.py +3 -0
- universal_cli_mcp_server-0.1.0.dist-info/METADATA +381 -0
- universal_cli_mcp_server-0.1.0.dist-info/RECORD +46 -0
- universal_cli_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- universal_cli_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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).
|