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/__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
+ ]
@@ -0,0 +1,7 @@
1
+ """Command line interface package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cli_mcp.cli.main import app, main
6
+
7
+ __all__ = ["app", "main"]
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
+ ]
@@ -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"