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.

Files changed (44) hide show
  1. hanzo_mcp/__init__.py +3 -0
  2. hanzo_mcp/cli.py +213 -0
  3. hanzo_mcp/server.py +149 -0
  4. hanzo_mcp/tools/__init__.py +81 -0
  5. hanzo_mcp/tools/agent/__init__.py +59 -0
  6. hanzo_mcp/tools/agent/agent_tool.py +474 -0
  7. hanzo_mcp/tools/agent/prompt.py +137 -0
  8. hanzo_mcp/tools/agent/tool_adapter.py +75 -0
  9. hanzo_mcp/tools/common/__init__.py +18 -0
  10. hanzo_mcp/tools/common/base.py +216 -0
  11. hanzo_mcp/tools/common/context.py +444 -0
  12. hanzo_mcp/tools/common/permissions.py +253 -0
  13. hanzo_mcp/tools/common/thinking_tool.py +123 -0
  14. hanzo_mcp/tools/common/validation.py +124 -0
  15. hanzo_mcp/tools/filesystem/__init__.py +89 -0
  16. hanzo_mcp/tools/filesystem/base.py +113 -0
  17. hanzo_mcp/tools/filesystem/content_replace.py +287 -0
  18. hanzo_mcp/tools/filesystem/directory_tree.py +286 -0
  19. hanzo_mcp/tools/filesystem/edit_file.py +287 -0
  20. hanzo_mcp/tools/filesystem/get_file_info.py +170 -0
  21. hanzo_mcp/tools/filesystem/read_files.py +198 -0
  22. hanzo_mcp/tools/filesystem/search_content.py +275 -0
  23. hanzo_mcp/tools/filesystem/write_file.py +162 -0
  24. hanzo_mcp/tools/jupyter/__init__.py +71 -0
  25. hanzo_mcp/tools/jupyter/base.py +284 -0
  26. hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
  27. hanzo_mcp/tools/jupyter/notebook_operations.py +514 -0
  28. hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
  29. hanzo_mcp/tools/project/__init__.py +64 -0
  30. hanzo_mcp/tools/project/analysis.py +882 -0
  31. hanzo_mcp/tools/project/base.py +66 -0
  32. hanzo_mcp/tools/project/project_analyze.py +173 -0
  33. hanzo_mcp/tools/shell/__init__.py +58 -0
  34. hanzo_mcp/tools/shell/base.py +148 -0
  35. hanzo_mcp/tools/shell/command_executor.py +740 -0
  36. hanzo_mcp/tools/shell/run_command.py +204 -0
  37. hanzo_mcp/tools/shell/run_script.py +215 -0
  38. hanzo_mcp/tools/shell/script_tool.py +244 -0
  39. hanzo_mcp-0.1.20.dist-info/METADATA +111 -0
  40. hanzo_mcp-0.1.20.dist-info/RECORD +44 -0
  41. hanzo_mcp-0.1.20.dist-info/WHEEL +5 -0
  42. hanzo_mcp-0.1.20.dist-info/entry_points.txt +2 -0
  43. hanzo_mcp-0.1.20.dist-info/licenses/LICENSE +21 -0
  44. 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()