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