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
|
@@ -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
|
cli_mcp/mcp/__init__.py
ADDED
|
@@ -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
|
+
)
|