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,54 @@
|
|
|
1
|
+
"""Strategy 1 for Python CLIs: reflect on click, typer and argparse objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from cli_mcp.config.settings import Settings
|
|
8
|
+
from cli_mcp.detect import TargetInfo
|
|
9
|
+
from cli_mcp.discovery.base import DiscoveryContext
|
|
10
|
+
from cli_mcp.discovery.reflection_payload import commands_from_payload, parse_payload
|
|
11
|
+
from cli_mcp.models import DiscoveryResult
|
|
12
|
+
|
|
13
|
+
MODULE = "cli_mcp.discovery.introspect"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PythonReflectionProvider:
|
|
17
|
+
"""Import a Python CLI in a subprocess and read its declared command objects."""
|
|
18
|
+
|
|
19
|
+
name = "reflection-python"
|
|
20
|
+
strategy = "reflection"
|
|
21
|
+
frameworks: tuple[str, ...] = ("python", "click", "typer", "argparse")
|
|
22
|
+
|
|
23
|
+
def __init__(self, settings: Settings) -> None:
|
|
24
|
+
"""Store the settings that gate reflection."""
|
|
25
|
+
self.settings = settings
|
|
26
|
+
|
|
27
|
+
def supports(self, target: TargetInfo) -> bool:
|
|
28
|
+
"""Reflection imports the target, so it needs a Python target and explicit opt-in."""
|
|
29
|
+
if not self.settings.discovery.allow_import_reflection:
|
|
30
|
+
return False
|
|
31
|
+
return target.origin == "python"
|
|
32
|
+
|
|
33
|
+
def discover(self, target: TargetInfo, ctx: DiscoveryContext) -> DiscoveryResult | None:
|
|
34
|
+
"""Run the introspection helper and convert its JSON document."""
|
|
35
|
+
interpreter = os.environ.get("CLI_MCP_PYTHON") or ctx.interpreter("python")
|
|
36
|
+
if not interpreter:
|
|
37
|
+
return None
|
|
38
|
+
argv = [interpreter, "-m", MODULE, "--target", target.resolved_path]
|
|
39
|
+
help_result = ctx.run(argv, timeout=self.settings.discovery.help_timeout_seconds)
|
|
40
|
+
payload = parse_payload(help_result.stdout)
|
|
41
|
+
if payload is None:
|
|
42
|
+
return None
|
|
43
|
+
commands = commands_from_payload(payload, target, self.settings.mcp.tool_prefix)
|
|
44
|
+
if not commands:
|
|
45
|
+
return None
|
|
46
|
+
warnings = [f"reflection error: {payload['error']}"] if payload.get("error") else []
|
|
47
|
+
return DiscoveryResult(
|
|
48
|
+
binary=target.binary,
|
|
49
|
+
resolved_path=target.resolved_path,
|
|
50
|
+
framework=payload.get("framework") or target.framework,
|
|
51
|
+
commands=commands,
|
|
52
|
+
strategies=[self.name],
|
|
53
|
+
warnings=warnings,
|
|
54
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Shared conversion from an introspection JSON payload into canonical commands.
|
|
2
|
+
|
|
3
|
+
Both reflection providers (Python and Node.js) emit the same JSON document, so
|
|
4
|
+
the conversion lives here rather than in each provider.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from cli_mcp.detect import TargetInfo
|
|
13
|
+
from cli_mcp.discovery.engine import coerce_default, tool_base
|
|
14
|
+
from cli_mcp.models import CommandSpec, Parameter, ParamKind
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_payload(stdout: str) -> dict[str, Any] | None:
|
|
18
|
+
"""Parse an introspection JSON document, or ``None`` when nothing usable came back."""
|
|
19
|
+
try:
|
|
20
|
+
payload = json.loads(stdout)
|
|
21
|
+
except (json.JSONDecodeError, ValueError):
|
|
22
|
+
return None
|
|
23
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("commands"), list):
|
|
24
|
+
return None
|
|
25
|
+
return payload
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parameter_from(raw: dict[str, Any]) -> Parameter:
|
|
29
|
+
"""Convert one introspected parameter into the canonical model."""
|
|
30
|
+
kind_raw = raw.get("kind") or "option"
|
|
31
|
+
kind = (
|
|
32
|
+
ParamKind.POSITIONAL
|
|
33
|
+
if kind_raw == "positional"
|
|
34
|
+
else ParamKind.FLAG
|
|
35
|
+
if kind_raw == "flag"
|
|
36
|
+
else ParamKind.OPTION
|
|
37
|
+
)
|
|
38
|
+
declared = str(raw.get("type") or "string")
|
|
39
|
+
default = raw.get("default")
|
|
40
|
+
if default is not None:
|
|
41
|
+
expected: dict[str, type | tuple[type, ...]] = {
|
|
42
|
+
"boolean": bool,
|
|
43
|
+
"integer": int,
|
|
44
|
+
"number": (int, float),
|
|
45
|
+
"string": str,
|
|
46
|
+
"array": list,
|
|
47
|
+
}
|
|
48
|
+
if not isinstance(default, expected.get(declared, object)):
|
|
49
|
+
default = coerce_default(declared, str(default))
|
|
50
|
+
choices = raw.get("choices")
|
|
51
|
+
return Parameter(
|
|
52
|
+
name=str(raw.get("name") or "arg"),
|
|
53
|
+
kind=kind,
|
|
54
|
+
cli_name=raw.get("cli_name"),
|
|
55
|
+
short=raw.get("short"),
|
|
56
|
+
short_only=bool(raw.get("short_only", False)),
|
|
57
|
+
type=declared,
|
|
58
|
+
description=raw.get("description") or None,
|
|
59
|
+
required=bool(raw.get("required", False)),
|
|
60
|
+
default=default,
|
|
61
|
+
choices=list(choices) if choices else None,
|
|
62
|
+
repeatable=bool(raw.get("repeatable", False)),
|
|
63
|
+
metavar=raw.get("metavar"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def commands_from_payload(payload: dict[str, Any], target: TargetInfo, prefix: bool) -> list[CommandSpec]:
|
|
68
|
+
"""Convert introspected command entries into :class:`CommandSpec` objects."""
|
|
69
|
+
commands: list[CommandSpec] = []
|
|
70
|
+
for raw in payload.get("commands", []):
|
|
71
|
+
if not isinstance(raw, dict):
|
|
72
|
+
continue
|
|
73
|
+
path = [str(part) for part in raw.get("path", [])]
|
|
74
|
+
parameters = [parameter_from(item) for item in raw.get("parameters", []) if isinstance(item, dict)]
|
|
75
|
+
has_subcommands = bool(raw.get("has_subcommands"))
|
|
76
|
+
if not path and has_subcommands and not parameters:
|
|
77
|
+
continue # a bare group is not executable
|
|
78
|
+
binary_name = tool_base(target)
|
|
79
|
+
name_parts = ([binary_name] if prefix else []) + (path or [binary_name])
|
|
80
|
+
description = str(raw.get("description") or "").strip() or f"Run {' '.join([target.binary, *path])}".strip()
|
|
81
|
+
commands.append(
|
|
82
|
+
CommandSpec(
|
|
83
|
+
name="_".join(name_parts),
|
|
84
|
+
path=path,
|
|
85
|
+
description=description,
|
|
86
|
+
parameters=parameters,
|
|
87
|
+
provider=f"reflection-{target.origin}",
|
|
88
|
+
examples=[f"{target.binary} {' '.join(path)}".strip()],
|
|
89
|
+
has_subcommands=has_subcommands,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return commands
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Provider registry: built-in providers plus third-party plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib.metadata import entry_points
|
|
8
|
+
from typing import Literal, cast
|
|
9
|
+
|
|
10
|
+
from cli_mcp.config.settings import Settings
|
|
11
|
+
from cli_mcp.detect import TargetInfo
|
|
12
|
+
from cli_mcp.discovery.base import DiscoveryContext, DiscoveryProvider
|
|
13
|
+
from cli_mcp.discovery.grammars import GRAMMAR_PROVIDERS, GenericHelpProvider
|
|
14
|
+
from cli_mcp.discovery.manifest import ManifestProvider
|
|
15
|
+
from cli_mcp.discovery.node_reflection import NodeReflectionProvider
|
|
16
|
+
from cli_mcp.discovery.python_reflection import PythonReflectionProvider
|
|
17
|
+
from cli_mcp.models import DiscoveryResult
|
|
18
|
+
|
|
19
|
+
ENTRY_POINT_GROUP = "cli_mcp.discovery"
|
|
20
|
+
ProviderSource = Literal["core", "plugin"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Registration:
|
|
25
|
+
"""A provider together with where it came from."""
|
|
26
|
+
|
|
27
|
+
provider: DiscoveryProvider
|
|
28
|
+
source: ProviderSource
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderRegistry:
|
|
32
|
+
"""The provider set used by one discovery run."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, settings: Settings, *, load_plugins: bool = True) -> None:
|
|
35
|
+
"""Register the built-in providers, plus installed plugins when asked."""
|
|
36
|
+
self.settings = settings
|
|
37
|
+
self._registrations: list[Registration] = []
|
|
38
|
+
for provider in _builtin_providers(settings):
|
|
39
|
+
self._registrations.append(Registration(provider=provider, source="core"))
|
|
40
|
+
if load_plugins:
|
|
41
|
+
for provider in plugin_providers(settings):
|
|
42
|
+
self._registrations.append(Registration(provider=provider, source="plugin"))
|
|
43
|
+
|
|
44
|
+
def register(self, provider: DiscoveryProvider, source: ProviderSource = "plugin") -> None:
|
|
45
|
+
"""Add a provider programmatically, for tests and embedders."""
|
|
46
|
+
self._registrations.append(Registration(provider=provider, source=source))
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def registrations(self) -> list[Registration]:
|
|
50
|
+
"""Return every registered provider in priority order."""
|
|
51
|
+
return list(self._registrations)
|
|
52
|
+
|
|
53
|
+
def for_target(self, target: TargetInfo) -> list[DiscoveryProvider]:
|
|
54
|
+
"""Return the providers that accept *target*, plugins last so built-ins win."""
|
|
55
|
+
return [
|
|
56
|
+
registration.provider for registration in self._registrations if registration.provider.supports(target)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
def for_strategy(self, strategy: str, target: TargetInfo) -> list[DiscoveryProvider]:
|
|
60
|
+
"""Return the providers that accept *target* and implement *strategy*."""
|
|
61
|
+
return [provider for provider in self.for_target(target) if provider.strategy == strategy]
|
|
62
|
+
|
|
63
|
+
def describe(self) -> list[tuple[str, str, str, ProviderSource]]:
|
|
64
|
+
"""Build the rows shown by ``cli-mcp providers``: name, strategy, frameworks, source."""
|
|
65
|
+
return [
|
|
66
|
+
(
|
|
67
|
+
registration.provider.name,
|
|
68
|
+
registration.provider.strategy,
|
|
69
|
+
", ".join(registration.provider.frameworks),
|
|
70
|
+
registration.source,
|
|
71
|
+
)
|
|
72
|
+
for registration in self._registrations
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
def run(self, strategy: str, target: TargetInfo, ctx: DiscoveryContext) -> DiscoveryResult | None:
|
|
76
|
+
"""Try every provider for *strategy* and return the first non-empty result."""
|
|
77
|
+
for provider in self.for_strategy(strategy, target):
|
|
78
|
+
result = provider.discover(target, ctx)
|
|
79
|
+
if result is not None and result.commands:
|
|
80
|
+
return result
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _builtin_providers(settings: Settings) -> list[DiscoveryProvider]:
|
|
85
|
+
"""Built-ins ordered so specific grammars beat the generic parser."""
|
|
86
|
+
return [
|
|
87
|
+
PythonReflectionProvider(settings),
|
|
88
|
+
NodeReflectionProvider(settings),
|
|
89
|
+
*(provider(settings) for provider in GRAMMAR_PROVIDERS),
|
|
90
|
+
GenericHelpProvider(settings),
|
|
91
|
+
ManifestProvider(settings),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def plugin_providers(settings: Settings) -> list[DiscoveryProvider]:
|
|
96
|
+
"""Load providers from the ``cli_mcp.discovery`` entry-point group."""
|
|
97
|
+
providers: list[DiscoveryProvider] = []
|
|
98
|
+
try:
|
|
99
|
+
found = entry_points(group=ENTRY_POINT_GROUP)
|
|
100
|
+
except Exception:
|
|
101
|
+
return providers
|
|
102
|
+
for entry_point in found:
|
|
103
|
+
try:
|
|
104
|
+
loaded = entry_point.load()
|
|
105
|
+
except Exception:
|
|
106
|
+
continue
|
|
107
|
+
provider = _instantiate(loaded, settings)
|
|
108
|
+
if provider is not None:
|
|
109
|
+
providers.append(provider)
|
|
110
|
+
return providers
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _instantiate(loaded: object, settings: Settings) -> DiscoveryProvider | None:
|
|
114
|
+
"""Turn an entry-point target into a provider instance."""
|
|
115
|
+
if isinstance(loaded, type):
|
|
116
|
+
parameters = inspect.signature(loaded).parameters
|
|
117
|
+
try:
|
|
118
|
+
instance = loaded(settings) if "settings" in parameters else loaded()
|
|
119
|
+
except Exception:
|
|
120
|
+
return None
|
|
121
|
+
return cast("DiscoveryProvider", instance)
|
|
122
|
+
if loaded is None or not hasattr(loaded, "supports") or not hasattr(loaded, "discover"):
|
|
123
|
+
return None
|
|
124
|
+
return cast("DiscoveryProvider", loaded)
|
cli_mcp/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Errors raised across cli-mcp layers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CliMcpError(Exception):
|
|
5
|
+
"""Base class for every cli-mcp failure."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(CliMcpError):
|
|
9
|
+
"""Configuration could not be read or is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BinaryNotFoundError(CliMcpError):
|
|
13
|
+
"""The requested binary is not installed or not on the allow list."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DiscoveryError(CliMcpError):
|
|
17
|
+
"""Discovery produced no usable command definitions."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SchemaError(CliMcpError):
|
|
21
|
+
"""A tool definition or argument payload failed validation."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SecurityViolation(CliMcpError):
|
|
25
|
+
"""A request was rejected by the security policy."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApprovalRequired(SecurityViolation):
|
|
29
|
+
"""The request needs a human decision before it can run."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ExecutionError(CliMcpError):
|
|
33
|
+
"""The underlying process could not be started or failed unexpectedly."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TransportError(CliMcpError):
|
|
37
|
+
"""The requested MCP transport is unavailable."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Execution layer: argv building, subprocess runner, approval gate and sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cli_mcp.executor.approval import EXPLICIT_HINT, ApprovalGate
|
|
6
|
+
from cli_mcp.executor.argv import build_argv, format_value
|
|
7
|
+
from cli_mcp.executor.runner import ExecutionResult, run
|
|
8
|
+
from cli_mcp.executor.sandbox import sandbox_argv, sandbox_available
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"EXPLICIT_HINT",
|
|
12
|
+
"ApprovalGate",
|
|
13
|
+
"ExecutionResult",
|
|
14
|
+
"build_argv",
|
|
15
|
+
"format_value",
|
|
16
|
+
"run",
|
|
17
|
+
"sandbox_argv",
|
|
18
|
+
"sandbox_available",
|
|
19
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Approval gate for risky tool invocations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from typing import TextIO
|
|
9
|
+
|
|
10
|
+
from cli_mcp.config.settings import SecuritySettings
|
|
11
|
+
from cli_mcp.errors import ApprovalRequired
|
|
12
|
+
from cli_mcp.security.policy import Decision
|
|
13
|
+
|
|
14
|
+
EXPLICIT_HINT = "Local terminal approval is required; client-supplied approval is not accepted."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApprovalGate:
|
|
18
|
+
"""Decides whether a risky invocation may proceed.
|
|
19
|
+
|
|
20
|
+
Over stdio the process' stdin is the MCP protocol channel, so prompting is
|
|
21
|
+
only possible when stdin is a terminal; otherwise the client has to approve
|
|
22
|
+
through a trusted local terminal. Client arguments cannot grant approval.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
settings: SecuritySettings,
|
|
28
|
+
stdin: TextIO | None = None,
|
|
29
|
+
stdout: TextIO | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Bind the gate to the security settings and the streams it prompts on."""
|
|
32
|
+
self.settings = settings
|
|
33
|
+
self._stdin = stdin or sys.stdin
|
|
34
|
+
self._stdout = stdout or sys.stderr
|
|
35
|
+
self._prompt_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
def prompt_available(self) -> bool:
|
|
38
|
+
"""Return whether an interactive approval prompt is possible."""
|
|
39
|
+
try:
|
|
40
|
+
return bool(self._stdin.isatty())
|
|
41
|
+
except (ValueError, OSError):
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
def approve(self, tool_name: str, argv: list[str], decision: Decision) -> None:
|
|
45
|
+
"""Raise :class:`ApprovalRequired` unless *decision* is approved."""
|
|
46
|
+
if not decision.requires_approval:
|
|
47
|
+
return
|
|
48
|
+
display = json.dumps(argv, ensure_ascii=True)
|
|
49
|
+
if self.settings.approval_mode == "explicit" or not self.prompt_available():
|
|
50
|
+
raise ApprovalRequired(
|
|
51
|
+
f"tool {tool_name!r} needs approval: {display}. {EXPLICIT_HINT}"
|
|
52
|
+
)
|
|
53
|
+
with self._prompt_lock:
|
|
54
|
+
self._stdout.write(f"Tool Request: {display}\n")
|
|
55
|
+
if decision.reason:
|
|
56
|
+
self._stdout.write(f"Reason: {decision.reason}\n")
|
|
57
|
+
self._stdout.write("Approve? [y/N] ")
|
|
58
|
+
self._stdout.flush()
|
|
59
|
+
try:
|
|
60
|
+
answer = self._stdin.readline().strip().lower()
|
|
61
|
+
except (OSError, ValueError) as exc:
|
|
62
|
+
raise ApprovalRequired(f"could not read local approval for tool {tool_name!r}: {exc}") from exc
|
|
63
|
+
if answer not in {"y", "yes"}:
|
|
64
|
+
raise ApprovalRequired(f"approval denied for tool {tool_name!r} ({display})")
|
cli_mcp/executor/argv.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Turning validated tool arguments back into a CLI argv."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cli_mcp.models import CommandSpec, Parameter, ParamKind
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_value(param: Parameter, value: Any) -> str:
|
|
11
|
+
"""Render one argument value as a CLI token."""
|
|
12
|
+
if isinstance(value, bool):
|
|
13
|
+
return "true" if value else "false"
|
|
14
|
+
if isinstance(value, (int, float)):
|
|
15
|
+
return str(value)
|
|
16
|
+
if value is None:
|
|
17
|
+
return ""
|
|
18
|
+
return str(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _tokens_for(param: Parameter, value: Any) -> list[str]:
|
|
22
|
+
"""CLI tokens contributed by one parameter value."""
|
|
23
|
+
flag = param.cli_flag
|
|
24
|
+
if param.kind is ParamKind.FLAG:
|
|
25
|
+
if isinstance(value, bool):
|
|
26
|
+
return [flag] if value else []
|
|
27
|
+
if value in (1, "true", "yes", "on"):
|
|
28
|
+
return [flag]
|
|
29
|
+
return []
|
|
30
|
+
if isinstance(value, list):
|
|
31
|
+
tokens: list[str] = []
|
|
32
|
+
for item in value:
|
|
33
|
+
tokens.append(flag)
|
|
34
|
+
rendered = format_value(param, item)
|
|
35
|
+
if rendered:
|
|
36
|
+
tokens.append(rendered)
|
|
37
|
+
return tokens
|
|
38
|
+
return [flag, format_value(param, value)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_argv(command: list[str], spec: CommandSpec, arguments: dict[str, Any]) -> list[str]:
|
|
42
|
+
"""Build the argv that runs *command* with the validated arguments.
|
|
43
|
+
|
|
44
|
+
Options and flags come first, positional values afterwards in declaration
|
|
45
|
+
order, which every supported framework accepts.
|
|
46
|
+
"""
|
|
47
|
+
argv = [*command, *spec.argv_path]
|
|
48
|
+
for param in spec.parameters:
|
|
49
|
+
if param.kind is ParamKind.POSITIONAL:
|
|
50
|
+
continue
|
|
51
|
+
value = arguments.get(param.name)
|
|
52
|
+
if value is None:
|
|
53
|
+
continue
|
|
54
|
+
argv.extend(_tokens_for(param, value))
|
|
55
|
+
for param in spec.ordered_positionals():
|
|
56
|
+
value = arguments.get(param.name)
|
|
57
|
+
if value is None:
|
|
58
|
+
continue
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
argv.extend(format_value(param, item) for item in value)
|
|
61
|
+
else:
|
|
62
|
+
argv.append(format_value(param, value))
|
|
63
|
+
return argv
|