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/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""cli-mcp: expose any command-line application as a compliant MCP server."""
|
|
2
|
+
|
|
3
|
+
from cli_mcp.errors import (
|
|
4
|
+
ApprovalRequired,
|
|
5
|
+
BinaryNotFoundError,
|
|
6
|
+
CliMcpError,
|
|
7
|
+
ConfigError,
|
|
8
|
+
DiscoveryError,
|
|
9
|
+
ExecutionError,
|
|
10
|
+
SchemaError,
|
|
11
|
+
SecurityViolation,
|
|
12
|
+
TransportError,
|
|
13
|
+
)
|
|
14
|
+
from cli_mcp.models import CommandSpec, DiscoveryResult, Parameter, ParamKind
|
|
15
|
+
from cli_mcp.version import __version__
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ApprovalRequired",
|
|
19
|
+
"BinaryNotFoundError",
|
|
20
|
+
"CliMcpError",
|
|
21
|
+
"CommandSpec",
|
|
22
|
+
"ConfigError",
|
|
23
|
+
"DiscoveryError",
|
|
24
|
+
"DiscoveryResult",
|
|
25
|
+
"ExecutionError",
|
|
26
|
+
"ParamKind",
|
|
27
|
+
"Parameter",
|
|
28
|
+
"SchemaError",
|
|
29
|
+
"SecurityViolation",
|
|
30
|
+
"TransportError",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
cli_mcp/cli/__init__.py
ADDED
cli_mcp/cli/main.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""``cli-mcp`` command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from cli_mcp.config.loader import template
|
|
16
|
+
from cli_mcp.config.settings import ApprovalMode, Settings, TransportName
|
|
17
|
+
from cli_mcp.core import PreparedCli, prepare, transport_endpoint
|
|
18
|
+
from cli_mcp.errors import CliMcpError
|
|
19
|
+
from cli_mcp.mcp.server import build_server, tool_definitions
|
|
20
|
+
from cli_mcp.plugins import PROTOCOL_DOC, registry_with_plugins
|
|
21
|
+
from cli_mcp.schema.docgen import render_docs
|
|
22
|
+
from cli_mcp.schema.jsonschema import argument_name
|
|
23
|
+
from cli_mcp.schema.openapi import build_openapi
|
|
24
|
+
from cli_mcp.security.policy import AccessPolicy
|
|
25
|
+
from cli_mcp.transport import run_server
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
error_console = Console(stderr=True)
|
|
29
|
+
|
|
30
|
+
def _config_option() -> Any:
|
|
31
|
+
"""Fresh ``--config`` option declaration."""
|
|
32
|
+
return typer.Option(None, "--config", help="Path to a cli-mcp.yaml config file")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _binary_argument(help_text: str) -> Any:
|
|
36
|
+
"""Fresh ``binary`` argument; a config file may supply it instead."""
|
|
37
|
+
return typer.Argument(None, help=help_text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _include_option() -> Any:
|
|
41
|
+
"""Fresh ``--include`` option declaration."""
|
|
42
|
+
return typer.Option(None, "--include", help="Only expose these commands")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _exclude_option() -> Any:
|
|
46
|
+
"""Fresh ``--exclude`` option declaration."""
|
|
47
|
+
return typer.Option(None, "--exclude", help="Never expose these commands")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _depth_option() -> Any:
|
|
51
|
+
"""Fresh ``--max-depth`` option declaration."""
|
|
52
|
+
return typer.Option(None, "--max-depth", help="How deep to walk subcommands")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _reflection_option() -> Any:
|
|
56
|
+
"""Fresh ``--allow-reflection`` option declaration."""
|
|
57
|
+
return typer.Option(False, "--allow-reflection", help="Allow reflection, which imports the target CLI")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _prefix_option() -> Any:
|
|
61
|
+
"""Fresh ``--tool-prefix`` option declaration."""
|
|
62
|
+
return typer.Option(False, "--tool-prefix", help="Prefix tool names with the binary name")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _docs_option() -> Any:
|
|
66
|
+
"""Fresh ``--docs`` option declaration."""
|
|
67
|
+
return typer.Option(True, "--docs/--no-docs", help="Also publish a documentation tool")
|
|
68
|
+
|
|
69
|
+
DESCRIPTION = "Swagger for Command Line Tools: expose any CLI as a fully compliant MCP server."
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fail(message: str) -> None:
|
|
73
|
+
"""Report a failure on stderr and exit with code 2."""
|
|
74
|
+
error_console.print(f"[red]✗ {message}[/red]")
|
|
75
|
+
raise typer.Exit(code=2)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _overrides(
|
|
79
|
+
*,
|
|
80
|
+
transport: TransportName | None = None,
|
|
81
|
+
host: str | None = None,
|
|
82
|
+
port: int | None = None,
|
|
83
|
+
readonly: bool | None = None,
|
|
84
|
+
approval: bool | None = None,
|
|
85
|
+
approval_mode: ApprovalMode | None = None,
|
|
86
|
+
allow: list[str] | None = None,
|
|
87
|
+
timeout: float | None = None,
|
|
88
|
+
include: list[str] | None = None,
|
|
89
|
+
exclude: list[str] | None = None,
|
|
90
|
+
max_depth: int | None = None,
|
|
91
|
+
allow_reflection: bool | None = None,
|
|
92
|
+
tool_prefix: bool | None = None,
|
|
93
|
+
docs: bool | None = None,
|
|
94
|
+
strategies: list[str] | None = None,
|
|
95
|
+
) -> dict[str, object]:
|
|
96
|
+
"""Collect only the flags the user actually passed."""
|
|
97
|
+
return {
|
|
98
|
+
key: value
|
|
99
|
+
for key, value in {
|
|
100
|
+
"transport": transport,
|
|
101
|
+
"host": host,
|
|
102
|
+
"port": port,
|
|
103
|
+
"readonly": readonly,
|
|
104
|
+
"approval": approval,
|
|
105
|
+
"approval_mode": approval_mode,
|
|
106
|
+
"allow": allow,
|
|
107
|
+
"timeout": timeout,
|
|
108
|
+
"include": include,
|
|
109
|
+
"exclude": exclude,
|
|
110
|
+
"max_depth": max_depth,
|
|
111
|
+
"allow_reflection": allow_reflection,
|
|
112
|
+
"tool_prefix": tool_prefix,
|
|
113
|
+
"docs": docs,
|
|
114
|
+
"strategies": strategies,
|
|
115
|
+
}.items()
|
|
116
|
+
if value is not None
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _prepare(binary: str | None, config: str | None, overrides: dict[str, object]) -> PreparedCli:
|
|
121
|
+
"""Discover the binary, turning cli-mcp errors into CLI failures."""
|
|
122
|
+
try:
|
|
123
|
+
return prepare(binary, config=config, overrides=overrides)
|
|
124
|
+
except CliMcpError as exc:
|
|
125
|
+
_fail(str(exc))
|
|
126
|
+
raise typer.Exit(code=2) from exc
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _checklist(prepared: PreparedCli, tool_count: int) -> None:
|
|
130
|
+
"""Print the five-line checklist that announces what cli-mcp did."""
|
|
131
|
+
settings = prepared.settings
|
|
132
|
+
endpoint = transport_endpoint(settings)
|
|
133
|
+
command_count = len(prepared.result.commands)
|
|
134
|
+
strategies = ", ".join(prepared.result.strategies)
|
|
135
|
+
framework = prepared.result.framework or "generic help"
|
|
136
|
+
error_console.print(
|
|
137
|
+
f"[green]✓[/green] Binary detected [dim]{prepared.target.binary}[/dim]",
|
|
138
|
+
f"({prepared.binary_path})",
|
|
139
|
+
)
|
|
140
|
+
error_console.print(
|
|
141
|
+
f"[green]✓[/green] Commands discovered [dim]{command_count}[/dim] via {strategies} ({framework})"
|
|
142
|
+
)
|
|
143
|
+
error_console.print(f"[green]✓[/green] Schemas generated [dim]{command_count}[/dim] input schemas")
|
|
144
|
+
error_console.print(f"[green]✓[/green] MCP tools published [dim]{tool_count}[/dim] tools ({settings.server_name})")
|
|
145
|
+
policy = AccessPolicy(settings.security).summary
|
|
146
|
+
if settings.security.readonly and not settings.security.readonly_argv:
|
|
147
|
+
error_console.print(
|
|
148
|
+
"[yellow]! Tools published, but all executions are blocked: security.readonly_argv is empty.[/yellow]"
|
|
149
|
+
)
|
|
150
|
+
error_console.print(f"[green]✓[/green] MCP server started [dim]{endpoint}[/dim] · security: {policy}")
|
|
151
|
+
for warning in prepared.result.warnings:
|
|
152
|
+
error_console.print(f"[yellow]! {warning}[/yellow]")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
app = typer.Typer(name="cli-mcp", help=DESCRIPTION, no_args_is_help=True, add_completion=False)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def expose(
|
|
160
|
+
binary: str | None = _binary_argument("The CLI to expose as an MCP server"),
|
|
161
|
+
config: str | None = _config_option(),
|
|
162
|
+
transport: TransportName | None = typer.Option(None, "--transport", help="stdio, http, streamable-http or sse"),
|
|
163
|
+
host: str | None = typer.Option(None, "--host", help="Bind host for HTTP transports"),
|
|
164
|
+
port: int | None = typer.Option(None, "--port", help="Bind port for HTTP transports"),
|
|
165
|
+
readonly: bool | None = typer.Option(
|
|
166
|
+
None, "--readonly/--no-readonly", help="Only allow configured exact read-only invocations"
|
|
167
|
+
),
|
|
168
|
+
approval: bool | None = typer.Option(None, "--approval/--no-approval", help="Require approval before risky runs"),
|
|
169
|
+
approval_mode: ApprovalMode | None = typer.Option(None, "--approval-mode", help="auto, prompt or explicit"),
|
|
170
|
+
allow: list[str] | None = typer.Option(None, "--allow", help="Allow list of binaries"),
|
|
171
|
+
timeout: float | None = typer.Option(None, "--timeout", help="Execution timeout in seconds"),
|
|
172
|
+
include: list[str] | None = _include_option(),
|
|
173
|
+
exclude: list[str] | None = _exclude_option(),
|
|
174
|
+
max_depth: int | None = _depth_option(),
|
|
175
|
+
allow_reflection: bool | None = _reflection_option(),
|
|
176
|
+
tool_prefix: bool | None = _prefix_option(),
|
|
177
|
+
docs: bool | None = _docs_option(),
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Discover a CLI, publish it as MCP tools and start the server."""
|
|
180
|
+
prepared = _prepare(binary, config, _overrides(
|
|
181
|
+
transport=transport,
|
|
182
|
+
host=host,
|
|
183
|
+
port=port,
|
|
184
|
+
readonly=readonly,
|
|
185
|
+
approval=approval,
|
|
186
|
+
approval_mode=approval_mode,
|
|
187
|
+
allow=allow,
|
|
188
|
+
timeout=timeout,
|
|
189
|
+
include=include,
|
|
190
|
+
exclude=exclude,
|
|
191
|
+
max_depth=max_depth,
|
|
192
|
+
allow_reflection=allow_reflection,
|
|
193
|
+
tool_prefix=tool_prefix,
|
|
194
|
+
docs=docs,
|
|
195
|
+
))
|
|
196
|
+
server = build_server(prepared.settings, prepared.result)
|
|
197
|
+
tool_count = len(prepared.result.commands) + (1 if prepared.settings.mcp.include_docs_tool else 0)
|
|
198
|
+
_checklist(prepared, tool_count)
|
|
199
|
+
try:
|
|
200
|
+
run_server(server, prepared.settings.mcp)
|
|
201
|
+
except CliMcpError as exc:
|
|
202
|
+
_fail(str(exc))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@app.command()
|
|
206
|
+
def serve(
|
|
207
|
+
binary: str | None = _binary_argument("The CLI to expose as an MCP server"),
|
|
208
|
+
config: str | None = _config_option(),
|
|
209
|
+
transport: TransportName | None = typer.Option(None, "--transport", help="stdio, http, streamable-http or sse"),
|
|
210
|
+
host: str | None = typer.Option(None, "--host", help="Bind host for HTTP transports"),
|
|
211
|
+
port: int | None = typer.Option(None, "--port", help="Bind port for HTTP transports"),
|
|
212
|
+
readonly: bool | None = typer.Option(
|
|
213
|
+
None, "--readonly/--no-readonly", help="Only allow configured exact read-only invocations"
|
|
214
|
+
),
|
|
215
|
+
approval: bool | None = typer.Option(None, "--approval/--no-approval", help="Require approval before risky runs"),
|
|
216
|
+
approval_mode: ApprovalMode | None = typer.Option(None, "--approval-mode", help="auto, prompt or explicit"),
|
|
217
|
+
allow: list[str] | None = typer.Option(None, "--allow", help="Allow list of binaries"),
|
|
218
|
+
include: list[str] | None = _include_option(),
|
|
219
|
+
exclude: list[str] | None = _exclude_option(),
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Start the MCP server without the checklist, for client configs."""
|
|
222
|
+
prepared = _prepare(binary, config, _overrides(
|
|
223
|
+
transport=transport,
|
|
224
|
+
host=host,
|
|
225
|
+
port=port,
|
|
226
|
+
readonly=readonly,
|
|
227
|
+
approval=approval,
|
|
228
|
+
approval_mode=approval_mode,
|
|
229
|
+
allow=allow,
|
|
230
|
+
include=include,
|
|
231
|
+
exclude=exclude,
|
|
232
|
+
))
|
|
233
|
+
server = build_server(prepared.settings, prepared.result)
|
|
234
|
+
try:
|
|
235
|
+
run_server(server, prepared.settings.mcp)
|
|
236
|
+
except CliMcpError as exc:
|
|
237
|
+
_fail(str(exc))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command()
|
|
241
|
+
def inspect(
|
|
242
|
+
binary: str | None = _binary_argument("The CLI to inspect"),
|
|
243
|
+
config: str | None = _config_option(),
|
|
244
|
+
include: list[str] | None = _include_option(),
|
|
245
|
+
exclude: list[str] | None = _exclude_option(),
|
|
246
|
+
max_depth: int | None = _depth_option(),
|
|
247
|
+
allow_reflection: bool | None = _reflection_option(),
|
|
248
|
+
tool_prefix: bool | None = _prefix_option(),
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Show the discovered commands, their arguments and the security policy."""
|
|
251
|
+
prepared = _prepare(binary, config, _overrides(
|
|
252
|
+
include=include,
|
|
253
|
+
exclude=exclude,
|
|
254
|
+
max_depth=max_depth,
|
|
255
|
+
allow_reflection=allow_reflection,
|
|
256
|
+
tool_prefix=tool_prefix,
|
|
257
|
+
))
|
|
258
|
+
console.print(f"[bold]{prepared.target.binary}[/bold] → {prepared.binary_path}")
|
|
259
|
+
console.print(
|
|
260
|
+
f"framework {prepared.result.framework or 'generic'} · strategies {', '.join(prepared.result.strategies)}"
|
|
261
|
+
)
|
|
262
|
+
console.print(f"Security: {AccessPolicy(prepared.settings.security).summary}")
|
|
263
|
+
table = Table(title=f"{len(prepared.result.commands)} MCP tools")
|
|
264
|
+
table.add_column("Tool", style="cyan")
|
|
265
|
+
table.add_column("CLI command")
|
|
266
|
+
table.add_column("Arguments")
|
|
267
|
+
table.add_column("Provider")
|
|
268
|
+
for command in prepared.result.commands:
|
|
269
|
+
arguments = ", ".join(argument_name(param) for param in command.parameters) or "-"
|
|
270
|
+
table.add_row(command.name, command.invocation(prepared.target.binary), arguments, command.provider)
|
|
271
|
+
console.print(table)
|
|
272
|
+
for warning in prepared.result.warnings:
|
|
273
|
+
console.print(f"[yellow]! {warning}[/yellow]")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.command()
|
|
277
|
+
def export(
|
|
278
|
+
binary: str | None = _binary_argument("The CLI to document"),
|
|
279
|
+
config: str | None = _config_option(),
|
|
280
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Markdown file to write"),
|
|
281
|
+
include: list[str] | None = _include_option(),
|
|
282
|
+
exclude: list[str] | None = _exclude_option(),
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Generate Markdown documentation for the discovered tools."""
|
|
285
|
+
prepared = _prepare(binary, config, _overrides(include=include, exclude=exclude))
|
|
286
|
+
path = output or Path("docs") / f"{prepared.settings.binary}-mcp.md"
|
|
287
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
path.write_text(render_docs(prepared.result, prepared.settings.mcp), encoding="utf-8")
|
|
289
|
+
console.print(f"[green]✓[/green] wrote {path} ({len(prepared.result.commands)} tools)")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@app.command("export-schema")
|
|
293
|
+
def export_schema(
|
|
294
|
+
binary: str | None = _binary_argument("The CLI to export"),
|
|
295
|
+
config: str | None = _config_option(),
|
|
296
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="JSON file to write"),
|
|
297
|
+
include: list[str] | None = _include_option(),
|
|
298
|
+
exclude: list[str] | None = _exclude_option(),
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Export the MCP tool definitions with their JSON Schemas."""
|
|
301
|
+
prepared = _prepare(binary, config, _overrides(include=include, exclude=exclude))
|
|
302
|
+
definitions = tool_definitions(prepared.settings, prepared.result)
|
|
303
|
+
payload = {
|
|
304
|
+
"server": prepared.settings.server_name,
|
|
305
|
+
"binary": prepared.settings.binary,
|
|
306
|
+
"tools": [
|
|
307
|
+
{"name": tool.name, "description": tool.description, "inputSchema": tool.schema} for tool in definitions
|
|
308
|
+
],
|
|
309
|
+
}
|
|
310
|
+
path = output or Path("schemas") / f"{prepared.settings.binary}-mcp.json"
|
|
311
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
313
|
+
console.print(f"[green]✓[/green] wrote {path} ({len(payload['tools'])} tools)")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command("export-openapi")
|
|
317
|
+
def export_openapi(
|
|
318
|
+
binary: str | None = _binary_argument("The CLI to export"),
|
|
319
|
+
config: str | None = _config_option(),
|
|
320
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="OpenAPI JSON file to write"),
|
|
321
|
+
include: list[str] | None = _include_option(),
|
|
322
|
+
exclude: list[str] | None = _exclude_option(),
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Export an OpenAPI document describing the exposed commands."""
|
|
325
|
+
prepared = _prepare(binary, config, _overrides(include=include, exclude=exclude))
|
|
326
|
+
document = build_openapi(prepared.result, prepared.settings.mcp)
|
|
327
|
+
path = output or Path("openapi") / f"{prepared.settings.binary}-openapi.json"
|
|
328
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
path.write_text(json.dumps(document, indent=2), encoding="utf-8")
|
|
330
|
+
console.print(f"[green]✓[/green] wrote {path} ({len(document['paths'])} endpoints)")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@app.command()
|
|
334
|
+
def doctor(
|
|
335
|
+
binary: str | None = _binary_argument("The CLI to check"),
|
|
336
|
+
config: str | None = _config_option(),
|
|
337
|
+
allow_reflection: bool | None = _reflection_option(),
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Report discovery, security and transport readiness."""
|
|
340
|
+
prepared = _prepare(binary, config, _overrides(allow_reflection=allow_reflection))
|
|
341
|
+
settings = prepared.settings
|
|
342
|
+
console.print("[bold]cli-mcp doctor[/bold]")
|
|
343
|
+
checks: list[tuple[bool, str]] = [
|
|
344
|
+
(True, f"binary resolved: {prepared.binary_path}"),
|
|
345
|
+
(bool(prepared.result.commands), f"commands discovered: {len(prepared.result.commands)}"),
|
|
346
|
+
(True, f"strategies: {', '.join(prepared.result.strategies)}"),
|
|
347
|
+
(True, f"transport: {settings.mcp.transport} ({transport_endpoint(settings)})"),
|
|
348
|
+
(True, f"security: {AccessPolicy(settings.security).summary}"),
|
|
349
|
+
(True, f"sandbox: {settings.security.sandbox.backend if settings.security.sandbox else 'disabled'}"),
|
|
350
|
+
]
|
|
351
|
+
for ok, message in checks:
|
|
352
|
+
marker = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
353
|
+
console.print(f"{marker} {message}")
|
|
354
|
+
for warning in prepared.result.warnings:
|
|
355
|
+
console.print(f"[yellow]! {warning}[/yellow]")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@app.command()
|
|
359
|
+
def init(
|
|
360
|
+
binary: str = typer.Argument("my-cli", help="The binary to write a config for"),
|
|
361
|
+
transport: TransportName = typer.Option("stdio", "--transport", help="Transport to configure"),
|
|
362
|
+
readonly: bool = typer.Option(False, "--readonly/--no-readonly", help="Enable read-only mode"),
|
|
363
|
+
approval: bool = typer.Option(False, "--approval/--no-approval", help="Enable approval mode"),
|
|
364
|
+
sandbox_image: str | None = typer.Option(
|
|
365
|
+
None, "--sandbox-image", help="Enable container sandboxing with this image"
|
|
366
|
+
),
|
|
367
|
+
output: Path = typer.Option(Path("cli-mcp.yaml"), "--output", "-o", help="Config file to write"),
|
|
368
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing config"),
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Write a commented cli-mcp.yaml template."""
|
|
371
|
+
if output.exists() and not force:
|
|
372
|
+
_fail(f"{output} already exists; pass --force to overwrite")
|
|
373
|
+
text = template(binary, transport=transport, readonly=readonly, approval=approval, sandbox_image=sandbox_image)
|
|
374
|
+
output.write_text(text, encoding="utf-8")
|
|
375
|
+
console.print(f"[green]✓[/green] wrote {output}")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@app.command()
|
|
379
|
+
def providers() -> None:
|
|
380
|
+
"""List discovery providers, including installed plugins."""
|
|
381
|
+
settings = Settings(binary="cli-mcp")
|
|
382
|
+
registry = registry_with_plugins(settings)
|
|
383
|
+
table = Table(title="Discovery providers")
|
|
384
|
+
table.add_column("Name", style="cyan")
|
|
385
|
+
table.add_column("Strategy")
|
|
386
|
+
table.add_column("Frameworks")
|
|
387
|
+
table.add_column("Source")
|
|
388
|
+
for name, strategy, frameworks, source in registry.describe():
|
|
389
|
+
table.add_row(name or "-", strategy, frameworks or "-", source)
|
|
390
|
+
console.print(table)
|
|
391
|
+
console.print(PROTOCOL_DOC)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _prepare_streams() -> None:
|
|
395
|
+
"""Give the output streams a codec that can carry the check and cross glyphs."""
|
|
396
|
+
for stream in (sys.stdout, sys.stderr):
|
|
397
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
398
|
+
if callable(reconfigure):
|
|
399
|
+
with contextlib.suppress(OSError, ValueError): # pragma: no cover - a stream that cannot be reconfigured
|
|
400
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def main() -> None:
|
|
404
|
+
"""Console entry point."""
|
|
405
|
+
_prepare_streams()
|
|
406
|
+
app()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__": # pragma: no cover
|
|
410
|
+
main()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Configuration package: typed settings plus YAML loading."""
|
|
2
|
+
|
|
3
|
+
from cli_mcp.config.loader import (
|
|
4
|
+
CONFIG_FILE_NAMES,
|
|
5
|
+
find_config_file,
|
|
6
|
+
load_settings,
|
|
7
|
+
load_yaml,
|
|
8
|
+
template,
|
|
9
|
+
)
|
|
10
|
+
from cli_mcp.config.settings import (
|
|
11
|
+
DEFAULT_STRATEGIES,
|
|
12
|
+
DiscoverySettings,
|
|
13
|
+
SandboxSettings,
|
|
14
|
+
SecuritySettings,
|
|
15
|
+
ServerSettings,
|
|
16
|
+
Settings,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CONFIG_FILE_NAMES",
|
|
21
|
+
"DEFAULT_STRATEGIES",
|
|
22
|
+
"DiscoverySettings",
|
|
23
|
+
"SandboxSettings",
|
|
24
|
+
"SecuritySettings",
|
|
25
|
+
"ServerSettings",
|
|
26
|
+
"Settings",
|
|
27
|
+
"find_config_file",
|
|
28
|
+
"load_settings",
|
|
29
|
+
"load_yaml",
|
|
30
|
+
"template",
|
|
31
|
+
]
|
cli_mcp/config/loader.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Load YAML configuration and generate configuration templates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from cli_mcp.config.settings import Settings
|
|
11
|
+
from cli_mcp.errors import ConfigError
|
|
12
|
+
|
|
13
|
+
CONFIG_FILE_NAMES: tuple[str, ...] = ("cli-mcp.yaml", "cli-mcp.yml", ".cli-mcp.yaml")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_yaml(path: Path) -> dict[str, Any]:
|
|
17
|
+
"""Read a YAML mapping from *path*."""
|
|
18
|
+
try:
|
|
19
|
+
text = path.read_text(encoding="utf-8")
|
|
20
|
+
except OSError as exc:
|
|
21
|
+
raise ConfigError(f"Cannot read configuration file {path}: {exc}") from exc
|
|
22
|
+
try:
|
|
23
|
+
data = yaml.safe_load(text)
|
|
24
|
+
except yaml.YAMLError as exc:
|
|
25
|
+
raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc
|
|
26
|
+
if data is None:
|
|
27
|
+
return {}
|
|
28
|
+
if not isinstance(data, dict):
|
|
29
|
+
raise ConfigError(f"Configuration file {path} must contain a mapping")
|
|
30
|
+
return data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def unwrap(document: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
"""Accept both flat config and config nested under a ``cli-mcp`` key."""
|
|
35
|
+
nested = document.get("cli-mcp")
|
|
36
|
+
if isinstance(nested, dict):
|
|
37
|
+
merged = dict(document)
|
|
38
|
+
merged.pop("cli-mcp")
|
|
39
|
+
merged.update(nested)
|
|
40
|
+
return merged
|
|
41
|
+
return document
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
#: Keys that belong to the manifest document rather than to the settings model.
|
|
45
|
+
MANIFEST_ONLY_KEYS: tuple[str, ...] = ("tools",)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def settings_from(document: dict[str, Any], binary: str | None = None) -> Settings:
|
|
49
|
+
"""Build :class:`Settings` from a YAML mapping."""
|
|
50
|
+
payload = dict(unwrap(document))
|
|
51
|
+
for key in MANIFEST_ONLY_KEYS:
|
|
52
|
+
payload.pop(key, None)
|
|
53
|
+
if binary and not payload.get("binary"):
|
|
54
|
+
payload["binary"] = binary
|
|
55
|
+
if not payload.get("binary"):
|
|
56
|
+
raise ConfigError("Configuration must define a binary (or pass one on the command line)")
|
|
57
|
+
try:
|
|
58
|
+
return Settings.model_validate(payload)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
raise ConfigError(f"Invalid configuration: {exc}") from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_settings(path: Path | None = None, binary: str | None = None, cwd: Path | None = None) -> Settings:
|
|
64
|
+
"""Load settings from an explicit file, a discovered config file, or defaults."""
|
|
65
|
+
if path is None and cwd is not None:
|
|
66
|
+
path = find_config_file(cwd)
|
|
67
|
+
if path is None:
|
|
68
|
+
if not binary:
|
|
69
|
+
raise ConfigError("No configuration file found and no binary given")
|
|
70
|
+
return settings_from({}, binary=binary)
|
|
71
|
+
return settings_from(load_yaml(path), binary=binary)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_config_file(cwd: Path) -> Path | None:
|
|
75
|
+
"""Find the first config file name present in *cwd*."""
|
|
76
|
+
for name in CONFIG_FILE_NAMES:
|
|
77
|
+
candidate = cwd / name
|
|
78
|
+
if candidate.is_file():
|
|
79
|
+
return candidate
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def template(
|
|
84
|
+
binary: str = "my-cli",
|
|
85
|
+
transport: str = "stdio",
|
|
86
|
+
readonly: bool = False,
|
|
87
|
+
approval: bool = False,
|
|
88
|
+
sandbox_image: str | None = None,
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Render a commented configuration template."""
|
|
91
|
+
security_lines = [
|
|
92
|
+
" security:",
|
|
93
|
+
f" readonly: {'true' if readonly else 'false'}",
|
|
94
|
+
" readonly_argv: [] # exact full argv vectors; read-only denies everything until configured",
|
|
95
|
+
f" approval: {'true' if approval else 'false'}",
|
|
96
|
+
" approval_mode: auto # auto/prompt require a local TTY; explicit always refuses",
|
|
97
|
+
" timeout_seconds: 60",
|
|
98
|
+
" max_output_bytes: 200000",
|
|
99
|
+
" allowlist:",
|
|
100
|
+
f" - {binary}",
|
|
101
|
+
]
|
|
102
|
+
if sandbox_image:
|
|
103
|
+
security_lines += [
|
|
104
|
+
" sandbox:",
|
|
105
|
+
" backend: docker",
|
|
106
|
+
f" image: {sandbox_image}",
|
|
107
|
+
" network: false",
|
|
108
|
+
" workdir_mount: true",
|
|
109
|
+
]
|
|
110
|
+
lines = [
|
|
111
|
+
"# cli-mcp configuration",
|
|
112
|
+
"# See docs/configuration.md for every option.",
|
|
113
|
+
"",
|
|
114
|
+
"cli-mcp:",
|
|
115
|
+
f" binary: {binary}",
|
|
116
|
+
"",
|
|
117
|
+
" mcp:",
|
|
118
|
+
f" transport: {transport}",
|
|
119
|
+
" host: 127.0.0.1",
|
|
120
|
+
" port: 8801",
|
|
121
|
+
" path: /mcp",
|
|
122
|
+
" include_docs_tool: true",
|
|
123
|
+
"",
|
|
124
|
+
*security_lines,
|
|
125
|
+
"",
|
|
126
|
+
" discovery:",
|
|
127
|
+
" strategies: [reflection, help, manifest]",
|
|
128
|
+
" max_depth: 2",
|
|
129
|
+
" allow_import_reflection: false",
|
|
130
|
+
" # include: [reduce, diff]",
|
|
131
|
+
" # exclude: [completion, help]",
|
|
132
|
+
]
|
|
133
|
+
return "\n".join(lines) + "\n"
|