hanzo-mcp 0.1.21__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.
@@ -0,0 +1,1001 @@
1
+ """Command executor tools for Hanzo Dev 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 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
+
22
+
23
+ @final
24
+ class CommandResult:
25
+ """Represents the result of a command execution."""
26
+
27
+ def __init__(
28
+ self,
29
+ return_code: int = 0,
30
+ stdout: str = "",
31
+ stderr: str = "",
32
+ error_message: str | None = None,
33
+ ):
34
+ """Initialize a command result.
35
+
36
+ Args:
37
+ return_code: The command's return code (0 for success)
38
+ stdout: Standard output from the command
39
+ stderr: Standard error from the command
40
+ error_message: Optional error message for failure cases
41
+ """
42
+ self.return_code: int = return_code
43
+ self.stdout: str = stdout
44
+ self.stderr: str = stderr
45
+ self.error_message: str | None = error_message
46
+
47
+ @property
48
+ def is_success(self) -> bool:
49
+ """Check if the command executed successfully.
50
+
51
+ Returns:
52
+ True if the command succeeded, False otherwise
53
+ """
54
+ return self.return_code == 0
55
+
56
+ def format_output(self, include_exit_code: bool = True) -> str:
57
+ """Format the command output as a string.
58
+
59
+ Args:
60
+ include_exit_code: Whether to include the exit code in the output
61
+
62
+ Returns:
63
+ Formatted output string
64
+ """
65
+ result_parts: list[str] = []
66
+
67
+ # Add error message if present
68
+ if self.error_message:
69
+ result_parts.append(f"Error: {self.error_message}")
70
+
71
+ # Add exit code if requested and not zero (for non-errors)
72
+ if include_exit_code and (self.return_code != 0 or not self.error_message):
73
+ result_parts.append(f"Exit code: {self.return_code}")
74
+
75
+ # Add stdout if present
76
+ if self.stdout:
77
+ result_parts.append(f"STDOUT:\n{self.stdout}")
78
+
79
+ # Add stderr if present
80
+ if self.stderr:
81
+ result_parts.append(f"STDERR:\n{self.stderr}")
82
+
83
+ # Join with newlines
84
+ return "\n\n".join(result_parts)
85
+
86
+
87
+ @final
88
+ class CommandExecutor:
89
+ """Command executor tools for Hanzo Dev MCP.
90
+
91
+ This class provides tools for executing shell commands and scripts with
92
+ comprehensive error handling, permissions checking, and progress tracking.
93
+ """
94
+
95
+ def __init__(
96
+ self, permission_manager: PermissionManager, verbose: bool = False
97
+ ) -> None:
98
+ """Initialize command execution.
99
+
100
+ Args:
101
+ permission_manager: Permission manager for access control
102
+ verbose: Enable verbose logging
103
+ """
104
+ self.permission_manager: PermissionManager = permission_manager
105
+ self.verbose: bool = verbose
106
+
107
+ # Excluded commands or patterns
108
+ self.excluded_commands: list[str] = ["rm"]
109
+
110
+ # Map of supported interpreters with special handling
111
+ self.special_interpreters: dict[
112
+ str,
113
+ Callable[
114
+ [str, str, str | None, dict[str, str] | None, float | None],
115
+ Awaitable[CommandResult],
116
+ ],
117
+ ] = {
118
+ "fish": self._handle_fish_script,
119
+ }
120
+
121
+ def allow_command(self, command: str) -> None:
122
+ """Allow a specific command that might otherwise be excluded.
123
+
124
+ Args:
125
+ command: The command to allow
126
+ """
127
+ if command in self.excluded_commands:
128
+ self.excluded_commands.remove(command)
129
+
130
+ def deny_command(self, command: str) -> None:
131
+ """Deny a specific command, adding it to the excluded list.
132
+
133
+ Args:
134
+ command: The command to deny
135
+ """
136
+ if command not in self.excluded_commands:
137
+ self.excluded_commands.append(command)
138
+
139
+ def _log(self, message: str, data: object = None) -> None:
140
+ """Log a message if verbose logging is enabled.
141
+
142
+ Args:
143
+ message: The message to log
144
+ data: Optional data to include with the message
145
+ """
146
+ if not self.verbose:
147
+ return
148
+
149
+ if data is not None:
150
+ try:
151
+ import json
152
+
153
+ if isinstance(data, (dict, list)):
154
+ data_str = json.dumps(data)
155
+ else:
156
+ data_str = str(data)
157
+ print(f"DEBUG: {message}: {data_str}", file=sys.stderr)
158
+ except Exception:
159
+ print(f"DEBUG: {message}: {data}", file=sys.stderr)
160
+ else:
161
+ print(f"DEBUG: {message}", file=sys.stderr)
162
+
163
+ def is_command_allowed(self, command: str) -> bool:
164
+ """Check if a command is allowed based on exclusion lists.
165
+
166
+ Args:
167
+ command: The command to check
168
+
169
+ Returns:
170
+ True if the command is allowed, False otherwise
171
+ """
172
+ # Check for empty commands
173
+ try:
174
+ args: list[str] = shlex.split(command)
175
+ except ValueError as e:
176
+ self._log(f"Command parsing error: {e}")
177
+ return False
178
+
179
+ if not args:
180
+ return False
181
+
182
+ base_command: str = args[0]
183
+
184
+ # Check if base command is in exclusion list
185
+ if base_command in self.excluded_commands:
186
+ self._log(f"Command rejected (in exclusion list): {base_command}")
187
+ return False
188
+
189
+ return True
190
+
191
+ async def execute_command(
192
+ self,
193
+ command: str,
194
+ cwd: str | None = None,
195
+ env: dict[str, str] | None = None,
196
+ timeout: float | None = 60.0,
197
+ use_login_shell: bool = True,
198
+ ) -> CommandResult:
199
+ """Execute a shell command with safety checks.
200
+
201
+ Args:
202
+ command: The command to execute
203
+ cwd: Optional working directory
204
+ env: Optional environment variables
205
+ timeout: Optional timeout in seconds
206
+ use_login_shell: Whether to use login shell. default true (loads ~/.zshrc, ~/.bashrc, etc.)
207
+
208
+ Returns:
209
+ CommandResult containing execution results
210
+ """
211
+ self._log(f"Executing command: {command}")
212
+
213
+ # Check if the command is allowed
214
+ if not self.is_command_allowed(command):
215
+ return CommandResult(
216
+ return_code=1, error_message=f"Command not allowed: {command}"
217
+ )
218
+
219
+ # Check working directory permissions if specified
220
+ if cwd:
221
+ if not os.path.isdir(cwd):
222
+ return CommandResult(
223
+ return_code=1,
224
+ error_message=f"Working directory does not exist: {cwd}",
225
+ )
226
+
227
+ if not self.permission_manager.is_path_allowed(cwd):
228
+ return CommandResult(
229
+ return_code=1, error_message=f"Working directory not allowed: {cwd}"
230
+ )
231
+
232
+ # Set up environment
233
+ command_env: dict[str, str] = os.environ.copy()
234
+ if env:
235
+ command_env.update(env)
236
+
237
+ try:
238
+ # Check if command uses shell features like &&, ||, |, etc. or $ for env vars
239
+ shell_operators = ["&&", "||", "|", ";", ">", "<", "$(", "`", "$"]
240
+ needs_shell = any(op in command for op in shell_operators)
241
+
242
+ if needs_shell or use_login_shell:
243
+ # Determine which shell to use
244
+ shell_cmd = command
245
+
246
+ if use_login_shell:
247
+ # Get the user's login shell
248
+ user_shell = os.environ.get("SHELL", "/bin/bash")
249
+ shell_basename = os.path.basename(user_shell)
250
+
251
+ self._log(f"Using login shell: {user_shell}")
252
+
253
+ # Wrap command with appropriate shell invocation
254
+ if shell_basename == "zsh":
255
+ shell_cmd = f"{user_shell} -l -c '{command}'"
256
+ elif shell_basename == "bash":
257
+ shell_cmd = f"{user_shell} -l -c '{command}'"
258
+ elif shell_basename == "fish":
259
+ shell_cmd = f"{user_shell} -l -c '{command}'"
260
+ else:
261
+ # Default fallback
262
+ shell_cmd = f"{user_shell} -c '{command}'"
263
+ else:
264
+ self._log(
265
+ f"Using shell for command with shell operators: {command}"
266
+ )
267
+
268
+ # Use shell for command execution
269
+ process = await asyncio.create_subprocess_shell(
270
+ shell_cmd,
271
+ stdout=asyncio.subprocess.PIPE,
272
+ stderr=asyncio.subprocess.PIPE,
273
+ cwd=cwd,
274
+ env=command_env,
275
+ )
276
+ else:
277
+ # Split the command into arguments for regular commands
278
+ args: list[str] = shlex.split(command)
279
+
280
+ # Create and run the process without shell
281
+ process = await asyncio.create_subprocess_exec(
282
+ *args,
283
+ stdout=asyncio.subprocess.PIPE,
284
+ stderr=asyncio.subprocess.PIPE,
285
+ cwd=cwd,
286
+ env=command_env,
287
+ )
288
+
289
+ # Wait for the process to complete with timeout
290
+ try:
291
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
292
+ process.communicate(), timeout=timeout
293
+ )
294
+
295
+ return CommandResult(
296
+ return_code=process.returncode or 0,
297
+ stdout=stdout_bytes.decode("utf-8", errors="replace"),
298
+ stderr=stderr_bytes.decode("utf-8", errors="replace"),
299
+ )
300
+ except asyncio.TimeoutError:
301
+ # Kill the process if it times out
302
+ try:
303
+ process.kill()
304
+ except ProcessLookupError:
305
+ pass # Process already terminated
306
+
307
+ return CommandResult(
308
+ return_code=-1,
309
+ error_message=f"Command timed out after {timeout} seconds: {command}",
310
+ )
311
+ except Exception as e:
312
+ self._log(f"Command execution error: {str(e)}")
313
+ return CommandResult(
314
+ return_code=1, error_message=f"Error executing command: {str(e)}"
315
+ )
316
+
317
+ async def execute_script(
318
+ self,
319
+ script: str,
320
+ interpreter: str = "bash",
321
+ cwd: str | None = None,
322
+ env: dict[str, str] | None = None,
323
+ timeout: float | None = 60.0,
324
+ use_login_shell: bool = True,
325
+ ) -> CommandResult:
326
+ """Execute a script with the specified interpreter.
327
+
328
+ Args:
329
+ script: The script content to execute
330
+ interpreter: The interpreter to use (bash, python, etc.)
331
+ cwd: Optional working directory
332
+ env: Optional environment variables
333
+ timeout: Optional timeout in seconds
334
+
335
+ Returns:
336
+ CommandResult containing execution results
337
+ """
338
+ self._log(f"Executing script with interpreter: {interpreter}")
339
+
340
+ # Check working directory permissions if specified
341
+ if cwd:
342
+ if not os.path.isdir(cwd):
343
+ return CommandResult(
344
+ return_code=1,
345
+ error_message=f"Working directory does not exist: {cwd}",
346
+ )
347
+
348
+ if not self.permission_manager.is_path_allowed(cwd):
349
+ return CommandResult(
350
+ return_code=1, error_message=f"Working directory not allowed: {cwd}"
351
+ )
352
+
353
+ # Check if we need special handling for this interpreter
354
+ interpreter_name = interpreter.split()[0].lower()
355
+ if interpreter_name in self.special_interpreters:
356
+ self._log(f"Using special handler for interpreter: {interpreter_name}")
357
+ special_handler = self.special_interpreters[interpreter_name]
358
+ return await special_handler(interpreter, script, cwd, env, timeout)
359
+
360
+ # Regular execution
361
+ return await self._execute_script_with_stdin(
362
+ interpreter, script, cwd, env, timeout, use_login_shell
363
+ )
364
+
365
+ async def _execute_script_with_stdin(
366
+ self,
367
+ interpreter: str,
368
+ script: str,
369
+ cwd: str | None = None,
370
+ env: dict[str, str] | None = None,
371
+ timeout: float | None = 60.0,
372
+ use_login_shell: bool = True,
373
+ ) -> CommandResult:
374
+ """Execute a script by passing it to stdin of the interpreter.
375
+
376
+ Args:
377
+ interpreter: The interpreter command
378
+ script: The script content
379
+ cwd: Optional working directory
380
+ env: Optional environment variables
381
+ timeout: Optional timeout in seconds
382
+
383
+ Returns:
384
+ CommandResult containing execution results
385
+ """
386
+ # Set up environment
387
+ command_env: dict[str, str] = os.environ.copy()
388
+ if env:
389
+ command_env.update(env)
390
+
391
+ try:
392
+ # Determine if we should use a login shell
393
+ if use_login_shell:
394
+ # Get the user's login shell
395
+ user_shell = os.environ.get("SHELL", "/bin/bash")
396
+ os.path.basename(user_shell)
397
+
398
+ self._log(f"Using login shell for interpreter: {user_shell}")
399
+
400
+ # Create command that pipes script to interpreter through login shell
401
+ shell_cmd = f"{user_shell} -l -c '{interpreter}'"
402
+
403
+ # Create and run the process with shell
404
+ process = await asyncio.create_subprocess_shell(
405
+ shell_cmd,
406
+ stdin=asyncio.subprocess.PIPE,
407
+ stdout=asyncio.subprocess.PIPE,
408
+ stderr=asyncio.subprocess.PIPE,
409
+ cwd=cwd,
410
+ env=command_env,
411
+ )
412
+ else:
413
+ # Parse the interpreter command to get arguments
414
+ interpreter_parts = shlex.split(interpreter)
415
+
416
+ # Create and run the process normally
417
+ process = await asyncio.create_subprocess_exec(
418
+ *interpreter_parts,
419
+ stdin=asyncio.subprocess.PIPE,
420
+ stdout=asyncio.subprocess.PIPE,
421
+ stderr=asyncio.subprocess.PIPE,
422
+ cwd=cwd,
423
+ env=command_env,
424
+ )
425
+
426
+ # Wait for the process to complete with timeout
427
+ try:
428
+ script_bytes: bytes = script.encode("utf-8")
429
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
430
+ process.communicate(script_bytes), timeout=timeout
431
+ )
432
+
433
+ return CommandResult(
434
+ return_code=process.returncode or 0,
435
+ stdout=stdout_bytes.decode("utf-8", errors="replace"),
436
+ stderr=stderr_bytes.decode("utf-8", errors="replace"),
437
+ )
438
+ except asyncio.TimeoutError:
439
+ # Kill the process if it times out
440
+ try:
441
+ process.kill()
442
+ except ProcessLookupError:
443
+ pass # Process already terminated
444
+
445
+ return CommandResult(
446
+ return_code=-1,
447
+ error_message=f"Script execution timed out after {timeout} seconds",
448
+ )
449
+ except Exception as e:
450
+ self._log(f"Script execution error: {str(e)}")
451
+ return CommandResult(
452
+ return_code=1, error_message=f"Error executing script: {str(e)}"
453
+ )
454
+
455
+ async def _handle_fish_script(
456
+ self,
457
+ interpreter: str,
458
+ script: str,
459
+ cwd: str | None = None,
460
+ env: dict[str, str] | None = None,
461
+ timeout: float | None = 60.0,
462
+ ) -> CommandResult:
463
+ """Special handler for Fish shell scripts.
464
+
465
+ The Fish shell has issues with piped input in some contexts, so we use
466
+ a workaround that base64 encodes the script and decodes it in the pipeline.
467
+
468
+ Args:
469
+ interpreter: The fish interpreter command
470
+ script: The fish script content
471
+ cwd: Optional working directory
472
+ env: Optional environment variables
473
+ timeout: Optional timeout in seconds
474
+
475
+ Returns:
476
+ CommandResult containing execution results
477
+ """
478
+ self._log("Using Fish shell workaround")
479
+
480
+ # Set up environment
481
+ command_env: dict[str, str] = os.environ.copy()
482
+ if env:
483
+ command_env.update(env)
484
+
485
+ try:
486
+ # Base64 encode the script to avoid stdin issues with Fish
487
+ base64_script = base64.b64encode(script.encode("utf-8")).decode("utf-8")
488
+
489
+ # Create a command that decodes the script and pipes it to fish
490
+ command = f'{interpreter} -c "echo {base64_script} | base64 -d | fish"'
491
+ self._log(f"Fish command: {command}")
492
+
493
+ # Create and run the process
494
+ process = await asyncio.create_subprocess_shell(
495
+ command,
496
+ stdout=asyncio.subprocess.PIPE,
497
+ stderr=asyncio.subprocess.PIPE,
498
+ cwd=cwd,
499
+ env=command_env,
500
+ )
501
+
502
+ # Wait for the process to complete with timeout
503
+ try:
504
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
505
+ process.communicate(), timeout=timeout
506
+ )
507
+
508
+ return CommandResult(
509
+ return_code=process.returncode or 0,
510
+ stdout=stdout_bytes.decode("utf-8", errors="replace"),
511
+ stderr=stderr_bytes.decode("utf-8", errors="replace"),
512
+ )
513
+ except asyncio.TimeoutError:
514
+ # Kill the process if it times out
515
+ try:
516
+ process.kill()
517
+ except ProcessLookupError:
518
+ pass # Process already terminated
519
+
520
+ return CommandResult(
521
+ return_code=-1,
522
+ error_message=f"Fish script execution timed out after {timeout} seconds",
523
+ )
524
+ except Exception as e:
525
+ self._log(f"Fish script execution error: {str(e)}")
526
+ return CommandResult(
527
+ return_code=1, error_message=f"Error executing Fish script: {str(e)}"
528
+ )
529
+
530
+ async def execute_script_from_file(
531
+ self,
532
+ script: str,
533
+ language: str,
534
+ cwd: str | None = None,
535
+ env: dict[str, str] | None = None,
536
+ timeout: float | None = 60.0,
537
+ args: list[str] | None = None,
538
+ use_login_shell: bool = True,
539
+ ) -> CommandResult:
540
+ """Execute a script by writing it to a temporary file and executing it.
541
+
542
+ This is useful for languages where the script is too complex or long
543
+ to pass via stdin, or for languages that have limitations with stdin.
544
+
545
+ Args:
546
+ script: The script content
547
+ language: The script language (determines file extension and interpreter)
548
+ cwd: Optional working directory
549
+ env: Optional environment variables
550
+ timeout: Optional timeout in seconds
551
+ args: Optional command-line arguments
552
+ use_login_shell: Whether to use login shell. default true (loads ~/.zshrc, ~/.bashrc, etc.)
553
+
554
+
555
+ Returns:
556
+ CommandResult containing execution results
557
+ """
558
+ # Language to interpreter mapping
559
+ language_map: dict[str, dict[str, str]] = {
560
+ "python": {
561
+ "command": "python",
562
+ "extension": ".py",
563
+ },
564
+ "javascript": {
565
+ "command": "node",
566
+ "extension": ".js",
567
+ },
568
+ "typescript": {
569
+ "command": "ts-node",
570
+ "extension": ".ts",
571
+ },
572
+ "bash": {
573
+ "command": "bash",
574
+ "extension": ".sh",
575
+ },
576
+ "fish": {
577
+ "command": "fish",
578
+ "extension": ".fish",
579
+ },
580
+ "ruby": {
581
+ "command": "ruby",
582
+ "extension": ".rb",
583
+ },
584
+ "php": {
585
+ "command": "php",
586
+ "extension": ".php",
587
+ },
588
+ "perl": {
589
+ "command": "perl",
590
+ "extension": ".pl",
591
+ },
592
+ "r": {
593
+ "command": "Rscript",
594
+ "extension": ".R",
595
+ },
596
+ }
597
+
598
+ # Check if the language is supported
599
+ if language not in language_map:
600
+ return CommandResult(
601
+ return_code=1,
602
+ error_message=f"Unsupported language: {language}. Supported languages: {', '.join(language_map.keys())}",
603
+ )
604
+
605
+ # Get language info
606
+ language_info = language_map[language]
607
+ command = language_info["command"]
608
+ extension = language_info["extension"]
609
+
610
+ # Set up environment
611
+ command_env: dict[str, str] = os.environ.copy()
612
+ if env:
613
+ command_env.update(env)
614
+
615
+ # Create a temporary file for the script
616
+ with tempfile.NamedTemporaryFile(
617
+ suffix=extension, mode="w", delete=False
618
+ ) as temp:
619
+ temp_path = temp.name
620
+ _ = temp.write(script) # Explicitly ignore the return value
621
+
622
+ try:
623
+ # Determine if we should use a login shell
624
+ if use_login_shell:
625
+ # Get the user's login shell
626
+ user_shell = os.environ.get("SHELL", "/bin/bash")
627
+ os.path.basename(user_shell)
628
+
629
+ self._log(f"Using login shell for script execution: {user_shell}")
630
+
631
+ # Build the command including args
632
+ cmd = f"{command} {temp_path}"
633
+ if args:
634
+ cmd += " " + " ".join(args)
635
+
636
+ # Create command that runs script through login shell
637
+ shell_cmd = f"{user_shell} -l -c '{cmd}'"
638
+
639
+ self._log(f"Executing script from file with login shell: {shell_cmd}")
640
+
641
+ # Create and run the process with shell
642
+ process = await asyncio.create_subprocess_shell(
643
+ shell_cmd,
644
+ stdout=asyncio.subprocess.PIPE,
645
+ stderr=asyncio.subprocess.PIPE,
646
+ cwd=cwd,
647
+ env=command_env,
648
+ )
649
+ else:
650
+ # Build command arguments
651
+ cmd_args = [command, temp_path]
652
+ if args:
653
+ cmd_args.extend(args)
654
+
655
+ self._log(f"Executing script from file with: {' '.join(cmd_args)}")
656
+
657
+ # Create and run the process normally
658
+ process = await asyncio.create_subprocess_exec(
659
+ *cmd_args,
660
+ stdout=asyncio.subprocess.PIPE,
661
+ stderr=asyncio.subprocess.PIPE,
662
+ cwd=cwd,
663
+ env=command_env,
664
+ )
665
+
666
+ # Wait for the process to complete with timeout
667
+ try:
668
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
669
+ process.communicate(), timeout=timeout
670
+ )
671
+
672
+ return CommandResult(
673
+ return_code=process.returncode or 0,
674
+ stdout=stdout_bytes.decode("utf-8", errors="replace"),
675
+ stderr=stderr_bytes.decode("utf-8", errors="replace"),
676
+ )
677
+ except asyncio.TimeoutError:
678
+ # Kill the process if it times out
679
+ try:
680
+ process.kill()
681
+ except ProcessLookupError:
682
+ pass # Process already terminated
683
+
684
+ return CommandResult(
685
+ return_code=-1,
686
+ error_message=f"Script execution timed out after {timeout} seconds",
687
+ )
688
+ except Exception as e:
689
+ self._log(f"Script file execution error: {str(e)}")
690
+ return CommandResult(
691
+ return_code=1, error_message=f"Error executing script: {str(e)}"
692
+ )
693
+ finally:
694
+ # Clean up temporary file
695
+ try:
696
+ os.unlink(temp_path)
697
+ except Exception as e:
698
+ self._log(f"Error cleaning up temporary file: {str(e)}")
699
+
700
+ def get_available_languages(self) -> list[str]:
701
+ """Get a list of available script languages.
702
+
703
+ Returns:
704
+ List of supported language names
705
+ """
706
+ # Use the same language map as in execute_script_from_file method
707
+ language_map = {
708
+ "python": {"command": "python", "extension": ".py"},
709
+ "javascript": {"command": "node", "extension": ".js"},
710
+ "typescript": {"command": "ts-node", "extension": ".ts"},
711
+ "bash": {"command": "bash", "extension": ".sh"},
712
+ "fish": {"command": "fish", "extension": ".fish"},
713
+ "ruby": {"command": "ruby", "extension": ".rb"},
714
+ "php": {"command": "php", "extension": ".php"},
715
+ "perl": {"command": "perl", "extension": ".pl"},
716
+ "r": {"command": "Rscript", "extension": ".R"},
717
+ }
718
+ return list(language_map.keys())
719
+
720
+ def register_tools(self, mcp_server: FastMCP) -> None:
721
+ """Register command execution tools with the MCP server.
722
+
723
+ Args:
724
+ mcp_server: The FastMCP server instance
725
+ """
726
+
727
+ # Run Command Tool
728
+ @mcp_server.tool()
729
+ async def run_command(
730
+ command: str,
731
+ cwd: str,
732
+ ctx: MCPContext,
733
+ use_login_shell: bool = True,
734
+ ) -> str:
735
+ """Execute a shell command.
736
+
737
+ Args:
738
+ command: The shell command to execute
739
+ cwd: Working directory for the command
740
+
741
+ use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
742
+
743
+ Returns:
744
+ The output of the command
745
+ """
746
+ tool_ctx = create_tool_context(ctx)
747
+ tool_ctx.set_tool_info("run_command")
748
+ await tool_ctx.info(f"Executing command: {command}")
749
+
750
+ # Check if command is allowed
751
+ if not self.is_command_allowed(command):
752
+ await tool_ctx.error(f"Command not allowed: {command}")
753
+ return f"Error: Command not allowed: {command}"
754
+
755
+ # Validate required cwd parameter
756
+ if not cwd:
757
+ await tool_ctx.error("Parameter 'cwd' is required but was None")
758
+ return "Error: Parameter 'cwd' is required but was None"
759
+
760
+ if cwd.strip() == "":
761
+ await tool_ctx.error("Parameter 'cwd' cannot be empty")
762
+ return "Error: Parameter 'cwd' cannot be empty"
763
+
764
+ # Check if working directory is allowed
765
+ if not self.permission_manager.is_path_allowed(cwd):
766
+ await tool_ctx.error(f"Working directory not allowed: {cwd}")
767
+ return f"Error: Working directory not allowed: {cwd}"
768
+
769
+ # Check if working directory exists
770
+ if not os.path.isdir(cwd):
771
+ await tool_ctx.error(f"Working directory does not exist: {cwd}")
772
+ return f"Error: Working directory does not exist: {cwd}"
773
+
774
+ # Execute the command
775
+ result: CommandResult = await self.execute_command(
776
+ command, cwd=cwd, timeout=30.0, use_login_shell=use_login_shell
777
+ )
778
+
779
+ # Report result
780
+ if result.is_success:
781
+ await tool_ctx.info("Command executed successfully")
782
+ else:
783
+ await tool_ctx.error(
784
+ f"Command failed with exit code {result.return_code}"
785
+ )
786
+
787
+ # Format the result
788
+ if result.is_success:
789
+ # For successful commands, just return stdout unless stderr has content
790
+ if result.stderr:
791
+ return f"Command executed successfully.\n\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
792
+ return result.stdout
793
+ else:
794
+ # For failed commands, include all available information
795
+ return result.format_output()
796
+
797
+ # Run Script Tool
798
+ @mcp_server.tool()
799
+ async def run_script(
800
+ script: str,
801
+ cwd: str,
802
+ ctx: MCPContext,
803
+ interpreter: str = "bash",
804
+ use_login_shell: bool = True,
805
+ ) -> str:
806
+ """Execute a script with the specified interpreter.
807
+
808
+ Args:
809
+ script: The script content to execute
810
+ cwd: Working directory for script execution
811
+
812
+ interpreter: The interpreter to use (bash, python, etc.)
813
+ use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
814
+
815
+ Returns:
816
+ The output of the script
817
+ """
818
+ tool_ctx = create_tool_context(ctx)
819
+ tool_ctx.set_tool_info("run_script")
820
+
821
+ # Validate script parameter
822
+ if not script:
823
+ await tool_ctx.error("Parameter 'script' is required but was None")
824
+ return "Error: Parameter 'script' is required but was None"
825
+
826
+ if script.strip() == "":
827
+ await tool_ctx.error("Parameter 'script' cannot be empty")
828
+ return "Error: Parameter 'script' cannot be empty"
829
+
830
+ # interpreter can be None safely as it has a default value
831
+ if not interpreter:
832
+ interpreter = "bash" # Use default if None
833
+ elif interpreter.strip() == "":
834
+ await tool_ctx.error("Parameter 'interpreter' cannot be empty")
835
+ return "Error: Parameter 'interpreter' cannot be empty"
836
+
837
+ # Validate required cwd parameter
838
+ if not cwd:
839
+ await tool_ctx.error("Parameter 'cwd' is required but was None")
840
+ return "Error: Parameter 'cwd' is required but was None"
841
+
842
+ if cwd.strip() == "":
843
+ await tool_ctx.error("Parameter 'cwd' cannot be empty")
844
+ return "Error: Parameter 'cwd' cannot be empty"
845
+
846
+ await tool_ctx.info(f"Executing script with interpreter: {interpreter}")
847
+
848
+ # Validate required cwd parameter
849
+ if not cwd:
850
+ await tool_ctx.error("Parameter 'cwd' is required but was None")
851
+ return "Error: Parameter 'cwd' is required but was None"
852
+
853
+ if cwd.strip() == "":
854
+ await tool_ctx.error("Parameter 'cwd' cannot be empty")
855
+ return "Error: Parameter 'cwd' cannot be empty"
856
+
857
+ # Check if working directory is allowed
858
+ if not self.permission_manager.is_path_allowed(cwd):
859
+ await tool_ctx.error(f"Working directory not allowed: {cwd}")
860
+ return f"Error: Working directory not allowed: {cwd}"
861
+
862
+ # Check if working directory exists
863
+ if not os.path.isdir(cwd):
864
+ await tool_ctx.error(f"Working directory does not exist: {cwd}")
865
+ return f"Error: Working directory does not exist: {cwd}"
866
+
867
+ # Execute the script
868
+ result: CommandResult = await self.execute_script(
869
+ script=script,
870
+ interpreter=interpreter,
871
+ cwd=cwd, # cwd is now a required parameter
872
+ timeout=30.0,
873
+ use_login_shell=use_login_shell,
874
+ )
875
+
876
+ # Report result
877
+ if result.is_success:
878
+ await tool_ctx.info("Script executed successfully")
879
+ else:
880
+ await tool_ctx.error(
881
+ f"Script execution failed with exit code {result.return_code}"
882
+ )
883
+
884
+ # Format the result
885
+ if result.is_success:
886
+ # For successful scripts, just return stdout unless stderr has content
887
+ if result.stderr:
888
+ return f"Script executed successfully.\n\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
889
+ return result.stdout
890
+ else:
891
+ # For failed scripts, include all available information
892
+ return result.format_output()
893
+
894
+ # Script tool for executing scripts in various languages
895
+ @mcp_server.tool()
896
+ async def script_tool(
897
+ language: str,
898
+ script: str,
899
+ cwd: str,
900
+ ctx: MCPContext,
901
+ args: list[str] | None = None,
902
+ use_login_shell: bool = True,
903
+ ) -> str:
904
+ """Execute a script in the specified language.
905
+
906
+ Args:
907
+ language: The programming language (python, javascript, etc.)
908
+ script: The script code to execute
909
+ cwd: Working directory for script execution
910
+
911
+ args: Optional command-line arguments
912
+ use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
913
+
914
+ Returns:
915
+ Script execution results
916
+ """
917
+ tool_ctx = create_tool_context(ctx)
918
+ tool_ctx.set_tool_info("script_tool")
919
+
920
+ # Validate required parameters
921
+ if not language:
922
+ await tool_ctx.error("Parameter 'language' is required but was None")
923
+ return "Error: Parameter 'language' is required but was None"
924
+
925
+ if language.strip() == "":
926
+ await tool_ctx.error("Parameter 'language' cannot be empty")
927
+ return "Error: Parameter 'language' cannot be empty"
928
+
929
+ if not script:
930
+ await tool_ctx.error("Parameter 'script' is required but was None")
931
+ return "Error: Parameter 'script' is required but was None"
932
+
933
+ if script.strip() == "":
934
+ await tool_ctx.error("Parameter 'script' cannot be empty")
935
+ return "Error: Parameter 'script' cannot be empty"
936
+
937
+ # args can be None as it's optional
938
+ # Check for empty list but still allow None
939
+ if args is not None and len(args) == 0:
940
+ await tool_ctx.warning("Parameter 'args' is an empty list")
941
+ # We don't return error for this as empty args is acceptable
942
+
943
+ # Validate required cwd parameter
944
+ if not cwd:
945
+ await tool_ctx.error("Parameter 'cwd' is required but was None")
946
+ return "Error: Parameter 'cwd' is required but was None"
947
+
948
+ if cwd.strip() == "":
949
+ await tool_ctx.error("Parameter 'cwd' cannot be empty")
950
+ return "Error: Parameter 'cwd' cannot be empty"
951
+
952
+ await tool_ctx.info(f"Executing {language} script")
953
+
954
+ # Check if the language is supported
955
+ if language not in self.get_available_languages():
956
+ await tool_ctx.error(f"Unsupported language: {language}")
957
+ return f"Error: Unsupported language: {language}. Supported languages: {', '.join(self.get_available_languages())}"
958
+
959
+ # Check if working directory is allowed
960
+ if not self.permission_manager.is_path_allowed(cwd):
961
+ await tool_ctx.error(f"Working directory not allowed: {cwd}")
962
+ return f"Error: Working directory not allowed: {cwd}"
963
+
964
+ # Check if working directory exists
965
+ if not os.path.isdir(cwd):
966
+ await tool_ctx.error(f"Working directory does not exist: {cwd}")
967
+ return f"Error: Working directory does not exist: {cwd}"
968
+
969
+ # Proceed with execution
970
+ await tool_ctx.info(f"Executing {language} script in {cwd}")
971
+
972
+ # Execute the script
973
+ result = await self.execute_script_from_file(
974
+ script=script,
975
+ language=language,
976
+ cwd=cwd, # cwd is now a required parameter
977
+ timeout=30.0,
978
+ args=args,
979
+ use_login_shell=use_login_shell,
980
+ )
981
+
982
+ # Report result
983
+ if result.is_success:
984
+ await tool_ctx.info(f"{language} script executed successfully")
985
+ else:
986
+ await tool_ctx.error(
987
+ f"{language} script execution failed with exit code {result.return_code}"
988
+ )
989
+
990
+ # Format the result
991
+ if result.is_success:
992
+ # Format the successful result
993
+ output = f"{language} script executed successfully.\n\n"
994
+ if result.stdout:
995
+ output += f"STDOUT:\n{result.stdout}\n\n"
996
+ if result.stderr:
997
+ output += f"STDERR:\n{result.stderr}"
998
+ return output.strip()
999
+ else:
1000
+ # For failed scripts, include all available information
1001
+ return result.format_output()