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,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
+ )