universal-cli-mcp-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. cli_mcp/__init__.py +32 -0
  2. cli_mcp/cli/__init__.py +7 -0
  3. cli_mcp/cli/main.py +410 -0
  4. cli_mcp/config/__init__.py +31 -0
  5. cli_mcp/config/loader.py +133 -0
  6. cli_mcp/config/settings.py +171 -0
  7. cli_mcp/core.py +133 -0
  8. cli_mcp/detect.py +143 -0
  9. cli_mcp/discovery/__init__.py +49 -0
  10. cli_mcp/discovery/base.py +129 -0
  11. cli_mcp/discovery/engine.py +238 -0
  12. cli_mcp/discovery/grammars.py +170 -0
  13. cli_mcp/discovery/help_parser.py +627 -0
  14. cli_mcp/discovery/introspect.py +276 -0
  15. cli_mcp/discovery/manifest.py +161 -0
  16. cli_mcp/discovery/node_reflection.py +126 -0
  17. cli_mcp/discovery/pipeline.py +108 -0
  18. cli_mcp/discovery/python_reflection.py +54 -0
  19. cli_mcp/discovery/reflection_payload.py +92 -0
  20. cli_mcp/discovery/registry.py +124 -0
  21. cli_mcp/errors.py +37 -0
  22. cli_mcp/executor/__init__.py +19 -0
  23. cli_mcp/executor/approval.py +64 -0
  24. cli_mcp/executor/argv.py +63 -0
  25. cli_mcp/executor/runner.py +384 -0
  26. cli_mcp/executor/sandbox.py +31 -0
  27. cli_mcp/mcp/__init__.py +22 -0
  28. cli_mcp/mcp/server.py +80 -0
  29. cli_mcp/mcp/tool_adapter.py +120 -0
  30. cli_mcp/models.py +213 -0
  31. cli_mcp/plugins/__init__.py +41 -0
  32. cli_mcp/schema/__init__.py +31 -0
  33. cli_mcp/schema/docgen.py +104 -0
  34. cli_mcp/schema/jsonschema.py +104 -0
  35. cli_mcp/schema/openapi.py +57 -0
  36. cli_mcp/schema/validate.py +84 -0
  37. cli_mcp/security/__init__.py +26 -0
  38. cli_mcp/security/policy.py +91 -0
  39. cli_mcp/security/readonly.py +173 -0
  40. cli_mcp/transport/__init__.py +49 -0
  41. cli_mcp/version.py +3 -0
  42. universal_cli_mcp_server-0.1.0.dist-info/METADATA +381 -0
  43. universal_cli_mcp_server-0.1.0.dist-info/RECORD +46 -0
  44. universal_cli_mcp_server-0.1.0.dist-info/WHEEL +4 -0
  45. universal_cli_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
  46. universal_cli_mcp_server-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,384 @@
