mcp-coder-utils 0.1.2__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.
- mcp_coder_utils/__init__.py +1 -0
- mcp_coder_utils/py.typed +0 -0
- mcp_coder_utils/subprocess_runner.py +754 -0
- mcp_coder_utils/subprocess_streaming.py +185 -0
- mcp_coder_utils-0.1.2.dist-info/METADATA +60 -0
- mcp_coder_utils-0.1.2.dist-info/RECORD +9 -0
- mcp_coder_utils-0.1.2.dist-info/WHEEL +5 -0
- mcp_coder_utils-0.1.2.dist-info/licenses/LICENSE +21 -0
- mcp_coder_utils-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared low-level Python helpers for the mcp-coder family of repos."""
|
mcp_coder_utils/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
"""Subprocess execution utilities with process isolation support.
|
|
2
|
+
|
|
3
|
+
This module provides functions for executing command-line tools with proper
|
|
4
|
+
timeout handling and STDIO isolation for Python commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# Re-export for external use (allows catching without direct subprocess import)
|
|
20
|
+
from subprocess import CalledProcessError, SubprocessError, TimeoutExpired
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
MAX_STDERR_IN_ERROR: int = 500
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CommandResult",
|
|
28
|
+
"CommandOptions",
|
|
29
|
+
"MAX_STDERR_IN_ERROR",
|
|
30
|
+
"check_tool_missing_error",
|
|
31
|
+
"execute_command",
|
|
32
|
+
"execute_subprocess",
|
|
33
|
+
"launch_process",
|
|
34
|
+
"format_command",
|
|
35
|
+
"prepare_env",
|
|
36
|
+
"truncate_stderr",
|
|
37
|
+
# Re-exported exceptions
|
|
38
|
+
"CalledProcessError",
|
|
39
|
+
"SubprocessError",
|
|
40
|
+
"TimeoutExpired",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_tool_missing_error(
|
|
45
|
+
stderr: str, tool_name: str, python_path: str
|
|
46
|
+
) -> str | None:
|
|
47
|
+
"""Check if stderr indicates the tool is not installed.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Error message string if the tool is missing, or None if no issue detected.
|
|
51
|
+
"""
|
|
52
|
+
if f"No module named {tool_name}" in stderr:
|
|
53
|
+
return (
|
|
54
|
+
f"{tool_name} is not installed in the Python environment "
|
|
55
|
+
f"at {python_path}."
|
|
56
|
+
)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def truncate_stderr(stderr: str, max_len: int = MAX_STDERR_IN_ERROR) -> str:
|
|
61
|
+
"""Truncate stderr to a maximum length.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The original stderr if within max_len, otherwise truncated with ellipsis.
|
|
65
|
+
"""
|
|
66
|
+
if len(stderr) > max_len:
|
|
67
|
+
return stderr[:max_len] + "..."
|
|
68
|
+
return stderr
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_command(command: list[str]) -> str:
|
|
72
|
+
"""Format a command list as a platform-aware shell string.
|
|
73
|
+
|
|
74
|
+
Uses shlex.join() on Unix, subprocess.list2cmdline() on Windows.
|
|
75
|
+
Truncates at 200 characters with '...' suffix.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The formatted command string, truncated if longer than 200 characters.
|
|
79
|
+
"""
|
|
80
|
+
if os.name == "nt":
|
|
81
|
+
full = subprocess.list2cmdline(command)
|
|
82
|
+
else:
|
|
83
|
+
full = shlex.join(command)
|
|
84
|
+
if len(full) > 200:
|
|
85
|
+
return full[:200] + "..."
|
|
86
|
+
return full
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class CommandResult:
|
|
91
|
+
"""Represents the result of a command execution."""
|
|
92
|
+
|
|
93
|
+
return_code: int
|
|
94
|
+
stdout: str
|
|
95
|
+
stderr: str
|
|
96
|
+
timed_out: bool
|
|
97
|
+
execution_error: str | None = None
|
|
98
|
+
command: list[str] | None = field(default=None)
|
|
99
|
+
runner_type: str | None = field(default=None)
|
|
100
|
+
execution_time_ms: int | None = field(default=None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class CommandOptions:
|
|
105
|
+
"""Configuration options for command execution.
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
cwd: Working directory for the subprocess
|
|
109
|
+
timeout_seconds: Maximum time to wait for process completion
|
|
110
|
+
env: Environment variables for the subprocess. May contain internal
|
|
111
|
+
testing flags prefixed with underscore (e.g., _DISABLE_STDIO_ISOLATION)
|
|
112
|
+
that should NEVER be used in production code.
|
|
113
|
+
env_remove: List of environment variable names to remove after merging
|
|
114
|
+
capture_output: Whether to capture stdout and stderr
|
|
115
|
+
text: Whether to decode output as text
|
|
116
|
+
check: Whether to raise exception on non-zero exit code
|
|
117
|
+
shell: Whether to execute through shell
|
|
118
|
+
input_data: Data to send to subprocess stdin
|
|
119
|
+
|
|
120
|
+
Warning:
|
|
121
|
+
Environment variables starting with underscore (_) are internal testing
|
|
122
|
+
flags that bypass safety mechanisms. They must not be used in production.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
cwd: str | None = None
|
|
126
|
+
timeout_seconds: int = 120
|
|
127
|
+
env: dict[str, str] | None = None
|
|
128
|
+
capture_output: bool = True
|
|
129
|
+
text: bool = True
|
|
130
|
+
check: bool = False
|
|
131
|
+
shell: bool = False
|
|
132
|
+
input_data: str | None = None
|
|
133
|
+
env_remove: list[str] | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_python_command(command: list[str]) -> bool:
|
|
137
|
+
"""Check if a command is a Python execution command.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
True if the command runs a Python interpreter.
|
|
141
|
+
"""
|
|
142
|
+
if not command:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
executable = Path(command[0]).name.lower()
|
|
146
|
+
return (
|
|
147
|
+
executable in ["python", "python3", "python.exe", "python3.exe"]
|
|
148
|
+
or command[0] == sys.executable
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_python_isolation_env() -> dict[str, str]:
|
|
153
|
+
"""Get environment variables for Python subprocess isolation.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Dict of environment variables with MCP isolation settings.
|
|
157
|
+
"""
|
|
158
|
+
env = os.environ.copy()
|
|
159
|
+
|
|
160
|
+
# Python-specific settings to prevent MCP STDIO conflicts
|
|
161
|
+
env.update(
|
|
162
|
+
{
|
|
163
|
+
"PYTHONUNBUFFERED": "1",
|
|
164
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
165
|
+
"PYTHONIOENCODING": "utf-8",
|
|
166
|
+
"PYTHONNOUSERSITE": "1",
|
|
167
|
+
"PYTHONHASHSEED": "0",
|
|
168
|
+
"PYTHONSTARTUP": "",
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Remove MCP-specific variables
|
|
173
|
+
for var in ["MCP_STDIO_TRANSPORT", "MCP_SERVER_NAME", "MCP_CLIENT_PARAMS"]:
|
|
174
|
+
env.pop(var, None)
|
|
175
|
+
|
|
176
|
+
return env
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_utf8_env() -> dict[str, str]:
|
|
180
|
+
"""Get environment variables for UTF-8 encoding support on all subprocess types.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Dict of environment variables with UTF-8 encoding settings.
|
|
184
|
+
"""
|
|
185
|
+
env = os.environ.copy()
|
|
186
|
+
|
|
187
|
+
# Set UTF-8 encoding for all subprocess types
|
|
188
|
+
env.update(
|
|
189
|
+
{
|
|
190
|
+
"PYTHONIOENCODING": "utf-8",
|
|
191
|
+
"PYTHONUTF8": "1",
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# On Windows, also set legacy encoding variables
|
|
196
|
+
if os.name == "nt":
|
|
197
|
+
env.update(
|
|
198
|
+
{
|
|
199
|
+
"PYTHONLEGACYWINDOWSFSENCODING": "utf-8",
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
# On Unix systems, set locale for UTF-8
|
|
204
|
+
env.update(
|
|
205
|
+
{
|
|
206
|
+
"LC_ALL": "C.UTF-8",
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return env
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def prepare_env(
|
|
214
|
+
command: list[str] | str,
|
|
215
|
+
env: dict[str, str] | None,
|
|
216
|
+
env_remove: list[str] | None,
|
|
217
|
+
) -> dict[str, str]:
|
|
218
|
+
"""Build a complete environment dict for subprocess execution.
|
|
219
|
+
|
|
220
|
+
Starts from os.environ, applies Python isolation or UTF-8 settings
|
|
221
|
+
based on command type, merges caller-provided env on top, then
|
|
222
|
+
removes any keys listed in env_remove.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
command: Command as list or string. String commands are always
|
|
226
|
+
treated as non-Python (shell=True implies unknown executable).
|
|
227
|
+
env: Optional caller-provided environment variables to merge.
|
|
228
|
+
env_remove: Optional list of environment variable names to remove.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Complete environment dict ready for subprocess.Popen.
|
|
232
|
+
"""
|
|
233
|
+
if isinstance(command, list) and is_python_command(command):
|
|
234
|
+
result = get_python_isolation_env()
|
|
235
|
+
else:
|
|
236
|
+
result = get_utf8_env()
|
|
237
|
+
|
|
238
|
+
if env:
|
|
239
|
+
result.update(env)
|
|
240
|
+
|
|
241
|
+
for key in env_remove or []:
|
|
242
|
+
result.pop(key, None)
|
|
243
|
+
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _kill_process(
|
|
248
|
+
process: subprocess.Popen[str], logger_instance: logging.Logger
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Kill a subprocess and its children, platform-aware.
|
|
251
|
+
|
|
252
|
+
On Unix, sends SIGTERM to the process group, waits briefly, then
|
|
253
|
+
SIGKILL if the process is still alive. On Windows, uses
|
|
254
|
+
``taskkill /F /T``. Falls back to ``process.kill()`` on failure.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
process: The subprocess to kill.
|
|
258
|
+
logger_instance: Logger used for debug messages on fallback paths.
|
|
259
|
+
"""
|
|
260
|
+
if os.name == "nt":
|
|
261
|
+
try:
|
|
262
|
+
subprocess.run(
|
|
263
|
+
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
|
264
|
+
capture_output=True,
|
|
265
|
+
timeout=5,
|
|
266
|
+
check=False,
|
|
267
|
+
)
|
|
268
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
269
|
+
logger_instance.debug(
|
|
270
|
+
"Taskkill failed, using fallback",
|
|
271
|
+
extra={"error": str(exc), "pid": process.pid},
|
|
272
|
+
)
|
|
273
|
+
process.kill()
|
|
274
|
+
else:
|
|
275
|
+
try:
|
|
276
|
+
if (
|
|
277
|
+
hasattr(os, "killpg")
|
|
278
|
+
and hasattr(os, "getpgid")
|
|
279
|
+
and hasattr(signal, "SIGTERM")
|
|
280
|
+
and hasattr(signal, "SIGKILL")
|
|
281
|
+
):
|
|
282
|
+
os.killpg( # type: ignore[attr-defined,unused-ignore]
|
|
283
|
+
os.getpgid(process.pid), # type: ignore[attr-defined,unused-ignore]
|
|
284
|
+
signal.SIGTERM, # type: ignore[attr-defined,unused-ignore]
|
|
285
|
+
)
|
|
286
|
+
time.sleep(0.5)
|
|
287
|
+
if process.poll() is None:
|
|
288
|
+
os.killpg( # type: ignore[attr-defined,unused-ignore]
|
|
289
|
+
os.getpgid(process.pid), # type: ignore[attr-defined,unused-ignore]
|
|
290
|
+
signal.SIGKILL, # type: ignore[attr-defined,unused-ignore]
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
process.kill()
|
|
294
|
+
except (OSError, ProcessLookupError, AttributeError) as exc:
|
|
295
|
+
logger_instance.debug(
|
|
296
|
+
"Process group kill failed, using fallback",
|
|
297
|
+
extra={"error": str(exc), "pid": process.pid},
|
|
298
|
+
)
|
|
299
|
+
process.kill()
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
process.wait(timeout=2)
|
|
303
|
+
except subprocess.TimeoutExpired:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _run_subprocess( # pylint: disable=too-many-statements
|
|
308
|
+
command: list[str], options: CommandOptions, use_stdio_isolation: bool = False
|
|
309
|
+
) -> subprocess.CompletedProcess[str]:
|
|
310
|
+
"""Run subprocess with or without STDIO isolation.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
command: Command to execute
|
|
314
|
+
options: Execution options
|
|
315
|
+
use_stdio_isolation: Whether to use file-based STDIO isolation
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
CompletedProcess with execution results
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
TimeoutExpired: If the subprocess exceeds the configured timeout.
|
|
322
|
+
"""
|
|
323
|
+
# Track start time for timeout logging
|
|
324
|
+
subprocess_start_time = time.time()
|
|
325
|
+
env = prepare_env(command, options.env, options.env_remove)
|
|
326
|
+
|
|
327
|
+
# Handle input data and stdin
|
|
328
|
+
stdin_value = subprocess.DEVNULL if options.input_data is None else None
|
|
329
|
+
|
|
330
|
+
# Use start_new_session for process isolation (thread-safe alternative to preexec_fn)
|
|
331
|
+
start_new_session = os.name != "nt" # True on Unix, False on Windows
|
|
332
|
+
|
|
333
|
+
# Use file-based STDIO for Python commands if needed
|
|
334
|
+
if use_stdio_isolation and options.capture_output:
|
|
335
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
336
|
+
stdout_file = Path(temp_dir) / "stdout.txt"
|
|
337
|
+
stderr_file = Path(temp_dir) / "stderr.txt"
|
|
338
|
+
|
|
339
|
+
process = None
|
|
340
|
+
stdout_f = None
|
|
341
|
+
stderr_f = None
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
# Open files
|
|
345
|
+
stdout_f = open(stdout_file, "w", encoding="utf-8")
|
|
346
|
+
stderr_f = open(stderr_file, "w", encoding="utf-8")
|
|
347
|
+
|
|
348
|
+
# Use Popen for better process control
|
|
349
|
+
popen_proc = None
|
|
350
|
+
try:
|
|
351
|
+
popen_proc = subprocess.Popen(
|
|
352
|
+
command,
|
|
353
|
+
stdout=stdout_f,
|
|
354
|
+
stderr=stderr_f,
|
|
355
|
+
stdin=(
|
|
356
|
+
stdin_value
|
|
357
|
+
if options.input_data is None
|
|
358
|
+
else subprocess.PIPE
|
|
359
|
+
),
|
|
360
|
+
cwd=options.cwd,
|
|
361
|
+
text=options.text,
|
|
362
|
+
encoding="utf-8" if options.text else None,
|
|
363
|
+
errors="replace", # Replace invalid characters instead of crashing
|
|
364
|
+
env=env,
|
|
365
|
+
shell=options.shell,
|
|
366
|
+
start_new_session=start_new_session,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Communicate with timeout
|
|
370
|
+
try:
|
|
371
|
+
_, _ = popen_proc.communicate(
|
|
372
|
+
input=options.input_data, timeout=options.timeout_seconds
|
|
373
|
+
)
|
|
374
|
+
process = subprocess.CompletedProcess(
|
|
375
|
+
args=command,
|
|
376
|
+
returncode=popen_proc.returncode,
|
|
377
|
+
stdout="", # Will be read from file
|
|
378
|
+
stderr="", # Will be read from file
|
|
379
|
+
)
|
|
380
|
+
except subprocess.TimeoutExpired:
|
|
381
|
+
# Kill the process and all children
|
|
382
|
+
if popen_proc:
|
|
383
|
+
elapsed_time = time.time() - subprocess_start_time
|
|
384
|
+
logger.warning(
|
|
385
|
+
"Killing timed out process",
|
|
386
|
+
extra={
|
|
387
|
+
"mode": "stdio_isolation",
|
|
388
|
+
"pid": popen_proc.pid,
|
|
389
|
+
"command": format_command(command),
|
|
390
|
+
"timeout_seconds": options.timeout_seconds,
|
|
391
|
+
"elapsed_seconds": round(elapsed_time, 1),
|
|
392
|
+
"cwd": options.cwd or "current",
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
_kill_process(popen_proc, logger)
|
|
396
|
+
|
|
397
|
+
# Re-raise the timeout exception
|
|
398
|
+
raise
|
|
399
|
+
|
|
400
|
+
except subprocess.TimeoutExpired:
|
|
401
|
+
# Close files before re-raising to prevent Windows file locking
|
|
402
|
+
# This cleanup is necessary before re-raising the timeout exception
|
|
403
|
+
if stdout_f:
|
|
404
|
+
stdout_f.flush()
|
|
405
|
+
stdout_f.close()
|
|
406
|
+
if stderr_f:
|
|
407
|
+
stderr_f.flush()
|
|
408
|
+
stderr_f.close()
|
|
409
|
+
|
|
410
|
+
# On Windows, add a small delay to help with file handle cleanup
|
|
411
|
+
if os.name == "nt":
|
|
412
|
+
time.sleep(0.1)
|
|
413
|
+
|
|
414
|
+
# Re-raise to be handled by the caller
|
|
415
|
+
raise # pylint: disable=try-except-raise
|
|
416
|
+
finally:
|
|
417
|
+
# Ensure files are closed
|
|
418
|
+
if stdout_f and not stdout_f.closed:
|
|
419
|
+
stdout_f.close()
|
|
420
|
+
if stderr_f and not stderr_f.closed:
|
|
421
|
+
stderr_f.close()
|
|
422
|
+
except subprocess.TimeoutExpired: # pylint: disable=try-except-raise
|
|
423
|
+
raise
|
|
424
|
+
except Exception: # pylint: disable=try-except-raise
|
|
425
|
+
# Let any other exceptions propagate after cleanup in finally block
|
|
426
|
+
raise
|
|
427
|
+
|
|
428
|
+
# Read output files after process completes
|
|
429
|
+
# Use a small delay on Windows to avoid file locking issues
|
|
430
|
+
if os.name == "nt":
|
|
431
|
+
time.sleep(0.2)
|
|
432
|
+
|
|
433
|
+
# Read output files, handling potential errors
|
|
434
|
+
stdout = ""
|
|
435
|
+
stderr = ""
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
if stdout_file.exists():
|
|
439
|
+
stdout = stdout_file.read_text(encoding="utf-8")
|
|
440
|
+
except (OSError, PermissionError) as exc:
|
|
441
|
+
logger.debug("Could not read stdout file", extra={"error": str(exc)})
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
if stderr_file.exists():
|
|
445
|
+
stderr = stderr_file.read_text(encoding="utf-8")
|
|
446
|
+
except (OSError, PermissionError) as exc:
|
|
447
|
+
logger.debug("Could not read stderr file", extra={"error": str(exc)})
|
|
448
|
+
|
|
449
|
+
# Update the process with the actual output read from files
|
|
450
|
+
return subprocess.CompletedProcess(
|
|
451
|
+
args=command,
|
|
452
|
+
returncode=process.returncode if process else 1,
|
|
453
|
+
stdout=stdout,
|
|
454
|
+
stderr=stderr,
|
|
455
|
+
)
|
|
456
|
+
else:
|
|
457
|
+
# Regular execution with better process cleanup
|
|
458
|
+
popen_proc = None
|
|
459
|
+
try:
|
|
460
|
+
if options.capture_output:
|
|
461
|
+
popen_proc = subprocess.Popen(
|
|
462
|
+
command,
|
|
463
|
+
stdout=subprocess.PIPE,
|
|
464
|
+
stderr=subprocess.PIPE,
|
|
465
|
+
stdin=(
|
|
466
|
+
stdin_value if options.input_data is None else subprocess.PIPE
|
|
467
|
+
),
|
|
468
|
+
cwd=options.cwd,
|
|
469
|
+
text=options.text,
|
|
470
|
+
encoding="utf-8" if options.text else None,
|
|
471
|
+
errors="replace", # Replace invalid characters instead of crashing
|
|
472
|
+
env=env,
|
|
473
|
+
shell=options.shell,
|
|
474
|
+
start_new_session=start_new_session,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
stdout, stderr = popen_proc.communicate(
|
|
479
|
+
input=options.input_data, timeout=options.timeout_seconds
|
|
480
|
+
)
|
|
481
|
+
return subprocess.CompletedProcess(
|
|
482
|
+
args=command,
|
|
483
|
+
returncode=popen_proc.returncode,
|
|
484
|
+
stdout=stdout or "",
|
|
485
|
+
stderr=stderr or "",
|
|
486
|
+
)
|
|
487
|
+
except subprocess.TimeoutExpired:
|
|
488
|
+
# Kill the process tree on timeout
|
|
489
|
+
if popen_proc:
|
|
490
|
+
elapsed_time = time.time() - subprocess_start_time
|
|
491
|
+
logger.warning(
|
|
492
|
+
"Killing timed out process",
|
|
493
|
+
extra={
|
|
494
|
+
"mode": "regular_execution",
|
|
495
|
+
"pid": popen_proc.pid,
|
|
496
|
+
"command": format_command(command),
|
|
497
|
+
"timeout_seconds": options.timeout_seconds,
|
|
498
|
+
"elapsed_seconds": round(elapsed_time, 1),
|
|
499
|
+
"cwd": options.cwd or "current",
|
|
500
|
+
},
|
|
501
|
+
)
|
|
502
|
+
_kill_process(popen_proc, logger)
|
|
503
|
+
raise
|
|
504
|
+
else:
|
|
505
|
+
# No output capture needed
|
|
506
|
+
return subprocess.run(
|
|
507
|
+
command,
|
|
508
|
+
capture_output=False,
|
|
509
|
+
cwd=options.cwd,
|
|
510
|
+
text=options.text,
|
|
511
|
+
encoding="utf-8" if options.text else None,
|
|
512
|
+
errors="replace", # Replace invalid characters instead of crashing
|
|
513
|
+
timeout=options.timeout_seconds,
|
|
514
|
+
env=env,
|
|
515
|
+
shell=options.shell,
|
|
516
|
+
stdin=stdin_value,
|
|
517
|
+
input=options.input_data,
|
|
518
|
+
start_new_session=start_new_session,
|
|
519
|
+
check=False,
|
|
520
|
+
)
|
|
521
|
+
except subprocess.TimeoutExpired: # pylint: disable=try-except-raise
|
|
522
|
+
raise
|
|
523
|
+
except Exception: # pylint: disable=try-except-raise
|
|
524
|
+
# Re-raise any other exceptions (this catch-all is needed for cleanup)
|
|
525
|
+
raise
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _run_heartbeat(
|
|
529
|
+
stop_event: threading.Event,
|
|
530
|
+
interval: int,
|
|
531
|
+
message: str,
|
|
532
|
+
start_time: float,
|
|
533
|
+
) -> None:
|
|
534
|
+
"""Heartbeat loop — runs in a daemon thread, logs at regular intervals."""
|
|
535
|
+
while not stop_event.wait(interval):
|
|
536
|
+
elapsed = time.time() - start_time
|
|
537
|
+
minutes, seconds = divmod(int(elapsed), 60)
|
|
538
|
+
logger.info("%s (elapsed: %dm %ds)", message, minutes, seconds)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def execute_subprocess(
|
|
542
|
+
command: list[str],
|
|
543
|
+
options: CommandOptions | None = None,
|
|
544
|
+
heartbeat_interval_seconds: int | None = None,
|
|
545
|
+
heartbeat_message: str = "",
|
|
546
|
+
) -> CommandResult:
|
|
547
|
+
"""Execute a command with automatic STDIO isolation for Python commands.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
command: Command and arguments as a list
|
|
551
|
+
options: Execution options
|
|
552
|
+
heartbeat_interval_seconds: If set and > 0, log a heartbeat message
|
|
553
|
+
at this interval (in seconds) while the subprocess is running.
|
|
554
|
+
heartbeat_message: Message to include in heartbeat log entries.
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
CommandResult with execution details
|
|
558
|
+
|
|
559
|
+
Raises:
|
|
560
|
+
TypeError: If command is None.
|
|
561
|
+
ValueError: If command is empty.
|
|
562
|
+
CalledProcessError: If check is True and the process returns non-zero exit code.
|
|
563
|
+
"""
|
|
564
|
+
if command is None:
|
|
565
|
+
raise TypeError("Command cannot be None")
|
|
566
|
+
|
|
567
|
+
if not command:
|
|
568
|
+
raise ValueError("Command cannot be empty")
|
|
569
|
+
|
|
570
|
+
if options is None:
|
|
571
|
+
options = CommandOptions()
|
|
572
|
+
|
|
573
|
+
start_time = time.time()
|
|
574
|
+
|
|
575
|
+
# Determine if we need STDIO isolation
|
|
576
|
+
disable_isolation = (
|
|
577
|
+
options.env and options.env.get("_DISABLE_STDIO_ISOLATION") == "1"
|
|
578
|
+
)
|
|
579
|
+
use_isolation = is_python_command(command) and not disable_isolation
|
|
580
|
+
|
|
581
|
+
logger.debug(
|
|
582
|
+
"Starting subprocess execution",
|
|
583
|
+
extra={
|
|
584
|
+
"command": command[:3] if command else None,
|
|
585
|
+
"cwd": options.cwd,
|
|
586
|
+
"timeout_seconds": options.timeout_seconds,
|
|
587
|
+
"use_isolation": use_isolation,
|
|
588
|
+
},
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
stop_event = None
|
|
592
|
+
heartbeat_thread = None
|
|
593
|
+
|
|
594
|
+
if heartbeat_interval_seconds and heartbeat_interval_seconds > 0:
|
|
595
|
+
stop_event = threading.Event()
|
|
596
|
+
heartbeat_thread = threading.Thread(
|
|
597
|
+
target=_run_heartbeat,
|
|
598
|
+
args=(
|
|
599
|
+
stop_event,
|
|
600
|
+
heartbeat_interval_seconds,
|
|
601
|
+
heartbeat_message,
|
|
602
|
+
start_time,
|
|
603
|
+
),
|
|
604
|
+
daemon=True,
|
|
605
|
+
)
|
|
606
|
+
heartbeat_thread.start()
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
process = _run_subprocess(command, options, use_isolation)
|
|
610
|
+
|
|
611
|
+
# Handle check parameter
|
|
612
|
+
if options.check and process.returncode != 0:
|
|
613
|
+
raise subprocess.CalledProcessError(
|
|
614
|
+
process.returncode, command, process.stdout, process.stderr
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
execution_time_ms = int((time.time() - start_time) * 1000)
|
|
618
|
+
|
|
619
|
+
return CommandResult(
|
|
620
|
+
return_code=process.returncode,
|
|
621
|
+
stdout=process.stdout or "",
|
|
622
|
+
stderr=process.stderr or "",
|
|
623
|
+
timed_out=False,
|
|
624
|
+
command=command,
|
|
625
|
+
runner_type="subprocess",
|
|
626
|
+
execution_time_ms=execution_time_ms,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
except subprocess.TimeoutExpired:
|
|
630
|
+
execution_time_ms = int((time.time() - start_time) * 1000)
|
|
631
|
+
return CommandResult(
|
|
632
|
+
return_code=1,
|
|
633
|
+
stdout="",
|
|
634
|
+
stderr="",
|
|
635
|
+
timed_out=True,
|
|
636
|
+
execution_error=f"Process timed out after {options.timeout_seconds} seconds",
|
|
637
|
+
command=command,
|
|
638
|
+
runner_type="subprocess",
|
|
639
|
+
execution_time_ms=execution_time_ms,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
except subprocess.CalledProcessError as e:
|
|
643
|
+
if options.check:
|
|
644
|
+
raise
|
|
645
|
+
execution_time_ms = int((time.time() - start_time) * 1000)
|
|
646
|
+
return CommandResult(
|
|
647
|
+
return_code=e.returncode,
|
|
648
|
+
stdout=getattr(e, "stdout", "") or "",
|
|
649
|
+
stderr=getattr(e, "stderr", "") or "",
|
|
650
|
+
timed_out=False,
|
|
651
|
+
command=command,
|
|
652
|
+
runner_type="subprocess",
|
|
653
|
+
execution_time_ms=execution_time_ms,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
except (FileNotFoundError, PermissionError, OSError) as e:
|
|
657
|
+
# Handle file system and permission errors
|
|
658
|
+
execution_time_ms = int((time.time() - start_time) * 1000)
|
|
659
|
+
logger.error(
|
|
660
|
+
"Subprocess execution failed",
|
|
661
|
+
extra={
|
|
662
|
+
"error": str(e),
|
|
663
|
+
"error_type": type(e).__name__,
|
|
664
|
+
"command_preview": command[:3] if command else None,
|
|
665
|
+
},
|
|
666
|
+
)
|
|
667
|
+
return CommandResult(
|
|
668
|
+
return_code=1,
|
|
669
|
+
stdout="",
|
|
670
|
+
stderr="",
|
|
671
|
+
timed_out=False,
|
|
672
|
+
execution_error=f"{type(e).__name__}: {e}",
|
|
673
|
+
command=command,
|
|
674
|
+
runner_type="subprocess",
|
|
675
|
+
execution_time_ms=execution_time_ms,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
finally:
|
|
679
|
+
if stop_event is not None:
|
|
680
|
+
stop_event.set()
|
|
681
|
+
if heartbeat_thread is not None:
|
|
682
|
+
heartbeat_thread.join(timeout=2)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def execute_command(
|
|
686
|
+
command: list[str],
|
|
687
|
+
cwd: str | None = None,
|
|
688
|
+
timeout_seconds: int = 120,
|
|
689
|
+
env: dict[str, str] | None = None,
|
|
690
|
+
) -> CommandResult:
|
|
691
|
+
"""Execute a command with automatic STDIO isolation for Python commands.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
command: Complete command as list (e.g., ["python", "-m", "pylint", "src"])
|
|
695
|
+
cwd: Working directory for subprocess
|
|
696
|
+
timeout_seconds: Timeout in seconds
|
|
697
|
+
env: Optional environment variables
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
CommandResult with execution details and output
|
|
701
|
+
"""
|
|
702
|
+
options = CommandOptions(
|
|
703
|
+
cwd=cwd,
|
|
704
|
+
timeout_seconds=timeout_seconds,
|
|
705
|
+
env=env,
|
|
706
|
+
)
|
|
707
|
+
return execute_subprocess(command, options)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def launch_process(
|
|
711
|
+
command: list[str] | str,
|
|
712
|
+
cwd: str | Path | None = None,
|
|
713
|
+
shell: bool = False,
|
|
714
|
+
env: dict[str, str] | None = None,
|
|
715
|
+
env_remove: list[str] | None = None,
|
|
716
|
+
) -> int:
|
|
717
|
+
"""Launch a process without waiting for it to complete.
|
|
718
|
+
|
|
719
|
+
This is for fire-and-forget process launching where you need the PID
|
|
720
|
+
but don't need to wait for completion or capture output.
|
|
721
|
+
|
|
722
|
+
Environment variables are always inherited from the parent process
|
|
723
|
+
via prepare_env(). The ``env`` parameter merges on top of the parent
|
|
724
|
+
environment rather than replacing it entirely.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
command: Command as list or string (string requires shell=True)
|
|
728
|
+
cwd: Working directory for the process
|
|
729
|
+
shell: Whether to execute through shell (required for string commands)
|
|
730
|
+
env: Extra environment variables merged on top of the parent env
|
|
731
|
+
env_remove: Environment variable names to remove after merging
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
Process ID (PID) of the launched process
|
|
735
|
+
|
|
736
|
+
Example:
|
|
737
|
+
# Launch VSCode — parent env is inherited automatically
|
|
738
|
+
pid = launch_process(["code", "myfile.txt"])
|
|
739
|
+
|
|
740
|
+
# Launch with extra env vars (no need for os.environ.copy())
|
|
741
|
+
pid = launch_process(["code", "myfile.txt"], env={"CUSTOM_VAR": "value"})
|
|
742
|
+
"""
|
|
743
|
+
cwd_str = str(cwd) if cwd else None
|
|
744
|
+
prepared_env = prepare_env(command, env, env_remove)
|
|
745
|
+
|
|
746
|
+
process = subprocess.Popen(
|
|
747
|
+
command,
|
|
748
|
+
cwd=cwd_str,
|
|
749
|
+
shell=shell,
|
|
750
|
+
env=prepared_env,
|
|
751
|
+
stdout=subprocess.DEVNULL,
|
|
752
|
+
stderr=subprocess.DEVNULL,
|
|
753
|
+
)
|
|
754
|
+
return process.pid
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Subprocess streaming utilities with inactivity watchdog support.
|
|
2
|
+
|
|
3
|
+
Provides real-time line-by-line stdout streaming from subprocesses,
|
|
4
|
+
with an optional inactivity watchdog that kills hung processes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Generator
|
|
13
|
+
|
|
14
|
+
from mcp_coder_utils.subprocess_runner import (
|
|
15
|
+
CommandOptions,
|
|
16
|
+
CommandResult,
|
|
17
|
+
_kill_process,
|
|
18
|
+
prepare_env,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"StreamResult",
|
|
25
|
+
"stream_subprocess",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StreamResult:
|
|
30
|
+
"""Iterator wrapper that yields stdout lines and holds the final CommandResult.
|
|
31
|
+
|
|
32
|
+
Consume all lines before accessing ``.result`` — the result is only
|
|
33
|
+
available after the generator is exhausted or the process terminates.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, lines: Generator[str, None, None]) -> None:
|
|
37
|
+
self._lines = lines
|
|
38
|
+
self._result: CommandResult | None = None
|
|
39
|
+
|
|
40
|
+
def __iter__(self) -> "StreamResult":
|
|
41
|
+
"""Return the iterator object itself."""
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def __next__(self) -> str:
|
|
45
|
+
"""Return the next stdout line from the stream."""
|
|
46
|
+
return next(self._lines)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def result(self) -> CommandResult:
|
|
50
|
+
"""Return the final CommandResult after the stream is consumed.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
RuntimeError: If the iterator has not been fully consumed yet.
|
|
54
|
+
"""
|
|
55
|
+
if self._result is None:
|
|
56
|
+
raise RuntimeError("Result not available yet — consume all lines first.")
|
|
57
|
+
return self._result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def stream_subprocess(
|
|
61
|
+
command: list[str],
|
|
62
|
+
options: CommandOptions | None = None,
|
|
63
|
+
inactivity_timeout_seconds: float | None = None,
|
|
64
|
+
) -> StreamResult:
|
|
65
|
+
"""Stream stdout lines from a subprocess with optional inactivity watchdog.
|
|
66
|
+
|
|
67
|
+
Yields one line at a time (with trailing newline stripped). When the
|
|
68
|
+
iterator is exhausted the caller can access the final
|
|
69
|
+
:class:`CommandResult` via ``stream_result.result``.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
command: Command and arguments as a list.
|
|
73
|
+
options: Execution options (timeout_seconds in options is *not*
|
|
74
|
+
used — use *inactivity_timeout_seconds* for watchdog control).
|
|
75
|
+
inactivity_timeout_seconds: Kill the process if no stdout line is
|
|
76
|
+
received for this many seconds. ``None`` disables the watchdog.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A :class:`StreamResult` that yields ``str`` lines and exposes
|
|
80
|
+
``.result`` after iteration.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
TypeError: If command is None.
|
|
84
|
+
ValueError: If command is empty.
|
|
85
|
+
"""
|
|
86
|
+
if command is None:
|
|
87
|
+
raise TypeError("Command cannot be None")
|
|
88
|
+
if not command:
|
|
89
|
+
raise ValueError("Command cannot be empty")
|
|
90
|
+
|
|
91
|
+
if options is None:
|
|
92
|
+
options = CommandOptions()
|
|
93
|
+
|
|
94
|
+
def _generate() -> Generator[str, None, None]:
|
|
95
|
+
env = prepare_env(command, options.env, options.env_remove)
|
|
96
|
+
start_new_session = os.name != "nt"
|
|
97
|
+
start_time = time.time()
|
|
98
|
+
|
|
99
|
+
process = subprocess.Popen(
|
|
100
|
+
command,
|
|
101
|
+
stdout=subprocess.PIPE,
|
|
102
|
+
stderr=subprocess.PIPE,
|
|
103
|
+
cwd=options.cwd,
|
|
104
|
+
text=True,
|
|
105
|
+
encoding="utf-8",
|
|
106
|
+
errors="replace",
|
|
107
|
+
env=env,
|
|
108
|
+
shell=options.shell,
|
|
109
|
+
start_new_session=start_new_session,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
last_activity = time.time()
|
|
113
|
+
watchdog_triggered = False
|
|
114
|
+
stop_watchdog = threading.Event()
|
|
115
|
+
|
|
116
|
+
def _watchdog() -> None:
|
|
117
|
+
nonlocal watchdog_triggered
|
|
118
|
+
while not stop_watchdog.is_set():
|
|
119
|
+
if (time.time() - last_activity) > (inactivity_timeout_seconds or 0):
|
|
120
|
+
watchdog_triggered = True
|
|
121
|
+
logger.warning(
|
|
122
|
+
"Inactivity watchdog triggered",
|
|
123
|
+
extra={
|
|
124
|
+
"pid": process.pid,
|
|
125
|
+
"inactivity_timeout_seconds": inactivity_timeout_seconds,
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
_kill_process(process, logger)
|
|
129
|
+
return
|
|
130
|
+
stop_watchdog.wait(0.5)
|
|
131
|
+
|
|
132
|
+
# Collect stderr in a background thread to avoid pipe-buffer deadlock
|
|
133
|
+
stderr_chunks: list[str] = []
|
|
134
|
+
|
|
135
|
+
def _drain_stderr() -> None:
|
|
136
|
+
assert process.stderr is not None
|
|
137
|
+
for line in process.stderr:
|
|
138
|
+
stderr_chunks.append(line)
|
|
139
|
+
|
|
140
|
+
stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
|
|
141
|
+
stderr_thread.start()
|
|
142
|
+
|
|
143
|
+
watchdog_thread: threading.Thread | None = None
|
|
144
|
+
if inactivity_timeout_seconds is not None:
|
|
145
|
+
watchdog_thread = threading.Thread(target=_watchdog, daemon=True)
|
|
146
|
+
watchdog_thread.start()
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
assert process.stdout is not None # guaranteed by PIPE
|
|
150
|
+
for raw_line in process.stdout:
|
|
151
|
+
last_activity = time.time()
|
|
152
|
+
yield raw_line.rstrip("\n").rstrip("\r")
|
|
153
|
+
|
|
154
|
+
process.wait()
|
|
155
|
+
|
|
156
|
+
finally:
|
|
157
|
+
stop_watchdog.set()
|
|
158
|
+
if watchdog_thread is not None:
|
|
159
|
+
watchdog_thread.join(timeout=2)
|
|
160
|
+
stderr_thread.join(timeout=5.0)
|
|
161
|
+
|
|
162
|
+
stderr = "".join(stderr_chunks)
|
|
163
|
+
|
|
164
|
+
execution_time_ms = int((time.time() - start_time) * 1000)
|
|
165
|
+
|
|
166
|
+
stream_result._result = CommandResult( # noqa: W0212
|
|
167
|
+
return_code=(
|
|
168
|
+
process.returncode if process.returncode is not None else 1
|
|
169
|
+
),
|
|
170
|
+
stdout="", # stdout was streamed line-by-line
|
|
171
|
+
stderr=stderr,
|
|
172
|
+
timed_out=watchdog_triggered,
|
|
173
|
+
execution_error=(
|
|
174
|
+
f"Process killed due to inactivity "
|
|
175
|
+
f"(no output for {inactivity_timeout_seconds}s)"
|
|
176
|
+
if watchdog_triggered
|
|
177
|
+
else None
|
|
178
|
+
),
|
|
179
|
+
command=command,
|
|
180
|
+
runner_type="streaming",
|
|
181
|
+
execution_time_ms=execution_time_ms,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
stream_result = StreamResult(lines=_generate())
|
|
185
|
+
return stream_result
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-coder-utils
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Shared low-level Python helpers (subprocess, logging, fs) for the mcp-coder family of repos
|
|
5
|
+
Author-email: Marcus Jellinghaus <Marcus@Jellinghaus.ch>
|
|
6
|
+
Project-URL: Homepage, https://github.com/MarcusJellinghaus/mcp-coder-utils
|
|
7
|
+
Project-URL: Repository, https://github.com/MarcusJellinghaus/mcp-coder-utils
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/MarcusJellinghaus/mcp-coder-utils/issues
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: structlog>=23.2.0
|
|
17
|
+
Requires-Dist: python-json-logger>=3.3.0
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-xdist; extra == "test"
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mcp-coder-utils[test]; extra == "dev"
|
|
23
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
24
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
|
25
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pylint>=3.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.9.0; extra == "dev"
|
|
28
|
+
Requires-Dist: vulture>=2.14; extra == "dev"
|
|
29
|
+
Requires-Dist: import-linter>=2.0; extra == "dev"
|
|
30
|
+
Requires-Dist: mcp-coder; extra == "dev"
|
|
31
|
+
Requires-Dist: mcp-tools-py; extra == "dev"
|
|
32
|
+
Requires-Dist: mcp-workspace; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# mcp-coder-utils
|
|
36
|
+
|
|
37
|
+
Shared low-level Python helpers (subprocess, logging, fs) for the mcp-coder family of repos.
|
|
38
|
+
|
|
39
|
+
Leaf library: no internal dependencies, language-agnostic, safe to import from any MCP server or client in the mcp-coder ecosystem.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install mcp-coder-utils
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
tools\reinstall_local.bat
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This creates a local `.venv`, installs the package in editable mode with dev dependencies, and overrides the sibling repos (`mcp-coder`, `mcp-tools-py`, `mcp-workspace`) with their latest GitHub versions.
|
|
54
|
+
|
|
55
|
+
## Related repos
|
|
56
|
+
|
|
57
|
+
- [mcp_coder](https://github.com/MarcusJellinghaus/mcp_coder) — CLI client and workflows
|
|
58
|
+
- [mcp-tools-py](https://github.com/MarcusJellinghaus/mcp-tools-py) — MCP server: Python code checks
|
|
59
|
+
- [mcp-workspace](https://github.com/MarcusJellinghaus/mcp-workspace) — MCP server: file operations
|
|
60
|
+
- [mcp-config](https://github.com/MarcusJellinghaus/mcp-config) — MCP client config CLI
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
mcp_coder_utils/__init__.py,sha256=zIYS61fxzcuUU2plZeWwo6MSmn84BHwQHOvYmj42nOk,73
|
|
2
|
+
mcp_coder_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
mcp_coder_utils/subprocess_runner.py,sha256=SJoZRJ78sFQipLj6Oo2NN7TyU8aEEmOFAczPzxI2ktA,25949
|
|
4
|
+
mcp_coder_utils/subprocess_streaming.py,sha256=pIgXSVSLI7wvYmostD3cVI7jwcjAfFU_4pIF2lU21fQ,6107
|
|
5
|
+
mcp_coder_utils-0.1.2.dist-info/licenses/LICENSE,sha256=hRZqBWHdMOHKnst3xDIqKmPhe-pzCwlBEI9nQHABRkU,1075
|
|
6
|
+
mcp_coder_utils-0.1.2.dist-info/METADATA,sha256=rF-vcqgwkcsftNdZJ5d-Wc9wtgmBQlXtpiVsn8Z8ofw,2418
|
|
7
|
+
mcp_coder_utils-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
mcp_coder_utils-0.1.2.dist-info/top_level.txt,sha256=mQ8w7ACfLD2rnarBYUVjYwW1yWqVXFHhzsA46O3PWIg,16
|
|
9
|
+
mcp_coder_utils-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcus Jellinghaus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp_coder_utils
|