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,238 @@
|
|
|
1
|
+
"""Recursive help walk: turn help pages into :class:`CommandSpec` definitions.
|
|
2
|
+
|
|
3
|
+
The generic help strategy and every framework grammar provider share this walk;
|
|
4
|
+
providers only contribute a *preprocess* hook (to normalise their own marker
|
|
5
|
+
spellings) and a *refine* hook (to adjust the resulting command).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cli_mcp.config.settings import Settings
|
|
14
|
+
from cli_mcp.detect import TargetInfo, executable_stem, launch_for
|
|
15
|
+
from cli_mcp.discovery.base import DiscoveryContext, HelpResult
|
|
16
|
+
from cli_mcp.discovery.help_parser import ParsedHelp, is_help_noise, parse_help
|
|
17
|
+
from cli_mcp.models import CommandSpec, DiscoveryResult, Parameter, ParamKind, sanitize_tool_name
|
|
18
|
+
|
|
19
|
+
SKIP_SUBCOMMANDS = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"help",
|
|
22
|
+
"completion",
|
|
23
|
+
"completions",
|
|
24
|
+
"__complete",
|
|
25
|
+
"__complete_no_color",
|
|
26
|
+
"--help",
|
|
27
|
+
"-h",
|
|
28
|
+
"--version",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
SKIP_PARAMETERS = frozenset({"help", "h", "?", "install_completion", "show_completion", "completion"})
|
|
33
|
+
|
|
34
|
+
Preprocess = Callable[[str], str]
|
|
35
|
+
Refine = Callable[[CommandSpec, HelpResult], CommandSpec]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def coerce_default(type_name: str, value: str | None) -> Any:
|
|
39
|
+
"""Convert a help-text default into the declared JSON Schema type."""
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
cleaned = value.strip().strip('"').strip("'")
|
|
43
|
+
lowered = cleaned.lower()
|
|
44
|
+
if not cleaned or lowered in {"none", "null", "nil"}:
|
|
45
|
+
return None
|
|
46
|
+
if type_name == "boolean":
|
|
47
|
+
if lowered in {"true", "yes", "1", "on"}:
|
|
48
|
+
return True
|
|
49
|
+
if lowered in {"false", "no", "0", "off"}:
|
|
50
|
+
return False
|
|
51
|
+
return None
|
|
52
|
+
if type_name == "integer":
|
|
53
|
+
try:
|
|
54
|
+
return int(lowered)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
if type_name == "number":
|
|
58
|
+
try:
|
|
59
|
+
return float(lowered)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
return cleaned
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parameters_from(parsed: ParsedHelp) -> list[Parameter]:
|
|
66
|
+
"""Build parameters from a parsed help page, skipping help boilerplate."""
|
|
67
|
+
parameters: list[Parameter] = []
|
|
68
|
+
for option in parsed.options:
|
|
69
|
+
if option.name.lower() in SKIP_PARAMETERS:
|
|
70
|
+
continue
|
|
71
|
+
description = option.description or (f"Value for --{option.name}" if not option.is_flag else "Toggle flag")
|
|
72
|
+
default = coerce_default(option.type, option.default)
|
|
73
|
+
parameters.append(
|
|
74
|
+
Parameter(
|
|
75
|
+
name=option.name,
|
|
76
|
+
kind=ParamKind.FLAG if option.is_flag else ParamKind.OPTION,
|
|
77
|
+
cli_name=option.name,
|
|
78
|
+
short=option.short,
|
|
79
|
+
short_only=option.short_only,
|
|
80
|
+
type=option.type,
|
|
81
|
+
description=description,
|
|
82
|
+
required=option.required,
|
|
83
|
+
default=default,
|
|
84
|
+
choices=option.choices,
|
|
85
|
+
metavar=option.metavar,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
for positional in parsed.positionals:
|
|
89
|
+
parameters.append(
|
|
90
|
+
Parameter(
|
|
91
|
+
name=positional.name,
|
|
92
|
+
kind=ParamKind.POSITIONAL,
|
|
93
|
+
type=positional.type,
|
|
94
|
+
description=positional.description or f"Positional argument {positional.name}",
|
|
95
|
+
required=positional.required,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return parameters
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _publishable_options(parsed: ParsedHelp) -> bool:
|
|
102
|
+
"""Whether a help page advertises options that become MCP arguments."""
|
|
103
|
+
return any(option.name.lower() not in SKIP_PARAMETERS for option in parsed.options)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def tool_base(target: TargetInfo) -> str:
|
|
107
|
+
"""Tool name for the root command: the binary's file name without its suffix."""
|
|
108
|
+
stem = executable_stem(target.resolved_path) if target.resolved_path else target.binary
|
|
109
|
+
return sanitize_tool_name(stem or target.binary)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def command_for(
|
|
113
|
+
target: TargetInfo,
|
|
114
|
+
path: list[str],
|
|
115
|
+
parsed: ParsedHelp,
|
|
116
|
+
provider: str,
|
|
117
|
+
help_result: HelpResult | None = None,
|
|
118
|
+
refine: Refine | None = None,
|
|
119
|
+
prefix: str | None = None,
|
|
120
|
+
) -> CommandSpec:
|
|
121
|
+
"""Build one command definition from a parsed help page."""
|
|
122
|
+
name_parts = ([prefix] if prefix else []) + (path or [tool_base(target)])
|
|
123
|
+
description = parsed.description.strip()
|
|
124
|
+
if not description or is_help_noise(description):
|
|
125
|
+
command_label = " ".join([target.binary, *path])
|
|
126
|
+
description = f"Run {command_label}"
|
|
127
|
+
spec = CommandSpec(
|
|
128
|
+
name="_".join(name_parts),
|
|
129
|
+
path=list(path),
|
|
130
|
+
description=description,
|
|
131
|
+
parameters=parameters_from(parsed),
|
|
132
|
+
provider=provider,
|
|
133
|
+
examples=[f"{target.binary} {' '.join(path)}".strip()],
|
|
134
|
+
has_subcommands=bool(parsed.subcommands),
|
|
135
|
+
)
|
|
136
|
+
if refine is not None and help_result is not None:
|
|
137
|
+
spec = refine(spec, help_result)
|
|
138
|
+
return spec
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def discover_from_help(
|
|
142
|
+
target: TargetInfo,
|
|
143
|
+
ctx: DiscoveryContext,
|
|
144
|
+
settings: Settings,
|
|
145
|
+
provider: str,
|
|
146
|
+
preprocess: Preprocess | None = None,
|
|
147
|
+
refine: Refine | None = None,
|
|
148
|
+
child_suffix: str | None = None,
|
|
149
|
+
) -> DiscoveryResult:
|
|
150
|
+
"""Walk ``--help`` recursively and collect command definitions.
|
|
151
|
+
|
|
152
|
+
*child_suffix* is a fallback help flag for subcommands whose ``--help``
|
|
153
|
+
prints nothing, e.g. ``git status --help`` opens a man pager and ``-h`` does not.
|
|
154
|
+
"""
|
|
155
|
+
result = DiscoveryResult(
|
|
156
|
+
binary=target.binary,
|
|
157
|
+
resolved_path=target.resolved_path,
|
|
158
|
+
framework=target.framework,
|
|
159
|
+
strategies=[provider],
|
|
160
|
+
)
|
|
161
|
+
prefix = sanitize_tool_name(target.binary) if settings.mcp.tool_prefix else None
|
|
162
|
+
|
|
163
|
+
root_result = ctx.help_for(target)
|
|
164
|
+
if not root_result.ok and not root_result.budget_exhausted and child_suffix is not None:
|
|
165
|
+
root_result = ctx.help_for(target, suffix=child_suffix)
|
|
166
|
+
if root_result.budget_exhausted:
|
|
167
|
+
result.add_warning(root_result.stderr)
|
|
168
|
+
return result
|
|
169
|
+
root_text = preprocess_text(target.root_help or root_result.text, preprocess)
|
|
170
|
+
if not root_result.ok:
|
|
171
|
+
result.add_warning(f"no help output from {target.binary} --help")
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
limit = settings.discovery.max_commands
|
|
175
|
+
seen: set[tuple[str, ...]] = set()
|
|
176
|
+
queue: list[tuple[list[str], str]] = [([], root_text)]
|
|
177
|
+
commands: list[CommandSpec] = []
|
|
178
|
+
|
|
179
|
+
while queue:
|
|
180
|
+
path, text = queue.pop(0)
|
|
181
|
+
if len(commands) >= limit:
|
|
182
|
+
result.add_warning(f"discovery capped at {limit} commands")
|
|
183
|
+
break
|
|
184
|
+
parsed = parse_help(text)
|
|
185
|
+
help_result = ctx.cache.get(tuple([*launch_for(target.resolved_path), *path, ctx.help_argv_suffix]))
|
|
186
|
+
if path and len(path) > settings.discovery.max_depth:
|
|
187
|
+
continue
|
|
188
|
+
emits_root = not path and (_publishable_options(parsed) or not parsed.subcommands)
|
|
189
|
+
if path or emits_root:
|
|
190
|
+
commands.append(command_for(target, path, parsed, provider, help_result, refine, prefix))
|
|
191
|
+
seen.add(tuple(path))
|
|
192
|
+
if len(commands) >= limit:
|
|
193
|
+
if parsed.subcommands or queue:
|
|
194
|
+
result.add_warning(f"discovery capped at {limit} commands")
|
|
195
|
+
break
|
|
196
|
+
_enqueue_children(target, ctx, path, parsed, settings, seen, queue, result, child_suffix, preprocess)
|
|
197
|
+
|
|
198
|
+
result.commands = commands
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _enqueue_children(
|
|
203
|
+
target: TargetInfo,
|
|
204
|
+
ctx: DiscoveryContext,
|
|
205
|
+
path: list[str],
|
|
206
|
+
parsed: ParsedHelp,
|
|
207
|
+
settings: Settings,
|
|
208
|
+
seen: set[tuple[str, ...]],
|
|
209
|
+
queue: list[tuple[list[str], str]],
|
|
210
|
+
result: DiscoveryResult,
|
|
211
|
+
child_suffix: str | None,
|
|
212
|
+
preprocess: Preprocess | None,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Fetch and queue the help page of every subcommand below *path*."""
|
|
215
|
+
if len(path) >= settings.discovery.max_depth:
|
|
216
|
+
return
|
|
217
|
+
for subcommand in sorted(parsed.subcommands, key=lambda item: item.name):
|
|
218
|
+
name = subcommand.name
|
|
219
|
+
if name.lower() in SKIP_SUBCOMMANDS or (tuple([*path, name]) in seen):
|
|
220
|
+
continue
|
|
221
|
+
child = ctx.help_for(target, [*path, name])
|
|
222
|
+
if child.budget_exhausted:
|
|
223
|
+
result.add_warning(child.stderr)
|
|
224
|
+
break
|
|
225
|
+
if not child.ok and child_suffix is not None:
|
|
226
|
+
child = ctx.help_for(target, [*path, name], suffix=child_suffix)
|
|
227
|
+
if child.budget_exhausted:
|
|
228
|
+
result.add_warning(child.stderr)
|
|
229
|
+
break
|
|
230
|
+
if not child.ok:
|
|
231
|
+
result.add_warning(f"no help output for {target.binary} {' '.join([*path, name])} --help")
|
|
232
|
+
continue
|
|
233
|
+
queue.append(([*path, name], preprocess_text(child.text, preprocess)))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def preprocess_text(text: str, preprocess: Preprocess | None) -> str:
|
|
237
|
+
"""Apply a provider-specific preprocessing step to help text."""
|
|
238
|
+
return preprocess(text) if preprocess is not None else text
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Framework-specific help grammars.
|
|
2
|
+
|
|
3
|
+
Each provider reads the help dialect of a specific CLI framework. They reuse the
|
|
4
|
+
generic parser and add the few framework-specific spellings that the generic
|
|
5
|
+
marker vocabulary does not cover (cobra's `(default "x")`, yargs aliases, clap
|
|
6
|
+
possible values, System.CommandLine command sections).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from cli_mcp.config.settings import Settings
|
|
14
|
+
from cli_mcp.detect import TargetInfo
|
|
15
|
+
from cli_mcp.discovery.base import DiscoveryContext, HelpResult
|
|
16
|
+
from cli_mcp.discovery.engine import discover_from_help
|
|
17
|
+
from cli_mcp.models import CommandSpec, DiscoveryResult
|
|
18
|
+
|
|
19
|
+
_COBRA_DEFAULT = re.compile(r"\((default(?: value)?\s+([^)]*))\)")
|
|
20
|
+
_COBRA_REQUIRED = re.compile(r"\((required)\)", re.IGNORECASE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GenericHelpProvider:
|
|
24
|
+
"""Strategy 2: parse ``--help`` output of any CLI, recursively."""
|
|
25
|
+
|
|
26
|
+
name = "help"
|
|
27
|
+
strategy = "help"
|
|
28
|
+
frameworks: tuple[str, ...] = ()
|
|
29
|
+
|
|
30
|
+
def __init__(self, settings: Settings) -> None:
|
|
31
|
+
"""Store the settings that bound the recursive help walk."""
|
|
32
|
+
self.settings = settings
|
|
33
|
+
|
|
34
|
+
def supports(self, target: TargetInfo) -> bool:
|
|
35
|
+
"""Report whether the generic parser can attempt any binary."""
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
def discover(self, target: TargetInfo, ctx: DiscoveryContext) -> DiscoveryResult | None:
|
|
39
|
+
"""Discover commands with the generic grammar."""
|
|
40
|
+
result = discover_from_help(target, ctx, self.settings, self.name)
|
|
41
|
+
return result if result.commands else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _GrammarProvider:
|
|
45
|
+
"""Base for framework grammars that only activate on a matching fingerprint."""
|
|
46
|
+
|
|
47
|
+
name = "grammar"
|
|
48
|
+
strategy = "help"
|
|
49
|
+
frameworks: tuple[str, ...] = ()
|
|
50
|
+
#: Fallback help flag for subcommands whose ``--help`` prints nothing.
|
|
51
|
+
child_suffix: str | None = None
|
|
52
|
+
|
|
53
|
+
def __init__(self, settings: Settings) -> None:
|
|
54
|
+
self.settings = settings
|
|
55
|
+
|
|
56
|
+
def supports(self, target: TargetInfo) -> bool:
|
|
57
|
+
"""Activate only when the fingerprint identified one of our frameworks."""
|
|
58
|
+
return target.framework in self.frameworks
|
|
59
|
+
|
|
60
|
+
def preprocess(self, text: str) -> str:
|
|
61
|
+
"""Normalise framework marker spellings before the generic parser runs."""
|
|
62
|
+
return text
|
|
63
|
+
|
|
64
|
+
def refine(self, spec: CommandSpec, help_result: HelpResult) -> CommandSpec:
|
|
65
|
+
"""Adjust the command after generic parsing."""
|
|
66
|
+
return spec
|
|
67
|
+
|
|
68
|
+
def discover(self, target: TargetInfo, ctx: DiscoveryContext) -> DiscoveryResult | None:
|
|
69
|
+
"""Discover commands with this framework's grammar."""
|
|
70
|
+
result = discover_from_help(
|
|
71
|
+
target,
|
|
72
|
+
ctx,
|
|
73
|
+
self.settings,
|
|
74
|
+
self.name,
|
|
75
|
+
preprocess=self.preprocess,
|
|
76
|
+
refine=self.refine,
|
|
77
|
+
child_suffix=self.child_suffix,
|
|
78
|
+
)
|
|
79
|
+
return result if result.commands else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_GIT_NEGATION = re.compile(r"--\[no-\]")
|
|
83
|
+
_GIT_OPTION_VALUE = re.compile(r"\[=<([^>\]]*)>\]")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class GitProvider(_GrammarProvider):
|
|
87
|
+
"""Git's own help dialect: grouped command lists and ``--[no-]option`` flags."""
|
|
88
|
+
|
|
89
|
+
name = "git"
|
|
90
|
+
frameworks = ("git",)
|
|
91
|
+
child_suffix = "-h"
|
|
92
|
+
|
|
93
|
+
def preprocess(self, text: str) -> str:
|
|
94
|
+
"""Rewrite git's negated flags and inline value placeholders into plain spellings."""
|
|
95
|
+
text = _GIT_NEGATION.sub("--", text)
|
|
96
|
+
return _GIT_OPTION_VALUE.sub(lambda match: f" <{match.group(1)}>", text)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CobraProvider(_GrammarProvider):
|
|
100
|
+
"""Go/cobra help grammar."""
|
|
101
|
+
|
|
102
|
+
name = "cobra"
|
|
103
|
+
frameworks = ("cobra",)
|
|
104
|
+
|
|
105
|
+
def preprocess(self, text: str) -> str:
|
|
106
|
+
"""Rewrite cobra's ``(default "x")`` and ``(required)`` into bracket markers."""
|
|
107
|
+
text = _COBRA_DEFAULT.sub(lambda match: f"[default: {match.group(2).strip().strip(chr(34))}]", text)
|
|
108
|
+
return _COBRA_REQUIRED.sub("[required]", text)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ClapProvider(_GrammarProvider):
|
|
112
|
+
"""Rust/clap help grammar."""
|
|
113
|
+
|
|
114
|
+
name = "clap"
|
|
115
|
+
frameworks = ("clap",)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class DotnetProvider(_GrammarProvider):
|
|
119
|
+
""".NET System.CommandLine help grammar."""
|
|
120
|
+
|
|
121
|
+
name = "dotnet"
|
|
122
|
+
frameworks = ("dotnet",)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class YargsProvider(_GrammarProvider):
|
|
126
|
+
"""Node.js/yargs help grammar."""
|
|
127
|
+
|
|
128
|
+
name = "yargs"
|
|
129
|
+
frameworks = ("yargs",)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class CommanderProvider(_GrammarProvider):
|
|
133
|
+
"""Node.js/commander.js help grammar."""
|
|
134
|
+
|
|
135
|
+
name = "commander"
|
|
136
|
+
frameworks = ("commander",)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ClickProvider(_GrammarProvider):
|
|
140
|
+
"""Python/click help grammar."""
|
|
141
|
+
|
|
142
|
+
name = "click"
|
|
143
|
+
frameworks = ("click",)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TyperProvider(_GrammarProvider):
|
|
147
|
+
"""Python/typer help grammar."""
|
|
148
|
+
|
|
149
|
+
name = "typer"
|
|
150
|
+
frameworks = ("typer",)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ArgparseProvider(_GrammarProvider):
|
|
154
|
+
"""Python/argparse help grammar."""
|
|
155
|
+
|
|
156
|
+
name = "argparse"
|
|
157
|
+
frameworks = ("argparse",)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
GRAMMAR_PROVIDERS: tuple[type[_GrammarProvider], ...] = (
|
|
161
|
+
GitProvider,
|
|
162
|
+
CobraProvider,
|
|
163
|
+
ClapProvider,
|
|
164
|
+
DotnetProvider,
|
|
165
|
+
YargsProvider,
|
|
166
|
+
CommanderProvider,
|
|
167
|
+
ClickProvider,
|
|
168
|
+
TyperProvider,
|
|
169
|
+
ArgparseProvider,
|
|
170
|
+
)
|