hanzo-mcp 0.1.20__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.
Potentially problematic release.
This version of hanzo-mcp might be problematic. Click here for more details.
- hanzo_mcp/__init__.py +3 -0
- hanzo_mcp/cli.py +213 -0
- hanzo_mcp/server.py +149 -0
- hanzo_mcp/tools/__init__.py +81 -0
- hanzo_mcp/tools/agent/__init__.py +59 -0
- hanzo_mcp/tools/agent/agent_tool.py +474 -0
- hanzo_mcp/tools/agent/prompt.py +137 -0
- hanzo_mcp/tools/agent/tool_adapter.py +75 -0
- hanzo_mcp/tools/common/__init__.py +18 -0
- hanzo_mcp/tools/common/base.py +216 -0
- hanzo_mcp/tools/common/context.py +444 -0
- hanzo_mcp/tools/common/permissions.py +253 -0
- hanzo_mcp/tools/common/thinking_tool.py +123 -0
- hanzo_mcp/tools/common/validation.py +124 -0
- hanzo_mcp/tools/filesystem/__init__.py +89 -0
- hanzo_mcp/tools/filesystem/base.py +113 -0
- hanzo_mcp/tools/filesystem/content_replace.py +287 -0
- hanzo_mcp/tools/filesystem/directory_tree.py +286 -0
- hanzo_mcp/tools/filesystem/edit_file.py +287 -0
- hanzo_mcp/tools/filesystem/get_file_info.py +170 -0
- hanzo_mcp/tools/filesystem/read_files.py +198 -0
- hanzo_mcp/tools/filesystem/search_content.py +275 -0
- hanzo_mcp/tools/filesystem/write_file.py +162 -0
- hanzo_mcp/tools/jupyter/__init__.py +71 -0
- hanzo_mcp/tools/jupyter/base.py +284 -0
- hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
- hanzo_mcp/tools/jupyter/notebook_operations.py +514 -0
- hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
- hanzo_mcp/tools/project/__init__.py +64 -0
- hanzo_mcp/tools/project/analysis.py +882 -0
- hanzo_mcp/tools/project/base.py +66 -0
- hanzo_mcp/tools/project/project_analyze.py +173 -0
- hanzo_mcp/tools/shell/__init__.py +58 -0
- hanzo_mcp/tools/shell/base.py +148 -0
- hanzo_mcp/tools/shell/command_executor.py +740 -0
- hanzo_mcp/tools/shell/run_command.py +204 -0
- hanzo_mcp/tools/shell/run_script.py +215 -0
- hanzo_mcp/tools/shell/script_tool.py +244 -0
- hanzo_mcp-0.1.20.dist-info/METADATA +111 -0
- hanzo_mcp-0.1.20.dist-info/RECORD +44 -0
- hanzo_mcp-0.1.20.dist-info/WHEEL +5 -0
- hanzo_mcp-0.1.20.dist-info/entry_points.txt +2 -0
- hanzo_mcp-0.1.20.dist-info/licenses/LICENSE +21 -0
- hanzo_mcp-0.1.20.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
"""Command executor tools for Hanzo MCP.
|
|
2
|
+
|
|
3
|
+
This module provides tools for executing shell commands and scripts with
|
|
4
|
+
comprehensive error handling, permissions checking, and progress tracking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import base64
|
|
9
|
+
import os
|
|
10
|
+
import shlex
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from typing import Dict, Optional, final
|
|
15
|
+
|
|
16
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
17
|
+
from mcp.server.fastmcp import FastMCP
|
|
18
|
+
|
|
19
|
+
from hanzo_mcp.tools.common.context import create_tool_context
|
|
20
|
+
from hanzo_mcp.tools.common.permissions import PermissionManager
|
|
21
|
+
from hanzo_mcp.tools.shell.base import CommandResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@final
|
|
25
|
+
class CommandExecutor:
|
|
26
|
+
"""Command executor tools for Hanzo MCP.
|
|
27
|
+
|
|
28
|
+
This class provides tools for executing shell commands and scripts with
|
|
29
|
+
comprehensive error handling, permissions checking, and progress tracking.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self, permission_manager: PermissionManager, verbose: bool = False
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Initialize command execution.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
permission_manager: Permission manager for access control
|
|
39
|
+
verbose: Enable verbose logging
|
|
40
|
+
"""
|
|
41
|
+
self.permission_manager: PermissionManager = permission_manager
|
|
42
|
+
self.verbose: bool = verbose
|
|
43
|
+
|
|
44
|
+
# Excluded commands or patterns
|
|
45
|
+
self.excluded_commands: list[str] = ["rm"]
|
|
46
|
+
|
|
47
|
+
# Map of supported interpreters with special handling
|
|
48
|
+
self.special_interpreters: Dict[
|
|
49
|
+
str,
|
|
50
|
+
Callable[
|
|
51
|
+
[str, str, str], dict[str, str]], Optional[float | None | None,
|
|
52
|
+
Awaitable[CommandResult],
|
|
53
|
+
],
|
|
54
|
+
] = {
|
|
55
|
+
"fish": self._handle_fish_script,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def allow_command(self, command: str) -> None:
|
|
59
|
+
"""Allow a specific command that might otherwise be excluded.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
command: The command to allow
|
|
63
|
+
"""
|
|
64
|
+
if command in self.excluded_commands:
|
|
65
|
+
self.excluded_commands.remove(command)
|
|
66
|
+
|
|
67
|
+
def deny_command(self, command: str) -> None:
|
|
68
|
+
"""Deny a specific command, adding it to the excluded list.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
command: The command to deny
|
|
72
|
+
"""
|
|
73
|
+
if command not in self.excluded_commands:
|
|
74
|
+
self.excluded_commands.append(command)
|
|
75
|
+
|
|
76
|
+
def _log(self, message: str, data: object | None = None) -> None:
|
|
77
|
+
"""Log a message if verbose logging is enabled.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
message: The message to log
|
|
81
|
+
data: Optional data to include with the message
|
|
82
|
+
"""
|
|
83
|
+
if not self.verbose:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if data is not None:
|
|
87
|
+
try:
|
|
88
|
+
import json
|
|
89
|
+
|
|
90
|
+
if isinstance(data, (dict, list)):
|
|
91
|
+
data_str = json.dumps(data)
|
|
92
|
+
else:
|
|
93
|
+
data_str = str(data)
|
|
94
|
+
print(f"DEBUG: {message}: {data_str}", file=sys.stderr)
|
|
95
|
+
except Exception:
|
|
96
|
+
print(f"DEBUG: {message}: {data}", file=sys.stderr)
|
|
97
|
+
else:
|
|
98
|
+
print(f"DEBUG: {message}", file=sys.stderr)
|
|
99
|
+
|
|
100
|
+
def is_command_allowed(self, command: str) -> bool:
|
|
101
|
+
"""Check if a command is allowed based on exclusion lists.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
command: The command to check
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
True if the command is allowed, False otherwise
|
|
108
|
+
"""
|
|
109
|
+
# Check for empty commands
|
|
110
|
+
try:
|
|
111
|
+
args: list[str] = shlex.split(command)
|
|
112
|
+
except ValueError as e:
|
|
113
|
+
self._log(f"Command parsing error: {e}")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
if not args:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
base_command: str = args[0]
|
|
120
|
+
|
|
121
|
+
# Check if base command is in exclusion list
|
|
122
|
+
if base_command in self.excluded_commands:
|
|
123
|
+
self._log(f"Command rejected (in exclusion list): {base_command}")
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
async def execute_command(
|
|
129
|
+
self,
|
|
130
|
+
command: str,
|
|
131
|
+
cwd: str | None = None,
|
|
132
|
+
env: dict[str, str] | None = None,
|
|
133
|
+
timeout: float | None = 60.0,
|
|
134
|
+
use_login_shell: bool = True,
|
|
135
|
+
) -> CommandResult:
|
|
136
|
+
"""Execute a shell command with safety checks.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
command: The command to execute
|
|
140
|
+
cwd: Optional working directory
|
|
141
|
+
env: Optional environment variables
|
|
142
|
+
timeout: Optional timeout in seconds
|
|
143
|
+
use_login_shell: Whether to use login shell. default true (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
CommandResult containing execution results
|
|
147
|
+
"""
|
|
148
|
+
self._log(f"Executing command: {command}")
|
|
149
|
+
|
|
150
|
+
# Check if the command is allowed
|
|
151
|
+
if not self.is_command_allowed(command):
|
|
152
|
+
return CommandResult(
|
|
153
|
+
return_code=1, error_message=f"Command not allowed: {command}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Check working directory permissions if specified
|
|
157
|
+
if cwd:
|
|
158
|
+
if not os.path.isdir(cwd):
|
|
159
|
+
return CommandResult(
|
|
160
|
+
return_code=1,
|
|
161
|
+
error_message=f"Working directory does not exist: {cwd}",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if not self.permission_manager.is_path_allowed(cwd):
|
|
165
|
+
return CommandResult(
|
|
166
|
+
return_code=1, error_message=f"Working directory not allowed: {cwd}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Set up environment
|
|
170
|
+
command_env: dict[str, str] = os.environ.copy()
|
|
171
|
+
if env:
|
|
172
|
+
command_env.update(env)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
# Check if command uses shell features like &&, ||, |, etc. or $ for env vars
|
|
176
|
+
shell_operators = ["&&", "||", "|", ";", ">", "<", "$(", "`", "$"]
|
|
177
|
+
needs_shell = any(op in command for op in shell_operators)
|
|
178
|
+
|
|
179
|
+
if needs_shell or use_login_shell:
|
|
180
|
+
# Determine which shell to use
|
|
181
|
+
shell_cmd = command
|
|
182
|
+
|
|
183
|
+
if use_login_shell:
|
|
184
|
+
# Get the user's login shell
|
|
185
|
+
user_shell = os.environ.get("SHELL", "/bin/bash")
|
|
186
|
+
shell_basename = os.path.basename(user_shell)
|
|
187
|
+
|
|
188
|
+
self._log(f"Using login shell: {user_shell}")
|
|
189
|
+
|
|
190
|
+
# Wrap command with appropriate shell invocation
|
|
191
|
+
if shell_basename == "zsh":
|
|
192
|
+
shell_cmd = f"{user_shell} -l -c '{command}'"
|
|
193
|
+
elif shell_basename == "bash":
|
|
194
|
+
shell_cmd = f"{user_shell} -l -c '{command}'"
|
|
195
|
+
elif shell_basename == "fish":
|
|
196
|
+
shell_cmd = f"{user_shell} -l -c '{command}'"
|
|
197
|
+
else:
|
|
198
|
+
# Default fallback
|
|
199
|
+
shell_cmd = f"{user_shell} -c '{command}'"
|
|
200
|
+
else:
|
|
201
|
+
self._log(
|
|
202
|
+
f"Using shell for command with shell operators: {command}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Use shell for command execution
|
|
206
|
+
process = await asyncio.create_subprocess_shell(
|
|
207
|
+
shell_cmd,
|
|
208
|
+
stdout=asyncio.subprocess.PIPE,
|
|
209
|
+
stderr=asyncio.subprocess.PIPE,
|
|
210
|
+
cwd=cwd,
|
|
211
|
+
env=command_env,
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
# Split the command into arguments for regular commands
|
|
215
|
+
args: list[str] = shlex.split(command)
|
|
216
|
+
|
|
217
|
+
# Create and run the process without shell
|
|
218
|
+
process = await asyncio.create_subprocess_exec(
|
|
219
|
+
*args,
|
|
220
|
+
stdout=asyncio.subprocess.PIPE,
|
|
221
|
+
stderr=asyncio.subprocess.PIPE,
|
|
222
|
+
cwd=cwd,
|
|
223
|
+
env=command_env,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Wait for the process to complete with timeout
|
|
227
|
+
try:
|
|
228
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
229
|
+
process.communicate(), timeout=timeout
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return CommandResult(
|
|
233
|
+
return_code=process.returncode or 0,
|
|
234
|
+
stdout=stdout_bytes.decode("utf-8", errors="replace"),
|
|
235
|
+
stderr=stderr_bytes.decode("utf-8", errors="replace"),
|
|
236
|
+
)
|
|
237
|
+
except asyncio.TimeoutError:
|
|
238
|
+
# Kill the process if it times out
|
|
239
|
+
try:
|
|
240
|
+
process.kill()
|
|
241
|
+
except ProcessLookupError:
|
|
242
|
+
pass # Process already terminated
|
|
243
|
+
|
|
244
|
+
return CommandResult(
|
|
245
|
+
return_code=-1,
|
|
246
|
+
error_message=f"Command timed out after {timeout} seconds: {command}",
|
|
247
|
+
)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
self._log(f"Command execution error: {str(e)}")
|
|
250
|
+
return CommandResult(
|
|
251
|
+
return_code=1, error_message=f"Error executing command: {str(e)}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
async def execute_script(
|
|
255
|
+
self,
|
|
256
|
+
script: str,
|
|
257
|
+
interpreter: str = "bash",
|
|
258
|
+
cwd: str | None = None,
|
|
259
|
+
env: dict[str, str] | None = None,
|
|
260
|
+
timeout: float | None = 60.0,
|
|
261
|
+
use_login_shell: bool = True,
|
|
262
|
+
) -> CommandResult:
|
|
263
|
+
"""Execute a script with the specified interpreter.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
script: The script content to execute
|
|
267
|
+
interpreter: The interpreter to use (bash, python, etc.)
|
|
268
|
+
cwd: Optional working directory
|
|
269
|
+
env: Optional environment variables
|
|
270
|
+
timeout: Optional timeout in seconds
|
|
271
|
+
use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
CommandResult containing execution results
|
|
275
|
+
"""
|
|
276
|
+
self._log(f"Executing script with interpreter: {interpreter}")
|
|
277
|
+
|
|
278
|
+
# Check working directory permissions if specified
|
|
279
|
+
if cwd:
|
|
280
|
+
if not os.path.isdir(cwd):
|
|
281
|
+
return CommandResult(
|
|
282
|
+
return_code=1,
|
|
283
|
+
error_message=f"Working directory does not exist: {cwd}",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if not self.permission_manager.is_path_allowed(cwd):
|
|
287
|
+
return CommandResult(
|
|
288
|
+
return_code=1, error_message=f"Working directory not allowed: {cwd}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Check if we need special handling for this interpreter
|
|
292
|
+
interpreter_name = interpreter.split()[0].lower()
|
|
293
|
+
if interpreter_name in self.special_interpreters:
|
|
294
|
+
self._log(f"Using special handler for interpreter: {interpreter_name}")
|
|
295
|
+
special_handler = self.special_interpreters[interpreter_name]
|
|
296
|
+
return await special_handler(interpreter, script, cwd, env, timeout)
|
|
297
|
+
|
|
298
|
+
# Regular execution
|
|
299
|
+
return await self._execute_script_with_stdin(
|
|
300
|
+
interpreter, script, cwd, env, timeout, use_login_shell
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def _execute_script_with_stdin(
|
|
304
|
+
self,
|
|
305
|
+
interpreter: str,
|
|
306
|
+
script: str,
|
|
307
|
+
cwd: str | None = None,
|
|
308
|
+
env: dict[str, str] | None = None,
|
|
309
|
+
timeout: float | None = 60.0,
|
|
310
|
+
use_login_shell: bool = True,
|
|
311
|
+
) -> CommandResult:
|
|
312
|
+
"""Execute a script by passing it to stdin of the interpreter.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
interpreter: The interpreter command
|
|
316
|
+
script: The script content
|
|
317
|
+
cwd: Optional working directory
|
|
318
|
+
env: Optional environment variables
|
|
319
|
+
timeout: Optional timeout in seconds
|
|
320
|
+
use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
CommandResult containing execution results
|
|
324
|
+
"""
|
|
325
|
+
# Set up environment
|
|
326
|
+
command_env: dict[str, str] = os.environ.copy()
|
|
327
|
+
if env:
|
|
328
|
+
command_env.update(env)
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
# Determine if we should use a login shell
|
|
332
|
+
if use_login_shell:
|
|
333
|
+
# Get the user's login shell
|
|
334
|
+
user_shell = os.environ.get("SHELL", "/bin/bash")
|
|
335
|
+
os.path.basename(user_shell)
|
|
336
|
+
|
|
337
|
+
self._log(f"Using login shell for interpreter: {user_shell}")
|
|
338
|
+
|
|
339
|
+
# Create command that pipes script to interpreter through login shell
|
|
340
|
+
shell_cmd = f"{user_shell} -l -c '{interpreter}'"
|
|
341
|
+
|
|
342
|
+
# Create and run the process with shell
|
|
343
|
+
process = await asyncio.create_subprocess_shell(
|
|
344
|
+
shell_cmd,
|
|
345
|
+
stdin=asyncio.subprocess.PIPE,
|
|
346
|
+
stdout=asyncio.subprocess.PIPE,
|
|
347
|
+
stderr=asyncio.subprocess.PIPE,
|
|
348
|
+
cwd=cwd,
|
|
349
|
+
env=command_env,
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
# Parse the interpreter command to get arguments
|
|
353
|
+
interpreter_parts = shlex.split(interpreter)
|
|
354
|
+
|
|
355
|
+
# Create and run the process normally
|
|
356
|
+
process = await asyncio.create_subprocess_exec(
|
|
357
|
+
*interpreter_parts,
|
|
358
|
+
stdin=asyncio.subprocess.PIPE,
|
|
359
|
+
stdout=asyncio.subprocess.PIPE,
|
|
360
|
+
stderr=asyncio.subprocess.PIPE,
|
|
361
|
+
cwd=cwd,
|
|
362
|
+
env=command_env,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Wait for the process to complete with timeout
|
|
366
|
+
try:
|
|
367
|
+
script_bytes: bytes = script.encode("utf-8")
|
|
368
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
369
|
+
process.communicate(script_bytes), timeout=timeout
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
return CommandResult(
|
|
373
|
+
return_code=process.returncode or 0,
|
|
374
|
+
stdout=stdout_bytes.decode("utf-8", errors="replace"),
|
|
375
|
+
stderr=stderr_bytes.decode("utf-8", errors="replace"),
|
|
376
|
+
)
|
|
377
|
+
except asyncio.TimeoutError:
|
|
378
|
+
# Kill the process if it times out
|
|
379
|
+
try:
|
|
380
|
+
process.kill()
|
|
381
|
+
except ProcessLookupError:
|
|
382
|
+
pass # Process already terminated
|
|
383
|
+
|
|
384
|
+
return CommandResult(
|
|
385
|
+
return_code=-1,
|
|
386
|
+
error_message=f"Script execution timed out after {timeout} seconds",
|
|
387
|
+
)
|
|
388
|
+
except Exception as e:
|
|
389
|
+
self._log(f"Script execution error: {str(e)}")
|
|
390
|
+
return CommandResult(
|
|
391
|
+
return_code=1, error_message=f"Error executing script: {str(e)}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
async def _handle_fish_script(
|
|
395
|
+
self,
|
|
396
|
+
interpreter: str,
|
|
397
|
+
script: str,
|
|
398
|
+
cwd: str | None = None,
|
|
399
|
+
env: dict[str, str] | None = None,
|
|
400
|
+
timeout: float | None = 60.0,
|
|
401
|
+
) -> CommandResult:
|
|
402
|
+
"""Special handler for Fish shell scripts.
|
|
403
|
+
|
|
404
|
+
The Fish shell has issues with piped input in some contexts, so we use
|
|
405
|
+
a workaround that base64 encodes the script and decodes it in the pipeline.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
interpreter: The fish interpreter command
|
|
409
|
+
script: The fish script content
|
|
410
|
+
cwd: Optional working directory
|
|
411
|
+
env: Optional environment variables
|
|
412
|
+
timeout: Optional timeout in seconds
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
CommandResult containing execution results
|
|
416
|
+
"""
|
|
417
|
+
self._log("Using Fish shell workaround")
|
|
418
|
+
|
|
419
|
+
# Set up environment
|
|
420
|
+
command_env: dict[str, str] = os.environ.copy()
|
|
421
|
+
if env:
|
|
422
|
+
command_env.update(env)
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
# Base64 encode the script to avoid stdin issues with Fish
|
|
426
|
+
base64_script = base64.b64encode(script.encode("utf-8")).decode("utf-8")
|
|
427
|
+
|
|
428
|
+
# Create a command that decodes the script and pipes it to fish
|
|
429
|
+
command = f'{interpreter} -c "echo {base64_script} | base64 -d | fish"'
|
|
430
|
+
self._log(f"Fish command: {command}")
|
|
431
|
+
|
|
432
|
+
# Create and run the process
|
|
433
|
+
process = await asyncio.create_subprocess_shell(
|
|
434
|
+
command,
|
|
435
|
+
stdout=asyncio.subprocess.PIPE,
|
|
436
|
+
stderr=asyncio.subprocess.PIPE,
|
|
437
|
+
cwd=cwd,
|
|
438
|
+
env=command_env,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# Wait for the process to complete with timeout
|
|
442
|
+
try:
|
|
443
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
444
|
+
process.communicate(), timeout=timeout
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
return CommandResult(
|
|
448
|
+
return_code=process.returncode or 0,
|
|
449
|
+
stdout=stdout_bytes.decode("utf-8", errors="replace"),
|
|
450
|
+
stderr=stderr_bytes.decode("utf-8", errors="replace"),
|
|
451
|
+
)
|
|
452
|
+
except asyncio.TimeoutError:
|
|
453
|
+
# Kill the process if it times out
|
|
454
|
+
try:
|
|
455
|
+
process.kill()
|
|
456
|
+
except ProcessLookupError:
|
|
457
|
+
pass # Process already terminated
|
|
458
|
+
|
|
459
|
+
return CommandResult(
|
|
460
|
+
return_code=-1,
|
|
461
|
+
error_message=f"Fish script execution timed out after {timeout} seconds",
|
|
462
|
+
)
|
|
463
|
+
except Exception as e:
|
|
464
|
+
self._log(f"Fish script execution error: {str(e)}")
|
|
465
|
+
return CommandResult(
|
|
466
|
+
return_code=1, error_message=f"Error executing Fish script: {str(e)}"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
async def execute_script_from_file(
|
|
470
|
+
self,
|
|
471
|
+
script: str,
|
|
472
|
+
language: str,
|
|
473
|
+
cwd: str | None = None,
|
|
474
|
+
env: dict[str, str] | None = None,
|
|
475
|
+
timeout: float | None = 60.0,
|
|
476
|
+
args: list[str] | None = None,
|
|
477
|
+
use_login_shell: bool = True,
|
|
478
|
+
) -> CommandResult:
|
|
479
|
+
"""Execute a script by writing it to a temporary file and executing it.
|
|
480
|
+
|
|
481
|
+
This is useful for languages where the script is too complex or long
|
|
482
|
+
to pass via stdin, or for languages that have limitations with stdin.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
script: The script content
|
|
486
|
+
language: The script language (determines file extension and interpreter)
|
|
487
|
+
cwd: Optional working directory
|
|
488
|
+
env: Optional environment variables
|
|
489
|
+
timeout: Optional timeout in seconds
|
|
490
|
+
args: Optional command-line arguments
|
|
491
|
+
use_login_shell: Whether to use login shell. default true (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
CommandResult containing execution results
|
|
495
|
+
"""
|
|
496
|
+
# Language to interpreter mapping
|
|
497
|
+
language_map: dict[str, dict[str, str]] = {
|
|
498
|
+
"python": {
|
|
499
|
+
"command": "python",
|
|
500
|
+
"extension": ".py",
|
|
501
|
+
},
|
|
502
|
+
"javascript": {
|
|
503
|
+
"command": "node",
|
|
504
|
+
"extension": ".js",
|
|
505
|
+
},
|
|
506
|
+
"typescript": {
|
|
507
|
+
"command": "ts-node",
|
|
508
|
+
"extension": ".ts",
|
|
509
|
+
},
|
|
510
|
+
"bash": {
|
|
511
|
+
"command": "bash",
|
|
512
|
+
"extension": ".sh",
|
|
513
|
+
},
|
|
514
|
+
"fish": {
|
|
515
|
+
"command": "fish",
|
|
516
|
+
"extension": ".fish",
|
|
517
|
+
},
|
|
518
|
+
"ruby": {
|
|
519
|
+
"command": "ruby",
|
|
520
|
+
"extension": ".rb",
|
|
521
|
+
},
|
|
522
|
+
"php": {
|
|
523
|
+
"command": "php",
|
|
524
|
+
"extension": ".php",
|
|
525
|
+
},
|
|
526
|
+
"perl": {
|
|
527
|
+
"command": "perl",
|
|
528
|
+
"extension": ".pl",
|
|
529
|
+
},
|
|
530
|
+
"r": {
|
|
531
|
+
"command": "Rscript",
|
|
532
|
+
"extension": ".R",
|
|
533
|
+
},
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
# Check if the language is supported
|
|
537
|
+
if language not in language_map:
|
|
538
|
+
return CommandResult(
|
|
539
|
+
return_code=1,
|
|
540
|
+
error_message=f"Unsupported language: {language}. Supported languages: {', '.join(language_map.keys())}",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Get language info
|
|
544
|
+
language_info = language_map[language]
|
|
545
|
+
command = language_info["command"]
|
|
546
|
+
extension = language_info["extension"]
|
|
547
|
+
|
|
548
|
+
# Set up environment
|
|
549
|
+
command_env: dict[str, str] = os.environ.copy()
|
|
550
|
+
if env:
|
|
551
|
+
command_env.update(env)
|
|
552
|
+
|
|
553
|
+
# Create a temporary file for the script
|
|
554
|
+
with tempfile.NamedTemporaryFile(
|
|
555
|
+
suffix=extension, mode="w", delete=False
|
|
556
|
+
) as temp:
|
|
557
|
+
temp_path = temp.name
|
|
558
|
+
_ = temp.write(script) # Explicitly ignore the return value
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
# Determine if we should use a login shell
|
|
562
|
+
if use_login_shell:
|
|
563
|
+
# Get the user's login shell
|
|
564
|
+
user_shell = os.environ.get("SHELL", "/bin/bash")
|
|
565
|
+
os.path.basename(user_shell)
|
|
566
|
+
|
|
567
|
+
self._log(f"Using login shell for script execution: {user_shell}")
|
|
568
|
+
|
|
569
|
+
# Build the command including args
|
|
570
|
+
cmd = f"{command} {temp_path}"
|
|
571
|
+
if args:
|
|
572
|
+
cmd += " " + " ".join(args)
|
|
573
|
+
|
|
574
|
+
# Create command that runs script through login shell
|
|
575
|
+
shell_cmd = f"{user_shell} -l -c '{cmd}'"
|
|
576
|
+
|
|
577
|
+
self._log(f"Executing script from file with login shell: {shell_cmd}")
|
|
578
|
+
|
|
579
|
+
# Create and run the process with shell
|
|
580
|
+
process = await asyncio.create_subprocess_shell(
|
|
581
|
+
shell_cmd,
|
|
582
|
+
stdout=asyncio.subprocess.PIPE,
|
|
583
|
+
stderr=asyncio.subprocess.PIPE,
|
|
584
|
+
cwd=cwd,
|
|
585
|
+
env=command_env,
|
|
586
|
+
)
|
|
587
|
+
else:
|
|
588
|
+
# Build command arguments
|
|
589
|
+
cmd_args = [command, temp_path]
|
|
590
|
+
if args:
|
|
591
|
+
cmd_args.extend(args)
|
|
592
|
+
|
|
593
|
+
self._log(f"Executing script from file with: {' '.join(cmd_args)}")
|
|
594
|
+
|
|
595
|
+
# Create and run the process normally
|
|
596
|
+
process = await asyncio.create_subprocess_exec(
|
|
597
|
+
*cmd_args,
|
|
598
|
+
stdout=asyncio.subprocess.PIPE,
|
|
599
|
+
stderr=asyncio.subprocess.PIPE,
|
|
600
|
+
cwd=cwd,
|
|
601
|
+
env=command_env,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# Wait for the process to complete with timeout
|
|
605
|
+
try:
|
|
606
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
607
|
+
process.communicate(), timeout=timeout
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
return CommandResult(
|
|
611
|
+
return_code=process.returncode or 0,
|
|
612
|
+
stdout=stdout_bytes.decode("utf-8", errors="replace"),
|
|
613
|
+
stderr=stderr_bytes.decode("utf-8", errors="replace"),
|
|
614
|
+
)
|
|
615
|
+
except asyncio.TimeoutError:
|
|
616
|
+
# Kill the process if it times out
|
|
617
|
+
try:
|
|
618
|
+
process.kill()
|
|
619
|
+
except ProcessLookupError:
|
|
620
|
+
pass # Process already terminated
|
|
621
|
+
|
|
622
|
+
return CommandResult(
|
|
623
|
+
return_code=-1,
|
|
624
|
+
error_message=f"Script execution timed out after {timeout} seconds",
|
|
625
|
+
)
|
|
626
|
+
except Exception as e:
|
|
627
|
+
self._log(f"Script file execution error: {str(e)}")
|
|
628
|
+
return CommandResult(
|
|
629
|
+
return_code=1, error_message=f"Error executing script: {str(e)}"
|
|
630
|
+
)
|
|
631
|
+
finally:
|
|
632
|
+
# Clean up temporary file
|
|
633
|
+
try:
|
|
634
|
+
os.unlink(temp_path)
|
|
635
|
+
except Exception as e:
|
|
636
|
+
self._log(f"Error cleaning up temporary file: {str(e)}")
|
|
637
|
+
|
|
638
|
+
def get_available_languages(self) -> list[str]:
|
|
639
|
+
"""Get a list of available script languages.
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
List of supported language names
|
|
643
|
+
"""
|
|
644
|
+
# Use the same language map as in execute_script_from_file method
|
|
645
|
+
language_map = {
|
|
646
|
+
"python": {"command": "python", "extension": ".py"},
|
|
647
|
+
"javascript": {"command": "node", "extension": ".js"},
|
|
648
|
+
"typescript": {"command": "ts-node", "extension": ".ts"},
|
|
649
|
+
"bash": {"command": "bash", "extension": ".sh"},
|
|
650
|
+
"fish": {"command": "fish", "extension": ".fish"},
|
|
651
|
+
"ruby": {"command": "ruby", "extension": ".rb"},
|
|
652
|
+
"php": {"command": "php", "extension": ".php"},
|
|
653
|
+
"perl": {"command": "perl", "extension": ".pl"},
|
|
654
|
+
"r": {"command": "Rscript", "extension": ".R"},
|
|
655
|
+
}
|
|
656
|
+
return list(language_map.keys())
|
|
657
|
+
|
|
658
|
+
# Legacy method to keep backwards compatibility with tests
|
|
659
|
+
def register_tools(self, mcp_server: FastMCP) -> None:
|
|
660
|
+
"""Register command execution tools with the MCP server.
|
|
661
|
+
|
|
662
|
+
Legacy method for backwards compatibility with existing tests.
|
|
663
|
+
New code should use the modular tool classes instead.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
mcp_server: The FastMCP server instance
|
|
667
|
+
"""
|
|
668
|
+
# Run Command Tool - keep original method names for test compatibility
|
|
669
|
+
@mcp_server.tool()
|
|
670
|
+
async def run_command(
|
|
671
|
+
command: str,
|
|
672
|
+
cwd: str,
|
|
673
|
+
ctx: MCPContext,
|
|
674
|
+
use_login_shell: bool = True,
|
|
675
|
+
) -> str:
|
|
676
|
+
tool_ctx = create_tool_context(ctx)
|
|
677
|
+
tool_ctx.set_tool_info("run_command")
|
|
678
|
+
await tool_ctx.info(f"Executing command: {command}")
|
|
679
|
+
|
|
680
|
+
# Run validations and execute
|
|
681
|
+
result = await self.execute_command(command, cwd, timeout=30.0, use_login_shell=use_login_shell)
|
|
682
|
+
|
|
683
|
+
if result.is_success:
|
|
684
|
+
return result.stdout
|
|
685
|
+
else:
|
|
686
|
+
return result.format_output()
|
|
687
|
+
|
|
688
|
+
# Run Script Tool
|
|
689
|
+
@mcp_server.tool()
|
|
690
|
+
async def run_script(
|
|
691
|
+
script: str,
|
|
692
|
+
cwd: str,
|
|
693
|
+
ctx: MCPContext,
|
|
694
|
+
interpreter: str = "bash",
|
|
695
|
+
use_login_shell: bool = True,
|
|
696
|
+
) -> str:
|
|
697
|
+
tool_ctx = create_tool_context(ctx)
|
|
698
|
+
tool_ctx.set_tool_info("run_script")
|
|
699
|
+
|
|
700
|
+
# Execute the script
|
|
701
|
+
result = await self.execute_script(
|
|
702
|
+
script=script,
|
|
703
|
+
interpreter=interpreter,
|
|
704
|
+
cwd=cwd,
|
|
705
|
+
timeout=30.0,
|
|
706
|
+
use_login_shell=use_login_shell,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
if result.is_success:
|
|
710
|
+
return result.stdout
|
|
711
|
+
else:
|
|
712
|
+
return result.format_output()
|
|
713
|
+
|
|
714
|
+
# Script tool for executing scripts in various languages
|
|
715
|
+
@mcp_server.tool()
|
|
716
|
+
async def script_tool(
|
|
717
|
+
language: str,
|
|
718
|
+
script: str,
|
|
719
|
+
cwd: str,
|
|
720
|
+
ctx: MCPContext,
|
|
721
|
+
args: list[str] | None = None,
|
|
722
|
+
use_login_shell: bool = True,
|
|
723
|
+
) -> str:
|
|
724
|
+
tool_ctx = create_tool_context(ctx)
|
|
725
|
+
tool_ctx.set_tool_info("script_tool")
|
|
726
|
+
|
|
727
|
+
# Execute the script
|
|
728
|
+
result = await self.execute_script_from_file(
|
|
729
|
+
script=script,
|
|
730
|
+
language=language,
|
|
731
|
+
cwd=cwd,
|
|
732
|
+
timeout=30.0,
|
|
733
|
+
args=args,
|
|
734
|
+
use_login_shell=use_login_shell
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
if result.is_success:
|
|
738
|
+
return result.stdout
|
|
739
|
+
else:
|
|
740
|
+
return result.format_output()
|