1
+ """Subprocess execution of discovered CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import locale
7
+ import os
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from collections.abc import Callable
13
+ from contextlib import suppress
14
+ from ctypes import wintypes
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import IO, Any
18
+
19
+ from cli_mcp.config.settings import SecuritySettings
20
+ from cli_mcp.detect import reject_batch_target
21
+ from cli_mcp.errors import ExecutionError
22
+ from cli_mcp.executor.sandbox import sandbox_argv, sandbox_available
23
+
24
+ TRUNCATION_NOTE = "\n[cli-mcp: output truncated]"
25
+ _READ_SIZE = 16_384
26
+ _POLL_SECONDS = 0.005
27
+ _CLEANUP_SECONDS = 0.5
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ExecutionResult:
32
+ """What a CLI invocation produced."""
33
+
34
+ stdout: str
35
+ stderr: str
36
+ exit_code: int
37
+ execution_time_ms: int
38
+ argv: list[str]
39
+ sandboxed: bool = False
40
+ truncated: bool = False
41
+
42
+ def as_payload(self) -> dict[str, Any]:
43
+ """Return the JSON payload handed to MCP clients."""
44
+ return {
45
+ "stdout": self.stdout,
46
+ "stderr": self.stderr,
47
+ "exitCode": self.exit_code,
48
+ "executionTimeMs": self.execution_time_ms,
49
+ "argv": self.argv,
50
+ "sandboxed": self.sandboxed,
51
+ "truncated": self.truncated,
52
+ }
53
+
54
+
55
+ def _windows_function(name: str) -> Callable[..., Any]:
56
+ """Resolve a platform-specific ctypes API without requiring it on POSIX."""
57
+ function = getattr(ctypes, name, None)
58
+ if not callable(function):
59
+ raise OSError(f"Windows ctypes API {name!r} is unavailable")
60
+ return lambda *args, **kwargs: function(*args, **kwargs)
61
+
62
+
63
+ def _output_encoding() -> str:
64
+ """Use the Windows CLI OEM code page, or the POSIX locale encoding."""
65
+ if os.name == "nt":
66
+ return f"cp{_windows_function('WinDLL')('kernel32', use_last_error=True).GetOEMCP()}"
67
+ return locale.getencoding()
68
+
69
+
70
+ class _ThreadEntry(ctypes.Structure):
71
+ """The documented Win32 THREADENTRY32 layout."""
72
+
73
+ _fields_ = [
74
+ ("dwSize", wintypes.DWORD),
75
+ ("cntUsage", wintypes.DWORD),
76
+ ("th32ThreadID", wintypes.DWORD),
77
+ ("th32OwnerProcessID", wintypes.DWORD),
78
+ ("tpBasePri", wintypes.LONG),
79
+ ("tpDeltaPri", wintypes.LONG),
80
+ ("dwFlags", wintypes.DWORD),
81
+ ]
82
+
83
+
84
+ class _WindowsTree:
85
+ """Assign a suspended child to a job before allowing it to spawn children."""
86
+
87
+ def __init__(self, process: subprocess.Popen[bytes]) -> None:
88
+ # Win32 symbols are absent from POSIX ctypes and its platform-specific stubs.
89
+ self.api = _windows_function("WinDLL")("kernel32", use_last_error=True)
90
+ signatures = {
91
+ "CreateJobObjectW": ([ctypes.c_void_p, wintypes.LPCWSTR], wintypes.HANDLE),
92
+ "OpenProcess": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
93
+ "AssignProcessToJobObject": ([wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
94
+ "TerminateJobObject": ([wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
95
+ "CloseHandle": ([wintypes.HANDLE], wintypes.BOOL),
96
+ "CreateToolhelp32Snapshot": ([wintypes.DWORD, wintypes.DWORD], wintypes.HANDLE),
97
+ "Thread32First": ([wintypes.HANDLE, ctypes.POINTER(_ThreadEntry)], wintypes.BOOL),
98
+ "Thread32Next": ([wintypes.HANDLE, ctypes.POINTER(_ThreadEntry)], wintypes.BOOL),
99
+ "OpenThread": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
100
+ "ResumeThread": ([wintypes.HANDLE], wintypes.DWORD),
101
+ "PeekNamedPipe": (
102
+ [
103
+ wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD,
104
+ ctypes.c_void_p, ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p,
105
+ ],
106
+ wintypes.BOOL,
107
+ ),
108
+ }
109
+ for name, (arguments, result) in signatures.items():
110
+ function = getattr(self.api, name)
111
+ function.argtypes = arguments
112
+ function.restype = result
113
+ self.job = self.api.CreateJobObjectW(None, None)
114
+ if not self.job:
115
+ raise _windows_error()
116
+ try:
117
+ self._assign(process.pid)
118
+ self._resume(process.pid)
119
+ except BaseException:
120
+ self.close()
121
+ raise
122
+
123
+ def _assign(self, pid: int) -> None:
124
+ handle = self.api.OpenProcess(0x0101, False, pid) # SET_QUOTA | TERMINATE
125
+ if not handle:
126
+ raise _windows_error()
127
+ try:
128
+ if not self.api.AssignProcessToJobObject(self.job, handle):
129
+ raise _windows_error()
130
+ finally:
131
+ self.api.CloseHandle(handle)
132
+
133
+ def _resume(self, pid: int) -> None:
134
+ snapshot = self.api.CreateToolhelp32Snapshot(0x0004, 0) # SNAPTHREAD
135
+ if snapshot == ctypes.c_void_p(-1).value:
136
+ raise _windows_error()
137
+ try:
138
+ entry = _ThreadEntry()
139
+ entry.dwSize = ctypes.sizeof(entry)
140
+ found = self.api.Thread32First(snapshot, ctypes.byref(entry))
141
+ while found:
142
+ if entry.th32OwnerProcessID == pid:
143
+ self._resume_thread(entry.th32ThreadID)
144
+ return
145
+ found = self.api.Thread32Next(snapshot, ctypes.byref(entry))
146
+ raise OSError(f"could not find suspended thread for process {pid}")
147
+ finally:
148
+ self.api.CloseHandle(snapshot)
149
+
150
+ def _resume_thread(self, tid: int) -> None:
151
+ handle = self.api.OpenThread(0x0002, False, tid) # SUSPEND_RESUME
152
+ if not handle:
153
+ raise _windows_error()
154
+ try:
155
+ if self.api.ResumeThread(handle) == 0xFFFFFFFF:
156
+ raise _windows_error()
157
+ finally:
158
+ self.api.CloseHandle(handle)
159
+
160
+ def stop(self) -> None:
161
+ """Terminate only processes belonging to this invocation's job."""
162
+ if not self.api.TerminateJobObject(self.job, 1):
163
+ raise _windows_error()
164
+
165
+ def close(self) -> None:
166
+ """Terminate any remaining descendants and release the job handle."""
167
+ try:
168
+ self.stop()
169
+ finally:
170
+ self.api.CloseHandle(self.job)
171
+
172
+
173
+ def _windows_error(code: int | None = None) -> OSError:
174
+ """Construct the native Windows error without importing Win32-only symbols on POSIX."""
175
+ if code is None:
176
+ code = int(_windows_function("get_last_error")())
177
+ error = _windows_function("WinError")(code)
178
+ if not isinstance(error, OSError):
179
+ raise TypeError("ctypes.WinError did not return an OSError")
180
+ return error
181
+
182
+
183
+ class _PipeCapture:
184
+ """Retain a byte prefix while continuously discarding excess pipe output."""
185
+
186
+ def __init__(self, stream: IO[bytes], limit: int, tree: _WindowsTree | None) -> None:
187
+ self.stream = stream
188
+ self.fd = stream.fileno()
189
+ self.limit = limit
190
+ self.tree = tree
191
+ self.data = bytearray()
192
+ self.truncated = False
193
+ self.finished = False
194
+ if tree is None:
195
+ os.set_blocking(self.fd, False)
196
+
197
+ def read(self) -> bool:
198
+ """Consume one bounded chunk without waiting for data or pipe closure."""
199
+ if self.finished:
200
+ return False
201
+ size = _READ_SIZE
202
+ if self.tree is not None:
203
+ import msvcrt
204
+
205
+ get_osfhandle = getattr(msvcrt, "get_osfhandle", None)
206
+ if not callable(get_osfhandle):
207
+ raise OSError("Windows msvcrt.get_osfhandle API is unavailable")
208
+ available = wintypes.DWORD()
209
+ if not self.tree.api.PeekNamedPipe(
210
+ get_osfhandle(self.fd), None, 0, None, ctypes.byref(available), None,
211
+ ):
212
+ error = int(_windows_function("get_last_error")())
213
+ if error in (109, 232): # BROKEN_PIPE / NO_DATA
214
+ self.finished = True
215
+ return False
216
+ raise _windows_error(error)
217
+ if not available.value:
218
+ return False
219
+ size = min(size, available.value)
220
+ try:
221
+ chunk = os.read(self.fd, size)
222
+ except BlockingIOError:
223
+ return False
224
+ if not chunk:
225
+ self.finished = True
226
+ return False
227
+ remaining = self.limit - len(self.data)
228
+ self.data.extend(chunk[:remaining])
229
+ self.truncated |= len(chunk) > remaining
230
+ return True
231
+
232
+ def text(self, encoding: str) -> str:
233
+ """Prefer strict UTF-8, then the CLI encoding with visible byte escapes."""
234
+ try:
235
+ text = self.data.decode("utf-8", errors="strict")
236
+ except UnicodeDecodeError:
237
+ text = self.data.decode(encoding, errors="backslashreplace")
238
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
239
+ return text + TRUNCATION_NOTE if self.truncated else text
240
+
241
+
242
+ def _capture_until(pipes: list[_PipeCapture], deadline: float) -> bool:
243
+ """Drain both streams fairly until EOF or the monotonic deadline."""
244
+ while not all(pipe.finished for pipe in pipes):
245
+ if time.perf_counter() >= deadline:
246
+ return False
247
+ progressed = False
248
+ for pipe in pipes:
249
+ progressed |= pipe.read()
250
+ if not progressed:
251
+ time.sleep(min(_POLL_SECONDS, max(0.0, deadline - time.perf_counter())))
252
+ return True
253
+
254
+
255
+ def _stop_process(process: subprocess.Popen[bytes], tree: _WindowsTree | None) -> None:
256
+ """Kill the invocation's job/session, never processes selected by name."""
257
+ if tree is not None:
258
+ tree.stop()
259
+ elif sys.platform != "win32":
260
+ with suppress(ProcessLookupError):
261
+ os.killpg(process.pid, signal.SIGKILL)
262
+ elif process.poll() is None:
263
+ process.kill()
264
+
265
+
266
+ def _finish_process(process: subprocess.Popen[bytes], tree: _WindowsTree | None) -> None:
267
+ """Bound reaping and close pipes without reader threads or blocking joins."""
268
+ try:
269
+ if tree is not None:
270
+ tree.close()
271
+ elif process.returncode is None:
272
+ # Do not signal a reaped POSIX PID/group: its ID could be reused.
273
+ _stop_process(process, tree)
274
+ finally:
275
+ for stream in (process.stdout, process.stderr):
276
+ if stream is not None:
277
+ stream.close()
278
+ try:
279
+ process.wait(timeout=_CLEANUP_SECONDS)
280
+ except subprocess.TimeoutExpired as exc:
281
+ raise ExecutionError(f"could not reap process {process.pid} after termination") from exc
282
+
283
+
284
+ def _collect_output(
285
+ process: subprocess.Popen[bytes],
286
+ tree: _WindowsTree | None,
287
+ security: SecuritySettings,
288
+ started: float,
289
+ encoding: str,
290
+ ) -> tuple[str, str, int, bool]:
291
+ """Collect bounded streams and handle the shared process/pipe deadline."""
292
+ assert process.stdout is not None
293
+ assert process.stderr is not None
294
+ pipes = [
295
+ _PipeCapture(process.stdout, security.max_output_bytes, tree),
296
+ _PipeCapture(process.stderr, security.max_output_bytes, tree),
297
+ ]
298
+ deadline = started + security.timeout_seconds
299
+ captured = _capture_until(pipes, deadline)
300
+ timed_out = not captured
301
+ if captured:
302
+ try:
303
+ process.wait(timeout=max(0.0, deadline - time.perf_counter()))
304
+ except subprocess.TimeoutExpired:
305
+ timed_out = True
306
+ if timed_out:
307
+ _stop_process(process, tree)
308
+ _capture_until(pipes, time.perf_counter() + _CLEANUP_SECONDS)
309
+ for pipe in pipes:
310
+ pipe.truncated |= not pipe.finished
311
+ stdout, stderr = (pipe.text(encoding) for pipe in pipes)
312
+ if timed_out:
313
+ stderr += f"\n[cli-mcp] timed out after {security.timeout_seconds}s"
314
+ exit_code = -1 if timed_out else process.returncode
315
+ assert exit_code is not None
316
+ return stdout, stderr, exit_code, any(pipe.truncated for pipe in pipes)
317
+
318
+
319
+ def run(
320
+ argv: list[str],
321
+ security: SecuritySettings,
322
+ cwd: Path | None = None,
323
+ *,
324
+ env: dict[str, str] | None = None,
325
+ ) -> ExecutionResult:
326
+ """Run *argv* with bounded raw-byte capture per stream, including timeouts.
327
+
328
+ Diagnostic/truncation notes are outside the byte budget. Decoding tries
329
+ strict UTF-8, then Windows OEM/POSIX locale with visible backslash escapes
330
+ for undecodable bytes, not silent loss or a reader-thread exception.
331
+ Descendants holding inherited pipes count toward the same timeout; remaining
332
+ job/session members are terminated on timeout. Windows job members are also
333
+ terminated after successful exit; already-reaped POSIX IDs are not signaled.
334
+ """
335
+ reject_batch_target(argv[0])
336
+ sandboxed = security.sandbox is not None
337
+ if sandboxed and security.sandbox is not None:
338
+ if not sandbox_available(security.sandbox.backend):
339
+ raise ExecutionError(
340
+ f"sandbox backend {security.sandbox.backend!r} is not available. "
341
+ "Install it, or set security.sandbox.backend to podman."
342
+ )
343
+ argv_to_run = sandbox_argv(argv, security.sandbox, cwd)
344
+ else:
345
+ argv_to_run = list(argv)
346
+
347
+ started = time.perf_counter()
348
+ process: subprocess.Popen[bytes] | None = None
349
+ tree: _WindowsTree | None = None
350
+ try:
351
+ encoding = _output_encoding()
352
+ process = subprocess.Popen(
353
+ argv_to_run,
354
+ stdout=subprocess.PIPE,
355
+ stderr=subprocess.PIPE,
356
+ text=False,
357
+ bufsize=0,
358
+ cwd=str(cwd) if cwd else None,
359
+ env=env,
360
+ start_new_session=os.name != "nt",
361
+ creationflags=0x0004 if os.name == "nt" else 0, # CREATE_SUSPENDED
362
+ )
363
+ if os.name == "nt":
364
+ tree = _WindowsTree(process)
365
+ stdout, stderr, exit_code, truncated = _collect_output(process, tree, security, started, encoding)
366
+ except OSError as exc:
367
+ raise ExecutionError(f"could not run {argv_to_run[0]!r}: {exc}") from exc
368
+ finally:
369
+ if process is not None:
370
+ try:
371
+ _finish_process(process, tree)
372
+ except OSError as exc:
373
+ raise ExecutionError(f"could not clean up {argv_to_run[0]!r}: {exc}") from exc
374
+
375
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
376
+ return ExecutionResult(
377
+ stdout=stdout,
378
+ stderr=stderr,
379
+ exit_code=exit_code,
380
+ execution_time_ms=elapsed_ms,
381
+ argv=argv_to_run,
382
+ sandboxed=sandboxed,
383
+ truncated=truncated,
384
+ )
@@ -0,0 +1,31 @@
1
+ """Container sandboxing for CLI execution.
2
+
3
+ The CLI binary itself runs inside a container started by docker or podman, so a
4
+ misconfigured tool cannot touch the host. The chosen image must contain the CLI;
5
+ for image-less workflows the image can be built to bundle it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from cli_mcp.config.settings import SandboxSettings
13
+
14
+
15
+ def sandbox_argv(argv: list[str], sandbox: SandboxSettings, cwd: Path | None = None) -> list[str]:
16
+ """Wrap *argv* in a ``docker``/``podman`` run invocation."""
17
+ prefix = [sandbox.backend, "run", "--rm"]
18
+ if not sandbox.network:
19
+ prefix.append("--network=none")
20
+ if sandbox.workdir_mount and cwd is not None:
21
+ prefix.extend(["--workdir", sandbox.container_workdir, "-v", f"{cwd}:{sandbox.container_workdir}"])
22
+ prefix.extend(sandbox.extra_args)
23
+ prefix.append(sandbox.image)
24
+ return [*prefix, *argv]
25
+
26
+
27
+ def sandbox_available(backend: str) -> bool:
28
+ """Whether the container backend binary is on PATH."""
29
+ import shutil
30
+
31
+ return shutil.which(backend) is not None
@@ -0,0 +1,22 @@
1
+ """MCP layer: tool adapters and server construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cli_mcp.mcp.server import build_server, publish_schema, tool_definitions, tool_schemas
6
+ from cli_mcp.mcp.tool_adapter import (
7
+ ToolDefinition,
8
+ build_docs_tool,
9
+ build_tool,
10
+ docs_tool_name,
11
+ )
12
+
13
+ __all__ = [
14
+ "ToolDefinition",
15
+ "build_docs_tool",
16
+ "build_server",
17
+ "build_tool",
18
+ "docs_tool_name",
19
+ "publish_schema",
20
+ "tool_definitions",
21
+ "tool_schemas",
22
+ ]
cli_mcp/mcp/server.py ADDED
@@ -0,0 +1,80 @@
1
+ """Build a fully MCP-compliant server from discovered commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from mcp.server.mcpserver import MCPServer
9
+
10
+ from cli_mcp.config.settings import Settings
11
+ from cli_mcp.detect import launch_for
12
+ from cli_mcp.errors import SchemaError
13
+ from cli_mcp.executor.approval import ApprovalGate
14
+ from cli_mcp.mcp.tool_adapter import ToolDefinition, build_docs_tool, build_tool, docs_tool_name
15
+ from cli_mcp.models import DiscoveryResult
16
+ from cli_mcp.schema.docgen import render_docs
17
+ from cli_mcp.security.policy import AccessPolicy
18
+
19
+
20
+ def tool_definitions(settings: Settings, result: DiscoveryResult, cwd: Path | None = None) -> list[ToolDefinition]:
21
+ """Tool definitions for *result*, usable for exports without a server."""
22
+ policy = AccessPolicy(settings.security)
23
+ policy.authorize_binary(settings.binary)
24
+ resolved = result.resolved_path or settings.binary
25
+ launch = launch_for(resolved)
26
+ gate = ApprovalGate(settings.security)
27
+ definitions = [build_tool(spec, launch, settings, policy, gate, cwd) for spec in result.commands]
28
+ if settings.mcp.include_docs_tool:
29
+ definitions.append(build_docs_tool(render_docs(result, settings.mcp), docs_tool_name(resolved)))
30
+ return definitions
31
+
32
+
33
+ def build_server(
34
+ settings: Settings,
35
+ result: DiscoveryResult,
36
+ cwd: Path | None = None,
37
+ gate: ApprovalGate | None = None,
38
+ ) -> MCPServer:
39
+ """Create an MCP server that publishes every discovered command as a tool."""
40
+ policy = AccessPolicy(settings.security)
41
+ policy.authorize_binary(settings.binary)
42
+ resolved = result.resolved_path or settings.binary
43
+ launch = launch_for(resolved)
44
+ approval_gate = gate or ApprovalGate(settings.security)
45
+ server = MCPServer(
46
+ name=settings.server_name,
47
+ title=settings.mcp.title,
48
+ description=settings.mcp.description,
49
+ instructions=settings.mcp.instructions,
50
+ version=settings.mcp.version,
51
+ )
52
+ for spec in result.commands:
53
+ tool = build_tool(spec, launch, settings, policy, approval_gate, cwd)
54
+ server.add_tool(tool.handler, name=tool.name, description=tool.description)
55
+ publish_schema(server, tool.name, tool.schema)
56
+ if settings.mcp.include_docs_tool:
57
+ docs_tool = build_docs_tool(render_docs(result, settings.mcp), docs_tool_name(resolved))
58
+ server.add_tool(docs_tool.handler, name=docs_tool.name, description=docs_tool.description)
59
+ publish_schema(server, docs_tool.name, docs_tool.schema)
60
+ return server
61
+
62
+
63
+ def publish_schema(server: MCPServer, name: str, schema: dict[str, Any]) -> None:
64
+ """Replace the SDK-derived input schema with cli-mcp's exact schema.
65
+
66
+ The SDK derives schemas from the handler signature; cli-mcp synthesises that
67
+ signature from the discovered metadata, and then pins the published schema so
68
+ descriptions, enums and defaults survive exactly as discovered.
69
+ """
70
+ manager = server._tool_manager
71
+ tool = manager.get_tool(name)
72
+ if tool is None:
73
+ raise SchemaError(f"tool {name!r} is not published on the server")
74
+ tool.parameters = schema
75
+
76
+
77
+ def tool_schemas(server: MCPServer) -> dict[str, dict[str, Any]]:
78
+ """Return the published input schemas by tool name, used by ``cli-mcp export-schema``."""
79
+ manager = server._tool_manager
80
+ return {tool.name: dict(tool.parameters) for tool in manager.list_tools()}
@@ -0,0 +1,120 @@
1
+ """Adapting a discovered command into an MCP tool with a faithful input schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ from collections.abc import Awaitable, Callable
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Annotated, Any
12
+
13
+ from pydantic import WithJsonSchema
14
+
15
+ from cli_mcp.config.settings import Settings
16
+ from cli_mcp.detect import executable_stem
17
+ from cli_mcp.errors import SecurityViolation
18
+ from cli_mcp.executor.approval import ApprovalGate
19
+ from cli_mcp.executor.argv import build_argv
20
+ from cli_mcp.executor.runner import run
21
+ from cli_mcp.models import CommandSpec
22
+ from cli_mcp.schema.jsonschema import input_schema
23
+ from cli_mcp.schema.validate import validate_arguments
24
+ from cli_mcp.security.policy import AccessPolicy
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ToolDefinition:
29
+ """One MCP tool: name, description, exact input schema and async handler."""
30
+
31
+ name: str
32
+ description: str
33
+ schema: dict[str, Any]
34
+ handler: Callable[..., Awaitable[str]]
35
+
36
+
37
+ def tool_description(spec: CommandSpec, binary: str, policy: AccessPolicy) -> str:
38
+ """Tool description: what the command does plus its security status."""
39
+ parts = [spec.description or f"Run {spec.invocation(binary)}"]
40
+ parts.append(f"Runs the CLI command `{spec.invocation(binary)}`.")
41
+ if spec.destructive or policy.assess(binary, spec).destructive:
42
+ parts.append("This command is considered destructive and needs approval before it runs.")
43
+ if spec.examples:
44
+ parts.append("Example: " + spec.examples[0])
45
+ return " ".join(parts)
46
+
47
+
48
+ def build_tool(
49
+ spec: CommandSpec,
50
+ launch: list[str],
51
+ settings: Settings,
52
+ policy: AccessPolicy,
53
+ gate: ApprovalGate,
54
+ cwd: Path | None = None,
55
+ ) -> ToolDefinition:
56
+ """Build an MCP tool for one discovered command.
57
+
58
+ *launch* is the command that runs the CLI: an interpreter prefix when the
59
+ target is a script, otherwise the resolved binary path.
60
+ """
61
+ schema = input_schema(spec)
62
+ published = dict(schema)
63
+ published["properties"] = dict(published.get("properties", {}))
64
+ published["additionalProperties"] = False
65
+
66
+ async def handler(**arguments: Any) -> str:
67
+ """Validate, gate and run the command, returning the execution payload as JSON."""
68
+ validated = validate_arguments(spec, arguments)
69
+ argv = build_argv(launch, spec, validated)
70
+ decision = policy.evaluate(settings.binary, spec, argv)
71
+ if not decision.allowed:
72
+ raise SecurityViolation(decision.reason or "command rejected by security policy")
73
+ gate.approve(spec.name, argv, decision)
74
+ result = await asyncio.to_thread(run, argv, settings.security, cwd or settings.execution.cwd)
75
+ return json.dumps(result.as_payload())
76
+
77
+ handler.__doc__ = published.get("description") or spec.description
78
+ handler.__signature__ = _signature(published) # type: ignore[attr-defined]
79
+ description = tool_description(spec, settings.binary, policy)
80
+ return ToolDefinition(name=spec.name, description=description, schema=published, handler=handler)
81
+
82
+
83
+ def _signature(schema: dict[str, Any]) -> inspect.Signature:
84
+ """Keyword-only signature whose JSON Schema matches *schema* exactly."""
85
+ parameters: list[inspect.Parameter] = []
86
+ for name, definition in schema.get("properties", {}).items():
87
+ annotation = Annotated[Any, WithJsonSchema(definition)]
88
+ required = name in schema.get("required", [])
89
+ default = inspect.Parameter.empty if required else None
90
+ parameters.append(
91
+ inspect.Parameter(name, inspect.Parameter.KEYWORD_ONLY, default=default, annotation=annotation)
92
+ )
93
+ return inspect.Signature(parameters, return_annotation=str)
94
+
95
+
96
+ def docs_tool_name(binary: str) -> str:
97
+ """Name of the generated documentation tool."""
98
+ from cli_mcp.models import sanitize_tool_name
99
+
100
+ stem = executable_stem(binary) or binary
101
+ return sanitize_tool_name(f"{stem}_docs")
102
+
103
+
104
+ def build_docs_tool(docs: str, name: str) -> ToolDefinition:
105
+ """Build a tool that returns the generated Markdown documentation for the CLI."""
106
+
107
+ async def handler() -> str:
108
+ """Return the generated documentation."""
109
+ return docs
110
+
111
+ handler.__signature__ = inspect.Signature([], return_annotation=str) # type: ignore[attr-defined]
112
+ description = (
113
+ "Documentation for this CLI: list every tool, its arguments and example invocations before running commands."
114
+ )
115
+ return ToolDefinition(
116
+ name=name,
117
+ description=description,
118
+ schema={"type": "object", "properties": {}, "additionalProperties": False},
119
+ handler=handler,
120
+ )