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
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
+ ]
@@ -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
+ ]