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,171 @@
|
|
|
1
|
+
"""Typed configuration for cli-mcp.
|
|
2
|
+
|
|
3
|
+
Everything the tool needs is expressed here: which binary to expose, how to
|
|
4
|
+
discover it, how to execute it and how to publish it over MCP.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
13
|
+
|
|
14
|
+
TransportName = Literal["stdio", "http", "streamable-http", "sse"]
|
|
15
|
+
ApprovalMode = Literal["auto", "prompt", "explicit"]
|
|
16
|
+
DEFAULT_STRATEGIES: tuple[str, ...] = ("reflection", "help", "manifest")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SandboxSettings(BaseModel):
|
|
20
|
+
"""Run the exposed CLI inside a container runtime."""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(extra="forbid")
|
|
23
|
+
|
|
24
|
+
backend: Literal["docker", "podman"] = "docker"
|
|
25
|
+
image: str = "alpine:3.20"
|
|
26
|
+
workdir_mount: bool = Field(True, description="Mount the current directory into the container")
|
|
27
|
+
container_workdir: str = "/work"
|
|
28
|
+
network: bool = False
|
|
29
|
+
extra_args: list[str] = Field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
@field_validator("image")
|
|
32
|
+
@classmethod
|
|
33
|
+
def _non_empty(cls, value: str) -> str:
|
|
34
|
+
if not value.strip():
|
|
35
|
+
raise ValueError("sandbox image must be non-empty")
|
|
36
|
+
return value.strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SecuritySettings(BaseModel):
|
|
40
|
+
"""Allow list, read-only mode, approval gate and execution limits."""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
allowlist: list[str] = Field(default_factory=list, description="Binaries that may be exposed")
|
|
45
|
+
readonly: bool = False
|
|
46
|
+
readonly_argv: list[list[str]] = Field(
|
|
47
|
+
default_factory=list,
|
|
48
|
+
description="Exact complete argv vectors explicitly approved by the operator for read-only execution",
|
|
49
|
+
)
|
|
50
|
+
approval: bool = False
|
|
51
|
+
approval_mode: ApprovalMode = "auto"
|
|
52
|
+
block_patterns: list[str] = Field(default_factory=list, description="Extra regexes rejected in argv")
|
|
53
|
+
allow_patterns: list[str] = Field(default_factory=list, description="Regexes that re-admit blocked verbs")
|
|
54
|
+
timeout_seconds: float = Field(60.0, gt=0)
|
|
55
|
+
max_output_bytes: int = Field(200_000, gt=0)
|
|
56
|
+
sandbox: SandboxSettings | None = None
|
|
57
|
+
|
|
58
|
+
@field_validator("readonly_argv")
|
|
59
|
+
@classmethod
|
|
60
|
+
def _readonly_argv(cls, value: list[list[str]]) -> list[list[str]]:
|
|
61
|
+
if any(not argv or any("\0" in token for token in argv) for argv in value):
|
|
62
|
+
raise ValueError("readonly_argv entries must be non-empty argv vectors without NUL bytes")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
@field_validator("allowlist")
|
|
66
|
+
@classmethod
|
|
67
|
+
def _normalise_allowlist(cls, value: list[str]) -> list[str]:
|
|
68
|
+
return [item.strip().lower() for item in value if item.strip()]
|
|
69
|
+
|
|
70
|
+
@field_validator("block_patterns", "allow_patterns")
|
|
71
|
+
@classmethod
|
|
72
|
+
def _strip_patterns(cls, value: list[str]) -> list[str]:
|
|
73
|
+
return [item for item in value if item.strip()]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DiscoverySettings(BaseModel):
|
|
77
|
+
"""Strategy order and limits for CLI discovery."""
|
|
78
|
+
|
|
79
|
+
model_config = ConfigDict(extra="forbid")
|
|
80
|
+
|
|
81
|
+
strategies: list[str] = Field(default_factory=lambda: list(DEFAULT_STRATEGIES))
|
|
82
|
+
include: list[str] | None = None
|
|
83
|
+
exclude: list[str] | None = None
|
|
84
|
+
max_depth: int = Field(2, ge=0, le=6)
|
|
85
|
+
max_commands: int = Field(120, ge=1, le=1000)
|
|
86
|
+
allow_import_reflection: bool = Field(
|
|
87
|
+
False,
|
|
88
|
+
description="Allow Python/Node reflection, which imports the target module and may run code",
|
|
89
|
+
)
|
|
90
|
+
help_timeout_seconds: float = Field(10.0, gt=0)
|
|
91
|
+
|
|
92
|
+
@field_validator("strategies")
|
|
93
|
+
@classmethod
|
|
94
|
+
def _ordered(cls, value: list[str]) -> list[str]:
|
|
95
|
+
seen: list[str] = []
|
|
96
|
+
for item in value:
|
|
97
|
+
name = item.strip().lower()
|
|
98
|
+
if name and name not in seen:
|
|
99
|
+
seen.append(name)
|
|
100
|
+
return seen or list(DEFAULT_STRATEGIES)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ServerSettings(BaseModel):
|
|
104
|
+
"""MCP server identity and transport choice."""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(extra="forbid")
|
|
107
|
+
|
|
108
|
+
name: str | None = None
|
|
109
|
+
title: str | None = None
|
|
110
|
+
description: str | None = None
|
|
111
|
+
instructions: str | None = None
|
|
112
|
+
version: str = Field("0.1.0", description="Server version reported over MCP")
|
|
113
|
+
transport: TransportName = "stdio"
|
|
114
|
+
host: str = "127.0.0.1"
|
|
115
|
+
port: int = Field(8801, ge=1, le=65535)
|
|
116
|
+
path: str = "/mcp"
|
|
117
|
+
json_response: bool = False
|
|
118
|
+
stateless_http: bool = False
|
|
119
|
+
include_docs_tool: bool = True
|
|
120
|
+
tool_prefix: bool = Field(
|
|
121
|
+
False,
|
|
122
|
+
description="Prefix tool names with the binary name, to avoid collisions when exposing several CLIs",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@field_validator("path")
|
|
126
|
+
@classmethod
|
|
127
|
+
def _leading_slash(cls, value: str) -> str:
|
|
128
|
+
return value if value.startswith("/") else f"/{value}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ExecutionSettings(BaseModel):
|
|
132
|
+
"""Explicit working directory for target discovery and execution."""
|
|
133
|
+
|
|
134
|
+
model_config = ConfigDict(extra="forbid")
|
|
135
|
+
|
|
136
|
+
cwd: Path | None = None
|
|
137
|
+
|
|
138
|
+
@field_validator("cwd")
|
|
139
|
+
@classmethod
|
|
140
|
+
def _cwd(cls, value: Path | None) -> Path | None:
|
|
141
|
+
if value is not None and (not value.is_absolute() or not value.is_dir()):
|
|
142
|
+
raise ValueError("execution.cwd must be an absolute path to an existing directory")
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Settings(BaseModel):
|
|
147
|
+
"""Complete configuration for one exposed binary."""
|
|
148
|
+
|
|
149
|
+
model_config = ConfigDict(extra="forbid")
|
|
150
|
+
|
|
151
|
+
binary: str
|
|
152
|
+
security: SecuritySettings = Field(default_factory=SecuritySettings)
|
|
153
|
+
discovery: DiscoverySettings = Field(default_factory=DiscoverySettings)
|
|
154
|
+
mcp: ServerSettings = Field(default_factory=ServerSettings)
|
|
155
|
+
execution: ExecutionSettings = Field(default_factory=ExecutionSettings)
|
|
156
|
+
manifest: str | None = Field(None, description="Optional manifest file that overrides discovery")
|
|
157
|
+
|
|
158
|
+
@field_validator("binary")
|
|
159
|
+
@classmethod
|
|
160
|
+
def _binary_name(cls, value: str) -> str:
|
|
161
|
+
cleaned = value.strip()
|
|
162
|
+
if not cleaned:
|
|
163
|
+
raise ValueError("binary must be non-empty")
|
|
164
|
+
return cleaned
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def server_name(self) -> str:
|
|
168
|
+
"""Default MCP server name derived from the binary."""
|
|
169
|
+
from cli_mcp.models import sanitize_tool_name
|
|
170
|
+
|
|
171
|
+
return self.mcp.name or sanitize_tool_name(self.binary)
|
cli_mcp/core.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""End-to-end orchestration: config, binary detection, discovery, schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from cli_mcp.config.loader import load_settings
|
|
10
|
+
from cli_mcp.config.settings import ApprovalMode, Settings
|
|
11
|
+
from cli_mcp.detect import TargetInfo, fingerprint, launch_for, resolve_binary
|
|
12
|
+
from cli_mcp.discovery.base import DiscoveryContext
|
|
13
|
+
from cli_mcp.discovery.pipeline import discover
|
|
14
|
+
from cli_mcp.models import DiscoveryResult
|
|
15
|
+
from cli_mcp.plugins import registry_with_plugins
|
|
16
|
+
from cli_mcp.security.policy import AccessPolicy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PreparedCli:
|
|
21
|
+
"""Everything needed to publish one binary as an MCP server."""
|
|
22
|
+
|
|
23
|
+
settings: Settings
|
|
24
|
+
target: TargetInfo
|
|
25
|
+
result: DiscoveryResult
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def binary_path(self) -> str:
|
|
29
|
+
"""Absolute path of the resolved CLI binary."""
|
|
30
|
+
return self.result.resolved_path or self.target.resolved_path
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def launch(self) -> list[str]:
|
|
34
|
+
"""Command that runs the CLI: interpreter plus script path when needed."""
|
|
35
|
+
return launch_for(self.binary_path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _apply_server_overrides(settings: Settings, flags: dict[str, Any]) -> None:
|
|
39
|
+
"""Apply transport and publishing flags."""
|
|
40
|
+
if flags.get("transport"):
|
|
41
|
+
settings.mcp.transport = str(flags["transport"]) # type: ignore[assignment]
|
|
42
|
+
if flags.get("host"):
|
|
43
|
+
settings.mcp.host = str(flags["host"])
|
|
44
|
+
if flags.get("port"):
|
|
45
|
+
settings.mcp.port = int(flags["port"])
|
|
46
|
+
if flags.get("tool_prefix") is not None:
|
|
47
|
+
settings.mcp.tool_prefix = bool(flags["tool_prefix"])
|
|
48
|
+
if flags.get("docs") is not None:
|
|
49
|
+
settings.mcp.include_docs_tool = bool(flags["docs"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _apply_security_overrides(settings: Settings, flags: dict[str, Any]) -> None:
|
|
53
|
+
"""Apply allow list, read-only, approval and timeout flags."""
|
|
54
|
+
if flags.get("readonly") is not None:
|
|
55
|
+
settings.security.readonly = bool(flags["readonly"])
|
|
56
|
+
if flags.get("approval") is not None:
|
|
57
|
+
settings.security.approval = bool(flags["approval"])
|
|
58
|
+
if flags.get("approval_mode"):
|
|
59
|
+
settings.security.approval_mode = str(flags["approval_mode"]) # type: ignore[assignment]
|
|
60
|
+
allow = flags.get("allow")
|
|
61
|
+
if allow:
|
|
62
|
+
settings.security.allowlist = [item.strip() for item in allow if item.strip()]
|
|
63
|
+
if flags.get("timeout"):
|
|
64
|
+
settings.security.timeout_seconds = float(flags["timeout"])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _apply_discovery_overrides(settings: Settings, flags: dict[str, Any]) -> None:
|
|
68
|
+
"""Apply strategy, filter and depth flags."""
|
|
69
|
+
if flags.get("strategies"):
|
|
70
|
+
settings.discovery.strategies = list(flags["strategies"])
|
|
71
|
+
if flags.get("include"):
|
|
72
|
+
settings.discovery.include = list(flags["include"])
|
|
73
|
+
if flags.get("exclude"):
|
|
74
|
+
settings.discovery.exclude = list(flags["exclude"])
|
|
75
|
+
if flags.get("max_depth") is not None:
|
|
76
|
+
settings.discovery.max_depth = int(flags["max_depth"])
|
|
77
|
+
if flags.get("allow_reflection") is not None:
|
|
78
|
+
settings.discovery.allow_import_reflection = bool(flags["allow_reflection"])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def apply_overrides(settings: Settings, **flags: Any) -> Settings:
|
|
82
|
+
"""Apply command-line overrides onto a settings object."""
|
|
83
|
+
_apply_server_overrides(settings, flags)
|
|
84
|
+
_apply_security_overrides(settings, flags)
|
|
85
|
+
_apply_discovery_overrides(settings, flags)
|
|
86
|
+
return settings
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def prepare(
|
|
90
|
+
binary: str | None = None,
|
|
91
|
+
config: str | Path | None = None,
|
|
92
|
+
cwd: Path | None = None,
|
|
93
|
+
overrides: dict[str, Any] | None = None,
|
|
94
|
+
*,
|
|
95
|
+
load_plugins: bool = True,
|
|
96
|
+
) -> PreparedCli:
|
|
97
|
+
"""Resolve *binary*, discover its commands and build the settings for it."""
|
|
98
|
+
working_dir = cwd or Path.cwd()
|
|
99
|
+
config_path = Path(config) if isinstance(config, str) else config
|
|
100
|
+
settings = load_settings(path=config_path, binary=binary, cwd=working_dir)
|
|
101
|
+
if overrides:
|
|
102
|
+
apply_overrides(settings, **overrides)
|
|
103
|
+
|
|
104
|
+
working_dir = settings.execution.cwd or working_dir
|
|
105
|
+
AccessPolicy(settings.security).authorize_binary(settings.binary)
|
|
106
|
+
target = resolve_binary(settings.binary)
|
|
107
|
+
help_suffix = "-h" if Path(target.resolved_path).stem.casefold() == "git" else "--help"
|
|
108
|
+
context = DiscoveryContext(settings.discovery, cwd=working_dir, help_argv_suffix=help_suffix)
|
|
109
|
+
|
|
110
|
+
if target.framework is None:
|
|
111
|
+
root_help = context.help_for(target)
|
|
112
|
+
if root_help.ok:
|
|
113
|
+
detected = fingerprint(root_help.text)
|
|
114
|
+
if detected:
|
|
115
|
+
target = target.with_help(root_help.text)
|
|
116
|
+
|
|
117
|
+
registry = registry_with_plugins(settings, load_plugins=load_plugins)
|
|
118
|
+
result = discover(target, settings, context, registry)
|
|
119
|
+
return PreparedCli(settings=settings, target=target, result=result)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def transport_endpoint(settings: Settings) -> str:
|
|
123
|
+
"""Where the server listens, for user-facing output."""
|
|
124
|
+
if settings.mcp.transport == "stdio":
|
|
125
|
+
return "stdio"
|
|
126
|
+
if settings.mcp.transport == "sse":
|
|
127
|
+
return f"http://{settings.mcp.host}:{settings.mcp.port}{settings.mcp.path} (SSE, deprecated)"
|
|
128
|
+
return f"http://{settings.mcp.host}:{settings.mcp.port}{settings.mcp.path}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def approval_mode(settings: Settings) -> ApprovalMode:
|
|
132
|
+
"""Return the active approval mode."""
|
|
133
|
+
return settings.security.approval_mode
|
cli_mcp/detect.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Binary resolution and CLI framework fingerprinting.
|
|
2
|
+
|
|
3
|
+
Resolution answers *where* the binary is; fingerprinting answers *which
|
|
4
|
+
framework* produced its help output, so the matching grammar provider can be
|
|
5
|
+
picked before the generic parser is used.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
from pathlib import Path, PureWindowsPath
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from cli_mcp.errors import BinaryNotFoundError, SecurityViolation
|
|
18
|
+
|
|
19
|
+
Origin = Literal["native", "python", "node"]
|
|
20
|
+
|
|
21
|
+
_PY_SUFFIXES = {".py", ".pyw"}
|
|
22
|
+
_NODE_SUFFIXES = {".js", ".mjs", ".cjs"}
|
|
23
|
+
|
|
24
|
+
#: Markers that identify a framework from its help output, in the casing each
|
|
25
|
+
#: framework emits. The framework with the most matched markers wins.
|
|
26
|
+
_FINGERPRINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
27
|
+
("git", ("usage: git", "These are common Git commands", "git help")),
|
|
28
|
+
("cobra", ("Available Commands:", "Flags:", "(default")),
|
|
29
|
+
("clap", ("Commands:", "Options:", "possible values:", "[default:")),
|
|
30
|
+
("dotnet", ("Direct commands:", "Show help and usage", "Usage:")),
|
|
31
|
+
("typer", ("Usage:", "Options:", "--install-completion", "--show-completion")),
|
|
32
|
+
("click", ("Usage:", "Options:", "Commands:", "Show this message and exit.")),
|
|
33
|
+
("yargs", ("Options:", "[string]", "[boolean]", "[required]")),
|
|
34
|
+
("commander", ("Usage:", "[options]", "[command]", "Commands:")),
|
|
35
|
+
("argparse", ("usage:", "options:", "-h, --help", "positional arguments:")),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_MIN_FINGERPRINT_SCORE = 2
|
|
39
|
+
_ANSI_SEQUENCE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def strip_ansi(text: str) -> str:
|
|
43
|
+
"""Remove ANSI CSI sequences before interpreting help text."""
|
|
44
|
+
return _ANSI_SEQUENCE.sub("", text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def executable_stem(path: str) -> str:
|
|
48
|
+
"""Extract a display name from Windows or POSIX paths on either platform."""
|
|
49
|
+
return PureWindowsPath(path).stem if "\\" in path else Path(path).stem
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class TargetInfo:
|
|
54
|
+
"""What cli-mcp knows about the binary it is about to expose."""
|
|
55
|
+
|
|
56
|
+
binary: str
|
|
57
|
+
resolved_path: str
|
|
58
|
+
origin: Origin
|
|
59
|
+
framework: str | None = None
|
|
60
|
+
root_help: str | None = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def is_script(self) -> bool:
|
|
64
|
+
"""Whether the target is an interpretable script rather than a native binary."""
|
|
65
|
+
return self.origin != "native"
|
|
66
|
+
|
|
67
|
+
def with_help(self, help_text: str | None) -> TargetInfo:
|
|
68
|
+
"""Return a copy carrying the root help text and its fingerprint."""
|
|
69
|
+
return replace(self, root_help=help_text, framework=self.framework or fingerprint(help_text))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def classify(resolved_path: Path) -> Origin:
|
|
73
|
+
"""Detect whether the target is a native binary, a Python script or a Node script."""
|
|
74
|
+
suffix = resolved_path.suffix.lower()
|
|
75
|
+
if suffix in _PY_SUFFIXES:
|
|
76
|
+
return "python"
|
|
77
|
+
if suffix in _NODE_SUFFIXES:
|
|
78
|
+
return "node"
|
|
79
|
+
try:
|
|
80
|
+
first_line = resolved_path.open("rb").readline()
|
|
81
|
+
except OSError:
|
|
82
|
+
return "native"
|
|
83
|
+
if not first_line.startswith(b"#!"):
|
|
84
|
+
return "native"
|
|
85
|
+
shebang = first_line.decode("utf-8", errors="replace").strip().lower()
|
|
86
|
+
if "python" in shebang:
|
|
87
|
+
return "python"
|
|
88
|
+
if "node" in shebang:
|
|
89
|
+
return "node"
|
|
90
|
+
return "native"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reject_batch_target(path: str) -> None:
|
|
94
|
+
"""Reject Windows batch files, which can invoke a shell despite shell=False."""
|
|
95
|
+
if Path(path).suffix.lower() in {".bat", ".cmd"}:
|
|
96
|
+
raise SecurityViolation("Windows .bat/.cmd targets are not supported; use the underlying executable")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_binary(binary: str) -> TargetInfo:
|
|
100
|
+
"""Locate *binary* as a path or on ``PATH`` and classify it."""
|
|
101
|
+
candidate = Path(binary)
|
|
102
|
+
if candidate.is_file():
|
|
103
|
+
resolved: Path | None = candidate
|
|
104
|
+
else:
|
|
105
|
+
found = shutil.which(binary)
|
|
106
|
+
resolved = Path(found) if found else None
|
|
107
|
+
if resolved is None:
|
|
108
|
+
raise BinaryNotFoundError(
|
|
109
|
+
f"binary {binary!r} not found. Install it, pass an absolute path, or add it to PATH."
|
|
110
|
+
)
|
|
111
|
+
reject_batch_target(str(resolved))
|
|
112
|
+
return TargetInfo(binary=binary, resolved_path=str(resolved), origin=classify(resolved))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def launch_for(path: str) -> list[str]:
|
|
116
|
+
"""Command that runs *path*: an interpreter prefix when it is a script."""
|
|
117
|
+
reject_batch_target(path)
|
|
118
|
+
origin = classify(Path(path))
|
|
119
|
+
if origin == "python":
|
|
120
|
+
return [sys.executable, path]
|
|
121
|
+
if origin == "node":
|
|
122
|
+
return [shutil.which("node") or "node", path]
|
|
123
|
+
return [path]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def launch_command(target: TargetInfo) -> list[str]:
|
|
127
|
+
"""Command line for *target*, without subcommand arguments."""
|
|
128
|
+
return launch_for(target.resolved_path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fingerprint(help_text: str | None) -> str | None:
|
|
132
|
+
"""Identify the CLI framework from help output, if a known one matches."""
|
|
133
|
+
if not help_text:
|
|
134
|
+
return None
|
|
135
|
+
help_text = strip_ansi(help_text)
|
|
136
|
+
best_framework: str | None = None
|
|
137
|
+
best_score = 0
|
|
138
|
+
for framework, markers in _FINGERPRINTS:
|
|
139
|
+
score = sum(1 for marker in markers if marker in help_text)
|
|
140
|
+
if score > best_score:
|
|
141
|
+
best_framework = framework
|
|
142
|
+
best_score = score
|
|
143
|
+
return best_framework if best_score >= _MIN_FINGERPRINT_SCORE else None
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Discovery package: strategies, providers, registry and pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cli_mcp.discovery.base import DiscoveryContext, DiscoveryProvider, HelpResult
|
|
6
|
+
from cli_mcp.discovery.engine import coerce_default, command_for, discover_from_help, parameters_from, tool_base
|
|
7
|
+
from cli_mcp.discovery.grammars import GRAMMAR_PROVIDERS, GenericHelpProvider
|
|
8
|
+
from cli_mcp.discovery.help_parser import parse_help
|
|
9
|
+
from cli_mcp.discovery.manifest import (
|
|
10
|
+
ManifestDocument,
|
|
11
|
+
ManifestProvider,
|
|
12
|
+
apply_filters,
|
|
13
|
+
read_manifest,
|
|
14
|
+
)
|
|
15
|
+
from cli_mcp.discovery.node_reflection import NodeReflectionProvider
|
|
16
|
+
from cli_mcp.discovery.pipeline import discover, merge_manifest
|
|
17
|
+
from cli_mcp.discovery.python_reflection import PythonReflectionProvider
|
|
18
|
+
from cli_mcp.discovery.registry import (
|
|
19
|
+
ENTRY_POINT_GROUP,
|
|
20
|
+
ProviderRegistry,
|
|
21
|
+
Registration,
|
|
22
|
+
plugin_providers,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ENTRY_POINT_GROUP",
|
|
27
|
+
"GRAMMAR_PROVIDERS",
|
|
28
|
+
"DiscoveryContext",
|
|
29
|
+
"DiscoveryProvider",
|
|
30
|
+
"GenericHelpProvider",
|
|
31
|
+
"HelpResult",
|
|
32
|
+
"ManifestDocument",
|
|
33
|
+
"ManifestProvider",
|
|
34
|
+
"NodeReflectionProvider",
|
|
35
|
+
"ProviderRegistry",
|
|
36
|
+
"PythonReflectionProvider",
|
|
37
|
+
"Registration",
|
|
38
|
+
"apply_filters",
|
|
39
|
+
"coerce_default",
|
|
40
|
+
"command_for",
|
|
41
|
+
"discover",
|
|
42
|
+
"discover_from_help",
|
|
43
|
+
"merge_manifest",
|
|
44
|
+
"parameters_from",
|
|
45
|
+
"parse_help",
|
|
46
|
+
"plugin_providers",
|
|
47
|
+
"read_manifest",
|
|
48
|
+
"tool_base",
|
|
49
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Discovery provider contract and the capabilities providers may use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from cli_mcp.config.settings import DiscoverySettings, SecuritySettings
|
|
11
|
+
from cli_mcp.detect import TargetInfo, launch_for
|
|
12
|
+
from cli_mcp.errors import ExecutionError
|
|
13
|
+
from cli_mcp.executor.runner import run
|
|
14
|
+
from cli_mcp.models import DiscoveryResult
|
|
15
|
+
|
|
16
|
+
MAX_HELP_BYTES = 400_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class HelpResult:
|
|
21
|
+
"""Captured output of a help invocation."""
|
|
22
|
+
|
|
23
|
+
argv: list[str]
|
|
24
|
+
exit_code: int
|
|
25
|
+
stdout: str
|
|
26
|
+
stderr: str
|
|
27
|
+
timed_out: bool = False
|
|
28
|
+
budget_exhausted: bool = False
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def ok(self) -> bool:
|
|
32
|
+
"""Return whether the invocation produced usable output."""
|
|
33
|
+
if self.timed_out or self.budget_exhausted:
|
|
34
|
+
return False
|
|
35
|
+
return bool(self.stdout.strip() or self.stderr.strip())
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def text(self) -> str:
|
|
39
|
+
"""Help text, preferring stdout with a stderr fallback."""
|
|
40
|
+
return self.stdout if self.stdout.strip() else self.stderr
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class DiscoveryContext:
|
|
45
|
+
"""Runtime capabilities handed to providers: run help commands, read sources."""
|
|
46
|
+
|
|
47
|
+
settings: DiscoverySettings
|
|
48
|
+
cwd: Path | None = None
|
|
49
|
+
help_argv_suffix: str = "--help"
|
|
50
|
+
cache: dict[tuple[str, ...], HelpResult] = field(default_factory=dict)
|
|
51
|
+
_help_calls: int = field(default=0, init=False)
|
|
52
|
+
|
|
53
|
+
def run(self, argv: list[str], timeout: float | None = None, env: dict[str, str] | None = None) -> HelpResult:
|
|
54
|
+
"""Run *argv* without a shell and capture its output."""
|
|
55
|
+
limit = timeout if timeout is not None else self.settings.help_timeout_seconds
|
|
56
|
+
environment = os.environ.copy()
|
|
57
|
+
if env:
|
|
58
|
+
environment.update(env)
|
|
59
|
+
try:
|
|
60
|
+
completed = run(
|
|
61
|
+
argv, SecuritySettings(timeout_seconds=limit, max_output_bytes=MAX_HELP_BYTES),
|
|
62
|
+
self.cwd, env=environment,
|
|
63
|
+
)
|
|
64
|
+
except ExecutionError as exc:
|
|
65
|
+
return HelpResult(argv=list(argv), exit_code=-1, stdout="", stderr=str(exc))
|
|
66
|
+
return HelpResult(
|
|
67
|
+
argv=list(argv),
|
|
68
|
+
exit_code=completed.exit_code,
|
|
69
|
+
stdout=completed.stdout,
|
|
70
|
+
stderr=completed.stderr,
|
|
71
|
+
timed_out=completed.exit_code == -1 and "[cli-mcp] timed out after" in completed.stderr,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def help_for(self, target: TargetInfo, path: list[str] | None = None, suffix: str | None = None) -> HelpResult:
|
|
75
|
+
"""Run cached help queries with a shared budget checked before spawning."""
|
|
76
|
+
argv = [*launch_for(target.resolved_path), *(path or []), suffix or self.help_argv_suffix]
|
|
77
|
+
key = tuple(argv)
|
|
78
|
+
cached = self.cache.get(key)
|
|
79
|
+
if cached is not None:
|
|
80
|
+
return cached
|
|
81
|
+
if self._help_calls >= self.settings.max_commands:
|
|
82
|
+
return HelpResult(
|
|
83
|
+
argv=argv,
|
|
84
|
+
exit_code=-1,
|
|
85
|
+
stdout="",
|
|
86
|
+
stderr=f"discovery help invocation budget exhausted ({self.settings.max_commands} calls)",
|
|
87
|
+
budget_exhausted=True,
|
|
88
|
+
)
|
|
89
|
+
self._help_calls += 1
|
|
90
|
+
result = self.run(argv)
|
|
91
|
+
self.cache[key] = result
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
def read_source(self, target: TargetInfo, max_bytes: int = 200_000) -> str:
|
|
95
|
+
"""Read a script target's source, or return an empty string for binaries."""
|
|
96
|
+
try:
|
|
97
|
+
return Path(target.resolved_path).read_text(encoding="utf-8", errors="replace")[:max_bytes]
|
|
98
|
+
except OSError:
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
def interpreter(self, origin: str) -> str | None:
|
|
102
|
+
"""Return the executable that can run a script target of the given origin."""
|
|
103
|
+
if origin == "python":
|
|
104
|
+
import sys
|
|
105
|
+
|
|
106
|
+
return sys.executable
|
|
107
|
+
if origin == "node":
|
|
108
|
+
import shutil
|
|
109
|
+
|
|
110
|
+
return shutil.which("node")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DiscoveryProvider(Protocol):
|
|
115
|
+
"""A discovery strategy. Third parties implement this and register it."""
|
|
116
|
+
|
|
117
|
+
name: str
|
|
118
|
+
#: Strategy bucket this provider belongs to: "reflection", "help" or "manifest".
|
|
119
|
+
strategy: str
|
|
120
|
+
#: Framework fingerprints this provider understands.
|
|
121
|
+
frameworks: tuple[str, ...]
|
|
122
|
+
|
|
123
|
+
def supports(self, target: TargetInfo) -> bool:
|
|
124
|
+
"""Whether this provider can handle *target*."""
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
def discover(self, target: TargetInfo, ctx: DiscoveryContext) -> DiscoveryResult | None:
|
|
128
|
+
"""Return command definitions, or ``None`` when this provider yields nothing."""
|
|
129
|
+
...
|