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
cli_mcp/models.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Canonical discovery model shared by every provider and every inner layer.
|
|
2
|
+
|
|
3
|
+
The model is deliberately framework-agnostic: providers translate their own
|
|
4
|
+
metadata into :class:`CommandSpec` / :class:`Parameter`, and the schema,
|
|
5
|
+
executor and MCP layers only ever see these objects.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
15
|
+
|
|
16
|
+
JSON_TYPES = frozenset({"string", "integer", "number", "boolean", "array", "object"})
|
|
17
|
+
|
|
18
|
+
_TYPE_ALIASES: dict[str, str] = {
|
|
19
|
+
"str": "string",
|
|
20
|
+
"string": "string",
|
|
21
|
+
"text": "string",
|
|
22
|
+
"file": "string",
|
|
23
|
+
"path": "string",
|
|
24
|
+
"filename": "string",
|
|
25
|
+
"dir": "string",
|
|
26
|
+
"int": "integer",
|
|
27
|
+
"integer": "integer",
|
|
28
|
+
"count": "integer",
|
|
29
|
+
"number": "number",
|
|
30
|
+
"float": "number",
|
|
31
|
+
"double": "number",
|
|
32
|
+
"bool": "boolean",
|
|
33
|
+
"boolean": "boolean",
|
|
34
|
+
"flag": "boolean",
|
|
35
|
+
"list": "array",
|
|
36
|
+
"array": "array",
|
|
37
|
+
"json": "object",
|
|
38
|
+
"object": "object",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_TOOL_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
42
|
+
_IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9_]+")
|
|
43
|
+
MAX_TOOL_NAME_LENGTH = 128
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ParamKind(StrEnum):
|
|
47
|
+
"""How a parameter is passed to the underlying CLI."""
|
|
48
|
+
|
|
49
|
+
OPTION = "option"
|
|
50
|
+
FLAG = "flag"
|
|
51
|
+
POSITIONAL = "positional"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def normalize_identifier(value: str) -> str:
|
|
55
|
+
"""Return an MCP/client-safe argument name derived from a CLI flag."""
|
|
56
|
+
cleaned = value.strip().strip("-").replace("-", "_")
|
|
57
|
+
cleaned = _IDENTIFIER_RE.sub("_", cleaned).strip("_")
|
|
58
|
+
if not cleaned:
|
|
59
|
+
return "arg"
|
|
60
|
+
if cleaned[0].isdigit():
|
|
61
|
+
cleaned = f"arg_{cleaned}"
|
|
62
|
+
return cleaned
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sanitize_tool_name(value: str) -> str:
|
|
66
|
+
"""Return a tool name conforming to the MCP tool-name rules."""
|
|
67
|
+
cleaned = _TOOL_NAME_RE.sub("_", value.strip()).strip("._-")
|
|
68
|
+
if not cleaned:
|
|
69
|
+
return "tool"
|
|
70
|
+
if cleaned[0].isdigit():
|
|
71
|
+
cleaned = f"tool_{cleaned}"
|
|
72
|
+
return cleaned[:MAX_TOOL_NAME_LENGTH]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def normalize_type(value: str | None) -> str:
|
|
76
|
+
"""Map framework type spellings onto JSON Schema types."""
|
|
77
|
+
if not value:
|
|
78
|
+
return "string"
|
|
79
|
+
return _TYPE_ALIASES.get(value.strip().lower().strip("[]()"), "string")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Parameter(BaseModel):
|
|
83
|
+
"""One argument, flag or positional of a discovered CLI command."""
|
|
84
|
+
|
|
85
|
+
model_config = ConfigDict(extra="forbid")
|
|
86
|
+
|
|
87
|
+
name: str = Field(description="Argument name published in the MCP tool schema")
|
|
88
|
+
kind: ParamKind = ParamKind.OPTION
|
|
89
|
+
cli_name: str | None = Field(None, description="Original CLI flag name (dashed)")
|
|
90
|
+
short: str | None = Field(None, description="Short flag alias, e.g. 'i' for -i")
|
|
91
|
+
short_only: bool = Field(False, description="The CLI advertises only a short option")
|
|
92
|
+
type: str = Field("string", description="JSON Schema type")
|
|
93
|
+
description: str | None = None
|
|
94
|
+
required: bool = False
|
|
95
|
+
default: Any = None
|
|
96
|
+
choices: list[str] | None = Field(None, description="Allowed values, published as enum")
|
|
97
|
+
repeatable: bool = Field(False, description="Flag may be given more than once")
|
|
98
|
+
metavar: str | None = Field(None, description="Placeholder shown in help output")
|
|
99
|
+
|
|
100
|
+
@field_validator("name")
|
|
101
|
+
@classmethod
|
|
102
|
+
def _names(cls, value: str) -> str:
|
|
103
|
+
return normalize_identifier(value)
|
|
104
|
+
|
|
105
|
+
@field_validator("cli_name")
|
|
106
|
+
@classmethod
|
|
107
|
+
def _cli_names(cls, value: str | None) -> str | None:
|
|
108
|
+
if value is None:
|
|
109
|
+
return None
|
|
110
|
+
stripped = value.strip().lstrip("-")
|
|
111
|
+
return stripped or None
|
|
112
|
+
|
|
113
|
+
@field_validator("short")
|
|
114
|
+
@classmethod
|
|
115
|
+
def _shorts(cls, value: str | None) -> str | None:
|
|
116
|
+
if value is None:
|
|
117
|
+
return None
|
|
118
|
+
stripped = value.strip().lstrip("-")
|
|
119
|
+
return stripped or None
|
|
120
|
+
|
|
121
|
+
@field_validator("type")
|
|
122
|
+
@classmethod
|
|
123
|
+
def _types(cls, value: str) -> str:
|
|
124
|
+
normalized = normalize_type(value)
|
|
125
|
+
return normalized if normalized in JSON_TYPES else "string"
|
|
126
|
+
|
|
127
|
+
@field_validator("choices")
|
|
128
|
+
@classmethod
|
|
129
|
+
def _choices(cls, value: list[str] | None) -> list[str] | None:
|
|
130
|
+
if not value:
|
|
131
|
+
return None
|
|
132
|
+
cleaned = [str(item) for item in value if str(item)]
|
|
133
|
+
return cleaned or None
|
|
134
|
+
|
|
135
|
+
@model_validator(mode="after")
|
|
136
|
+
def _flag_type(self) -> Parameter:
|
|
137
|
+
"""Force the boolean schema type for a CLI toggle flag."""
|
|
138
|
+
if self.kind is ParamKind.FLAG:
|
|
139
|
+
self.type = "boolean"
|
|
140
|
+
if self.short_only and not self.short:
|
|
141
|
+
raise ValueError("short_only requires a short option spelling")
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def cli_flag(self) -> str:
|
|
146
|
+
"""Option spelling as it must appear on the command line."""
|
|
147
|
+
if self.short_only or (self.cli_name is None and self.short):
|
|
148
|
+
return f"-{self.short}"
|
|
149
|
+
return f"--{self.cli_name or self.name}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class CommandSpec(BaseModel):
|
|
153
|
+
"""A discovered CLI command expressed as a candidate MCP tool."""
|
|
154
|
+
|
|
155
|
+
model_config = ConfigDict(extra="forbid")
|
|
156
|
+
|
|
157
|
+
name: str = Field(description="MCP tool name")
|
|
158
|
+
path: list[str] = Field(default_factory=list, description="Subcommand path after the binary")
|
|
159
|
+
description: str = ""
|
|
160
|
+
parameters: list[Parameter] = Field(default_factory=list)
|
|
161
|
+
provider: str = "unknown"
|
|
162
|
+
examples: list[str] = Field(default_factory=list)
|
|
163
|
+
destructive: bool = False
|
|
164
|
+
has_subcommands: bool = False
|
|
165
|
+
|
|
166
|
+
@field_validator("name")
|
|
167
|
+
@classmethod
|
|
168
|
+
def _names(cls, value: str) -> str:
|
|
169
|
+
return sanitize_tool_name(value)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def argv_path(self) -> list[str]:
|
|
173
|
+
"""Argument vector that selects this command (without the binary)."""
|
|
174
|
+
return list(self.path)
|
|
175
|
+
|
|
176
|
+
def invocation(self, binary: str) -> str:
|
|
177
|
+
"""Shell-style invocation string for this command."""
|
|
178
|
+
return " ".join([binary, *self.path])
|
|
179
|
+
|
|
180
|
+
def parameter(self, name: str) -> Parameter | None:
|
|
181
|
+
"""Look up a parameter by its published argument name."""
|
|
182
|
+
target = normalize_identifier(name)
|
|
183
|
+
return next((param for param in self.parameters if param.name == target), None)
|
|
184
|
+
|
|
185
|
+
def ordered_positionals(self) -> list[Parameter]:
|
|
186
|
+
"""Positional parameters in declaration order."""
|
|
187
|
+
return [param for param in self.parameters if param.kind is ParamKind.POSITIONAL]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class DiscoveryResult(BaseModel):
|
|
191
|
+
"""Outcome of a discovery run for one binary."""
|
|
192
|
+
|
|
193
|
+
model_config = ConfigDict(extra="forbid")
|
|
194
|
+
|
|
195
|
+
binary: str
|
|
196
|
+
resolved_path: str | None = None
|
|
197
|
+
framework: str | None = None
|
|
198
|
+
commands: list[CommandSpec] = Field(default_factory=list)
|
|
199
|
+
strategies: list[str] = Field(default_factory=list)
|
|
200
|
+
warnings: list[str] = Field(default_factory=list)
|
|
201
|
+
|
|
202
|
+
def tool_names(self) -> list[str]:
|
|
203
|
+
"""Names of the published tools."""
|
|
204
|
+
return [command.name for command in self.commands]
|
|
205
|
+
|
|
206
|
+
def get(self, name: str) -> CommandSpec | None:
|
|
207
|
+
"""Find a command by tool name."""
|
|
208
|
+
return next((command for command in self.commands if command.name == name), None)
|
|
209
|
+
|
|
210
|
+
def add_warning(self, message: str) -> None:
|
|
211
|
+
"""Record a non-fatal discovery problem."""
|
|
212
|
+
if message not in self.warnings:
|
|
213
|
+
self.warnings.append(message)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Plugin system: third-party discovery providers.
|
|
2
|
+
|
|
3
|
+
A provider implements the :class:`cli_mcp.discovery.base.DiscoveryProvider`
|
|
4
|
+
protocol and is registered through the ``cli_mcp.discovery`` entry-point group:
|
|
5
|
+
|
|
6
|
+
.. code-block:: toml
|
|
7
|
+
|
|
8
|
+
[project.entry-points."cli_mcp.discovery"]
|
|
9
|
+
my-framework = "my_pkg.provider:MyProvider"
|
|
10
|
+
|
|
11
|
+
Provider classes are instantiated with the current ``Settings`` when they accept
|
|
12
|
+
a ``settings`` parameter, otherwise with no arguments.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from cli_mcp.config.settings import Settings
|
|
18
|
+
from cli_mcp.discovery.base import DiscoveryProvider
|
|
19
|
+
from cli_mcp.discovery.registry import ENTRY_POINT_GROUP, ProviderRegistry, plugin_providers
|
|
20
|
+
|
|
21
|
+
PROTOCOL_DOC = """DiscoveryProvider protocol:
|
|
22
|
+
|
|
23
|
+
name: str display name, e.g. "my-framework"
|
|
24
|
+
strategy: str "reflection", "help" or "manifest"
|
|
25
|
+
frameworks: tuple framework fingerprints this provider understands
|
|
26
|
+
supports(target) whether the provider can handle this binary
|
|
27
|
+
discover(target, ctx) return a DiscoveryResult, or None to abstain
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_plugins(settings: Settings) -> list[DiscoveryProvider]:
|
|
32
|
+
"""Load third-party providers from the installed distributions."""
|
|
33
|
+
return plugin_providers(settings)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def registry_with_plugins(settings: Settings, load_plugins: bool = True) -> ProviderRegistry:
|
|
37
|
+
"""Return a registry containing built-ins and any installed plugins."""
|
|
38
|
+
return ProviderRegistry(settings, load_plugins=load_plugins)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = ["ENTRY_POINT_GROUP", "PROTOCOL_DOC", "load_plugins", "registry_with_plugins"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Schema layer: JSON Schema generation, argument validation, docs and OpenAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cli_mcp.schema.docgen import command_section, parameter_summary, render_docs
|
|
6
|
+
from cli_mcp.schema.jsonschema import (
|
|
7
|
+
argument_name,
|
|
8
|
+
cli_hint,
|
|
9
|
+
coerce_choices,
|
|
10
|
+
execution_result_schema,
|
|
11
|
+
input_schema,
|
|
12
|
+
parameter_schema,
|
|
13
|
+
)
|
|
14
|
+
from cli_mcp.schema.openapi import OPENAPI_VERSION, build_openapi, tool_endpoint
|
|
15
|
+
from cli_mcp.schema.validate import validate_arguments
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"OPENAPI_VERSION",
|
|
19
|
+
"argument_name",
|
|
20
|
+
"build_openapi",
|
|
21
|
+
"cli_hint",
|
|
22
|
+
"coerce_choices",
|
|
23
|
+
"command_section",
|
|
24
|
+
"execution_result_schema",
|
|
25
|
+
"input_schema",
|
|
26
|
+
"parameter_schema",
|
|
27
|
+
"parameter_summary",
|
|
28
|
+
"render_docs",
|
|
29
|
+
"tool_endpoint",
|
|
30
|
+
"validate_arguments",
|
|
31
|
+
]
|
cli_mcp/schema/docgen.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Markdown documentation generated from discovered command metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cli_mcp.config.settings import ServerSettings
|
|
8
|
+
from cli_mcp.models import CommandSpec, DiscoveryResult, Parameter
|
|
9
|
+
from cli_mcp.schema.jsonschema import argument_name, cli_hint
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _display_binary(binary: str) -> str:
|
|
13
|
+
"""Return the name a user types, not the absolute path that resolved it."""
|
|
14
|
+
return Path(binary).name if "\\" in binary or "/" in binary else binary
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _escape(text: str) -> str:
|
|
18
|
+
"""Make text safe inside a Markdown table cell."""
|
|
19
|
+
return text.replace("|", "\\|").replace("\n", " ")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _arguments_table(command: CommandSpec) -> list[str]:
|
|
23
|
+
"""Markdown table rows for a command's parameters."""
|
|
24
|
+
lines = ["| Argument | CLI | Type | Required | Default | Description |", "| --- | --- | --- | --- | --- | --- |"]
|
|
25
|
+
for param in command.parameters:
|
|
26
|
+
default = "" if param.default is None else str(param.default)
|
|
27
|
+
choices = f" one of: {', '.join(param.choices)}" if param.choices else ""
|
|
28
|
+
lines.append(
|
|
29
|
+
"| "
|
|
30
|
+
+ " | ".join(
|
|
31
|
+
[
|
|
32
|
+
argument_name(param),
|
|
33
|
+
_escape(cli_hint(param)),
|
|
34
|
+
param.type,
|
|
35
|
+
"yes" if param.required else "no",
|
|
36
|
+
_escape(default),
|
|
37
|
+
_escape((param.description or "") + choices),
|
|
38
|
+
]
|
|
39
|
+
)
|
|
40
|
+
+ " |"
|
|
41
|
+
)
|
|
42
|
+
return lines
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def command_section(command: CommandSpec, binary: str) -> list[str]:
|
|
46
|
+
"""Markdown block for one tool."""
|
|
47
|
+
lines = [f"### `{command.name}`", "", command.description or "No description available.", ""]
|
|
48
|
+
lines.append(f"CLI invocation: `{command.invocation(_display_binary(binary))}`")
|
|
49
|
+
lines.append("")
|
|
50
|
+
if command.parameters:
|
|
51
|
+
lines.extend(_arguments_table(command))
|
|
52
|
+
lines.append("")
|
|
53
|
+
else:
|
|
54
|
+
lines.append("This tool takes no arguments.")
|
|
55
|
+
lines.append("")
|
|
56
|
+
if command.examples:
|
|
57
|
+
lines.append("Examples:")
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("```bash")
|
|
60
|
+
display = _display_binary(binary)
|
|
61
|
+
lines.extend(example.replace(f"{binary} ", f"{display} ") for example in command.examples)
|
|
62
|
+
lines.append("```")
|
|
63
|
+
lines.append("")
|
|
64
|
+
if command.destructive:
|
|
65
|
+
lines.append("> **Security:** this command is treated as destructive and needs approval.")
|
|
66
|
+
lines.append("")
|
|
67
|
+
if command.has_subcommands:
|
|
68
|
+
lines.append("> This command also exposes nested subcommands as separate tools.")
|
|
69
|
+
lines.append("")
|
|
70
|
+
return lines
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_docs(result: DiscoveryResult, server: ServerSettings | None = None) -> str:
|
|
74
|
+
"""Render the full documentation document for a discovery result."""
|
|
75
|
+
name = server.name if server else result.binary
|
|
76
|
+
display = _display_binary(result.binary)
|
|
77
|
+
lines = [f"# `{display}` as an MCP server", ""]
|
|
78
|
+
lines.append(f"Server name: `{name}`")
|
|
79
|
+
if result.framework:
|
|
80
|
+
lines.append(f"Discovery framework: `{result.framework}`")
|
|
81
|
+
if result.strategies:
|
|
82
|
+
lines.append(f"Strategies used: {', '.join(result.strategies)}")
|
|
83
|
+
lines.append(f"Published tools: {len(result.commands)}")
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append("Every tool below maps to one CLI command. Arguments are validated against the")
|
|
86
|
+
lines.append("schema below before the command runs, and unknown arguments are rejected.")
|
|
87
|
+
lines.append("")
|
|
88
|
+
lines.append("## Tools")
|
|
89
|
+
lines.append("")
|
|
90
|
+
for command in result.commands:
|
|
91
|
+
lines.extend(command_section(command, display))
|
|
92
|
+
if result.warnings:
|
|
93
|
+
lines.append("## Discovery notes")
|
|
94
|
+
lines.append("")
|
|
95
|
+
for warning in result.warnings:
|
|
96
|
+
lines.append(f"- {warning}")
|
|
97
|
+
lines.append("")
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parameter_summary(param: Parameter) -> str:
|
|
102
|
+
"""One-line summary of a parameter, used by ``cli-mcp inspect``."""
|
|
103
|
+
required = "required" if param.required else "optional"
|
|
104
|
+
return f"{argument_name(param)} ({param.type}, {required}) — {cli_hint(param)}"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""JSON Schema generation from discovered command metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cli_mcp.models import CommandSpec, Parameter, ParamKind, normalize_identifier
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def argument_name(param: Parameter) -> str:
|
|
11
|
+
"""Return the MCP-facing argument name for a parameter."""
|
|
12
|
+
return normalize_identifier(param.cli_name or param.name)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cli_hint(param: Parameter) -> str:
|
|
16
|
+
"""Describe how the parameter appears on the command line."""
|
|
17
|
+
if param.kind is ParamKind.POSITIONAL:
|
|
18
|
+
metavar = param.metavar or param.cli_name or param.name
|
|
19
|
+
return f"Positional argument: {metavar}"
|
|
20
|
+
flag = param.cli_flag
|
|
21
|
+
if param.kind is ParamKind.FLAG:
|
|
22
|
+
return f"CLI flag: {flag}"
|
|
23
|
+
metavar = param.metavar or param.cli_name or param.name
|
|
24
|
+
return f"CLI option: {flag} {metavar}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def coerce_choices(param: Parameter) -> list[Any]:
|
|
28
|
+
"""Choices narrowed to the parameter's declared numeric type.
|
|
29
|
+
|
|
30
|
+
Help text spells enum values as text (``[choices: 1, 2, 3]``), so numeric
|
|
31
|
+
parameters need numeric values to stay schema-valid.
|
|
32
|
+
"""
|
|
33
|
+
choices = list(param.choices or [])
|
|
34
|
+
if param.type not in {"integer", "number"}:
|
|
35
|
+
return choices
|
|
36
|
+
coerced: list[Any] = []
|
|
37
|
+
for choice in choices:
|
|
38
|
+
try:
|
|
39
|
+
value = float(choice)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return choices
|
|
42
|
+
coerced.append(int(value) if value.is_integer() else value)
|
|
43
|
+
return coerced
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parameter_schema(param: Parameter) -> dict[str, Any]:
|
|
47
|
+
"""Build the JSON Schema for one parameter, keeping description, enum and default."""
|
|
48
|
+
schema: dict[str, Any] = {"type": param.type}
|
|
49
|
+
choices = coerce_choices(param)
|
|
50
|
+
if param.type == "array":
|
|
51
|
+
items: dict[str, Any] = {"type": "string"}
|
|
52
|
+
if choices:
|
|
53
|
+
items["enum"] = choices
|
|
54
|
+
schema["items"] = items
|
|
55
|
+
elif choices:
|
|
56
|
+
schema["enum"] = choices
|
|
57
|
+
if param.default is not None:
|
|
58
|
+
schema["default"] = param.default
|
|
59
|
+
description = param.description or ("Toggle flag" if param.kind is ParamKind.FLAG else "Required value")
|
|
60
|
+
schema["description"] = f"{description} ({cli_hint(param)})"
|
|
61
|
+
if param.repeatable:
|
|
62
|
+
schema["description"] += " May be passed multiple times as an array."
|
|
63
|
+
return schema
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def input_schema(spec: CommandSpec) -> dict[str, Any]:
|
|
67
|
+
"""Build the MCP ``inputSchema`` for a discovered command."""
|
|
68
|
+
properties: dict[str, Any] = {}
|
|
69
|
+
required: list[str] = []
|
|
70
|
+
for param in spec.parameters:
|
|
71
|
+
name = argument_name(param)
|
|
72
|
+
properties[name] = parameter_schema(param)
|
|
73
|
+
if param.required:
|
|
74
|
+
required.append(name)
|
|
75
|
+
schema: dict[str, Any] = {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": properties,
|
|
78
|
+
"additionalProperties": False,
|
|
79
|
+
}
|
|
80
|
+
if required:
|
|
81
|
+
schema["required"] = required
|
|
82
|
+
return schema
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def execution_result_schema() -> dict[str, Any]:
|
|
86
|
+
"""JSON Schema for the tool result cli-mcp returns after running a command."""
|
|
87
|
+
return {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {
|
|
90
|
+
"stdout": {"type": "string", "description": "Standard output of the CLI process"},
|
|
91
|
+
"stderr": {"type": "string", "description": "Standard error of the CLI process"},
|
|
92
|
+
"exitCode": {"type": "integer", "description": "Process exit code"},
|
|
93
|
+
"executionTimeMs": {"type": "integer", "description": "Wall-clock execution time in milliseconds"},
|
|
94
|
+
"argv": {
|
|
95
|
+
"type": "array",
|
|
96
|
+
"items": {"type": "string"},
|
|
97
|
+
"description": "Exact argv the CLI process was started with",
|
|
98
|
+
},
|
|
99
|
+
"truncated": {"type": "boolean", "description": "Whether output was truncated"},
|
|
100
|
+
"sandboxed": {"type": "boolean", "description": "Whether execution used a container sandbox"},
|
|
101
|
+
},
|
|
102
|
+
"required": ["stdout", "stderr", "exitCode", "executionTimeMs"],
|
|
103
|
+
"additionalProperties": False,
|
|
104
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""OpenAPI export: each MCP tool becomes an endpoint, plus the result schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cli_mcp.config.settings import ServerSettings
|
|
8
|
+
from cli_mcp.models import DiscoveryResult
|
|
9
|
+
from cli_mcp.schema.jsonschema import execution_result_schema, input_schema
|
|
10
|
+
|
|
11
|
+
OPENAPI_VERSION = "3.1.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def tool_endpoint(command: Any, binary: str) -> dict[str, Any]:
|
|
15
|
+
"""Build the OpenAPI operation for one MCP tool."""
|
|
16
|
+
description = command.description or f"Run {command.invocation(binary)}"
|
|
17
|
+
return {
|
|
18
|
+
"description": description,
|
|
19
|
+
"requestBody": {
|
|
20
|
+
"required": True,
|
|
21
|
+
"content": {"application/json": {"schema": input_schema(command)}},
|
|
22
|
+
},
|
|
23
|
+
"responses": {
|
|
24
|
+
"200": {
|
|
25
|
+
"description": "CLI execution result",
|
|
26
|
+
"content": {"application/json": {"schema": execution_result_schema()}},
|
|
27
|
+
},
|
|
28
|
+
"400": {"description": "Arguments rejected by schema validation"},
|
|
29
|
+
"403": {"description": "Rejected by the security policy (allow list, read-only mode or approval)"},
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_openapi(result: DiscoveryResult, server: ServerSettings | None = None) -> dict[str, Any]:
|
|
35
|
+
"""Build an OpenAPI 3.1 document describing the published tools."""
|
|
36
|
+
settings = server or ServerSettings(name=result.binary)
|
|
37
|
+
paths: dict[str, Any] = {}
|
|
38
|
+
for command in result.commands:
|
|
39
|
+
paths[f"/tools/{command.name}"] = {"post": tool_endpoint(command, result.binary)}
|
|
40
|
+
document: dict[str, Any] = {
|
|
41
|
+
"openapi": OPENAPI_VERSION,
|
|
42
|
+
"info": {
|
|
43
|
+
"title": settings.title or settings.name or result.binary,
|
|
44
|
+
"description": settings.description or f"{result.binary} exposed as an MCP server by cli-mcp",
|
|
45
|
+
"version": settings.version or "0.1.0",
|
|
46
|
+
},
|
|
47
|
+
"paths": paths,
|
|
48
|
+
"components": {"schemas": {"ExecutionResult": execution_result_schema()}},
|
|
49
|
+
"x-cli-mcp": {
|
|
50
|
+
"binary": result.binary,
|
|
51
|
+
"framework": result.framework,
|
|
52
|
+
"strategies": result.strategies,
|
|
53
|
+
"transport": settings.transport,
|
|
54
|
+
"tool_count": len(result.commands),
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
return document
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Strict argument validation against a discovered command's schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cli_mcp.errors import SchemaError
|
|
8
|
+
from cli_mcp.models import CommandSpec, Parameter, normalize_identifier
|
|
9
|
+
from cli_mcp.schema.jsonschema import argument_name, coerce_choices
|
|
10
|
+
|
|
11
|
+
_TYPE_CHECKERS: dict[str, type | tuple[type, ...]] = {
|
|
12
|
+
"string": str,
|
|
13
|
+
"integer": int,
|
|
14
|
+
"number": (int, float),
|
|
15
|
+
"boolean": bool,
|
|
16
|
+
"array": list,
|
|
17
|
+
"object": dict,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _type_matches(param: Parameter, value: Any) -> bool:
|
|
22
|
+
"""Whether *value* satisfies the parameter's declared type."""
|
|
23
|
+
expected = _TYPE_CHECKERS.get(param.type)
|
|
24
|
+
if expected is None:
|
|
25
|
+
return True
|
|
26
|
+
if param.type == "integer" and isinstance(value, bool):
|
|
27
|
+
return False
|
|
28
|
+
if param.type == "number" and isinstance(value, bool):
|
|
29
|
+
return False
|
|
30
|
+
return isinstance(value, expected)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check(param: Parameter, name: str, value: Any) -> list[str]:
|
|
34
|
+
"""Problems for one supplied argument."""
|
|
35
|
+
problems: list[str] = []
|
|
36
|
+
if not _type_matches(param, value):
|
|
37
|
+
problems.append(f"argument {name!r} must be of type {param.type}, got {type(value).__name__}")
|
|
38
|
+
return problems
|
|
39
|
+
choices = coerce_choices(param)
|
|
40
|
+
if choices:
|
|
41
|
+
if param.type == "array" and isinstance(value, list):
|
|
42
|
+
invalid = [item for item in value if item not in choices]
|
|
43
|
+
if invalid:
|
|
44
|
+
problems.append(f"argument {name!r} has invalid items {invalid}; allowed: {choices}")
|
|
45
|
+
elif value not in choices:
|
|
46
|
+
problems.append(f"argument {name!r} must be one of {choices}, got {value!r}")
|
|
47
|
+
return problems
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_arguments(spec: CommandSpec, arguments: dict[str, Any] | None) -> dict[str, Any]:
|
|
51
|
+
"""Validate tool arguments and return them keyed by canonical argument names.
|
|
52
|
+
|
|
53
|
+
Unknown keys are rejected: an MCP client must not silently pass options the
|
|
54
|
+
CLI does not understand.
|
|
55
|
+
"""
|
|
56
|
+
lookup: dict[str, Parameter] = {}
|
|
57
|
+
for param in spec.parameters:
|
|
58
|
+
lookup[argument_name(param)] = param
|
|
59
|
+
supplied = arguments or {}
|
|
60
|
+
problems: list[str] = []
|
|
61
|
+
missing: set[str] = set()
|
|
62
|
+
normalized: dict[str, Any] = {}
|
|
63
|
+
for key, value in supplied.items():
|
|
64
|
+
name = normalize_identifier(str(key))
|
|
65
|
+
matched = lookup.get(name)
|
|
66
|
+
if matched is None:
|
|
67
|
+
hint = ", ".join(sorted(lookup)) or "none"
|
|
68
|
+
problems.append(f"unknown argument {key!r} for tool {spec.name!r}; supported arguments: {hint}")
|
|
69
|
+
continue
|
|
70
|
+
if value is None:
|
|
71
|
+
if not matched.required:
|
|
72
|
+
continue # the SDK passes None for omitted optional arguments
|
|
73
|
+
missing.add(name)
|
|
74
|
+
problems.append(f"missing required argument {name!r} for tool {spec.name!r}")
|
|
75
|
+
continue
|
|
76
|
+
problems.extend(_check(matched, name, value))
|
|
77
|
+
normalized[argument_name(matched)] = value
|
|
78
|
+
for param in spec.parameters:
|
|
79
|
+
name = argument_name(param)
|
|
80
|
+
if param.required and name not in normalized and name not in missing:
|
|
81
|
+
problems.append(f"missing required argument {name!r} for tool {spec.name!r}")
|
|
82
|
+
if problems:
|
|
83
|
+
raise SchemaError(f"invalid arguments for tool {spec.name}: " + "; ".join(problems))
|
|
84
|
+
return normalized
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Security package."""
|
|
2
|
+
|
|
3
|
+
from cli_mcp.security.policy import AccessPolicy, Decision
|
|
4
|
+
from cli_mcp.security.readonly import (
|
|
5
|
+
BLOCKED_BINARIES,
|
|
6
|
+
DESTRUCTIVE_FLAGS,
|
|
7
|
+
DESTRUCTIVE_VERBS,
|
|
8
|
+
RiskAssessment,
|
|
9
|
+
assess,
|
|
10
|
+
binary_basename,
|
|
11
|
+
is_blocked_binary,
|
|
12
|
+
is_destructive_verb,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BLOCKED_BINARIES",
|
|
17
|
+
"DESTRUCTIVE_FLAGS",
|
|
18
|
+
"DESTRUCTIVE_VERBS",
|
|
19
|
+
"AccessPolicy",
|
|
20
|
+
"Decision",
|
|
21
|
+
"RiskAssessment",
|
|
22
|
+
"assess",
|
|
23
|
+
"binary_basename",
|
|
24
|
+
"is_blocked_binary",
|
|
25
|
+
"is_destructive_verb",
|
|
26
|
+
]
|