hanzo-mcp 0.3.4__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hanzo-mcp might be problematic. Click here for more details.

Files changed (87) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/cli.py +123 -160
  3. hanzo_mcp/cli_enhanced.py +438 -0
  4. hanzo_mcp/config/__init__.py +19 -0
  5. hanzo_mcp/config/settings.py +388 -0
  6. hanzo_mcp/config/tool_config.py +197 -0
  7. hanzo_mcp/prompts/__init__.py +117 -0
  8. hanzo_mcp/prompts/compact_conversation.py +77 -0
  9. hanzo_mcp/prompts/create_release.py +38 -0
  10. hanzo_mcp/prompts/project_system.py +120 -0
  11. hanzo_mcp/prompts/project_todo_reminder.py +111 -0
  12. hanzo_mcp/prompts/utils.py +286 -0
  13. hanzo_mcp/server.py +120 -98
  14. hanzo_mcp/tools/__init__.py +107 -31
  15. hanzo_mcp/tools/agent/__init__.py +8 -11
  16. hanzo_mcp/tools/agent/agent_tool.py +290 -224
  17. hanzo_mcp/tools/agent/prompt.py +16 -13
  18. hanzo_mcp/tools/agent/tool_adapter.py +9 -9
  19. hanzo_mcp/tools/common/__init__.py +17 -16
  20. hanzo_mcp/tools/common/base.py +79 -110
  21. hanzo_mcp/tools/common/batch_tool.py +330 -0
  22. hanzo_mcp/tools/common/context.py +26 -292
  23. hanzo_mcp/tools/common/permissions.py +12 -12
  24. hanzo_mcp/tools/common/thinking_tool.py +153 -0
  25. hanzo_mcp/tools/common/validation.py +1 -63
  26. hanzo_mcp/tools/filesystem/__init__.py +88 -41
  27. hanzo_mcp/tools/filesystem/base.py +32 -24
  28. hanzo_mcp/tools/filesystem/content_replace.py +114 -107
  29. hanzo_mcp/tools/filesystem/directory_tree.py +129 -105
  30. hanzo_mcp/tools/filesystem/edit.py +279 -0
  31. hanzo_mcp/tools/filesystem/grep.py +458 -0
  32. hanzo_mcp/tools/filesystem/grep_ast_tool.py +250 -0
  33. hanzo_mcp/tools/filesystem/multi_edit.py +362 -0
  34. hanzo_mcp/tools/filesystem/read.py +255 -0
  35. hanzo_mcp/tools/filesystem/write.py +156 -0
  36. hanzo_mcp/tools/jupyter/__init__.py +41 -29
  37. hanzo_mcp/tools/jupyter/base.py +66 -57
  38. hanzo_mcp/tools/jupyter/{edit_notebook.py → notebook_edit.py} +162 -139
  39. hanzo_mcp/tools/jupyter/notebook_read.py +152 -0
  40. hanzo_mcp/tools/shell/__init__.py +29 -20
  41. hanzo_mcp/tools/shell/base.py +87 -45
  42. hanzo_mcp/tools/shell/bash_session.py +731 -0
  43. hanzo_mcp/tools/shell/bash_session_executor.py +295 -0
  44. hanzo_mcp/tools/shell/command_executor.py +435 -384
  45. hanzo_mcp/tools/shell/run_command.py +284 -131
  46. hanzo_mcp/tools/shell/run_command_windows.py +328 -0
  47. hanzo_mcp/tools/shell/session_manager.py +196 -0
  48. hanzo_mcp/tools/shell/session_storage.py +325 -0
  49. hanzo_mcp/tools/todo/__init__.py +66 -0
  50. hanzo_mcp/tools/todo/base.py +319 -0
  51. hanzo_mcp/tools/todo/todo_read.py +148 -0
  52. hanzo_mcp/tools/todo/todo_write.py +378 -0
  53. hanzo_mcp/tools/vector/__init__.py +95 -0
  54. hanzo_mcp/tools/vector/infinity_store.py +365 -0
  55. hanzo_mcp/tools/vector/project_manager.py +361 -0
  56. hanzo_mcp/tools/vector/vector_index.py +115 -0
  57. hanzo_mcp/tools/vector/vector_search.py +215 -0
  58. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/METADATA +35 -3
  59. hanzo_mcp-0.5.0.dist-info/RECORD +63 -0
  60. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/WHEEL +1 -1
  61. hanzo_mcp/tools/agent/base_provider.py +0 -73
  62. hanzo_mcp/tools/agent/litellm_provider.py +0 -45
  63. hanzo_mcp/tools/agent/lmstudio_agent.py +0 -385
  64. hanzo_mcp/tools/agent/lmstudio_provider.py +0 -219
  65. hanzo_mcp/tools/agent/provider_registry.py +0 -120
  66. hanzo_mcp/tools/common/error_handling.py +0 -86
  67. hanzo_mcp/tools/common/logging_config.py +0 -115
  68. hanzo_mcp/tools/common/session.py +0 -91
  69. hanzo_mcp/tools/common/think_tool.py +0 -123
  70. hanzo_mcp/tools/common/version_tool.py +0 -120
  71. hanzo_mcp/tools/filesystem/edit_file.py +0 -287
  72. hanzo_mcp/tools/filesystem/get_file_info.py +0 -170
  73. hanzo_mcp/tools/filesystem/read_files.py +0 -198
  74. hanzo_mcp/tools/filesystem/search_content.py +0 -275
  75. hanzo_mcp/tools/filesystem/write_file.py +0 -162
  76. hanzo_mcp/tools/jupyter/notebook_operations.py +0 -514
  77. hanzo_mcp/tools/jupyter/read_notebook.py +0 -165
  78. hanzo_mcp/tools/project/__init__.py +0 -64
  79. hanzo_mcp/tools/project/analysis.py +0 -882
  80. hanzo_mcp/tools/project/base.py +0 -66
  81. hanzo_mcp/tools/project/project_analyze.py +0 -173
  82. hanzo_mcp/tools/shell/run_script.py +0 -215
  83. hanzo_mcp/tools/shell/script_tool.py +0 -244
  84. hanzo_mcp-0.3.4.dist-info/RECORD +0 -53
  85. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
  86. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/licenses/LICENSE +0 -0
  87. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/top_level.txt +0 -0
@@ -7,19 +7,16 @@ comprehensive error handling, permissions checking, and progress tracking.
7
7
  import asyncio
8
8
  import base64
9
9
  import os
10
+ import re
10
11
  import shlex
12
+ import shutil
11
13
  import sys
12
14
  import tempfile
13
15
  from collections.abc import Awaitable, Callable
14
- from pathlib import Path
15
- from typing import Dict, Optional, final
16
+ from typing import final
16
17
 
17
- from mcp.server.fastmcp import Context as MCPContext
18
- from mcp.server.fastmcp import FastMCP
19
18
 
20
- from hanzo_mcp.tools.common.context import create_tool_context
21
19
  from hanzo_mcp.tools.common.permissions import PermissionManager
22
- from hanzo_mcp.tools.common.session import SessionManager
23
20
  from hanzo_mcp.tools.shell.base import CommandResult
24
21
 
25
22
 
@@ -43,111 +40,135 @@ class CommandExecutor:
43
40
  self.permission_manager: PermissionManager = permission_manager
44
41
  self.verbose: bool = verbose
45
42
 
46
- # Session management (initialized on first use with session ID)
47
- self.session_manager: Optional[SessionManager] = None
48
-
49
43
  # Excluded commands or patterns
50
44
  self.excluded_commands: list[str] = ["rm"]
51
45
 
52
46
  # Map of supported interpreters with special handling
53
- self.special_interpreters: Dict[
47
+ self.special_interpreters: dict[
54
48
  str,
55
- Callable[
56
- [str, str, str], dict[str, str]], Optional[float | None | None,
57
- Awaitable[CommandResult],
58
- ],
49
+ Callable[[str, str, str], dict[str, str]] | Awaitable[CommandResult],
59
50
  ] = {
60
51
  "fish": self._handle_fish_script,
61
52
  }
62
53
 
63
- def allow_command(self, command: str) -> None:
64
- """Allow a specific command that might otherwise be excluded.
54
+ def _get_shell_by_type(self, shell_type: str) -> tuple[str, str]:
55
+ """Get shell information for a specified shell type.
65
56
 
66
57
  Args:
67
- command: The command to allow
58
+ shell_type: The shell name to use (e.g., "bash", "cmd", "powershell")
59
+
60
+ Returns:
61
+ Tuple of (shell_basename, shell_path)
68
62
  """
69
- if command in self.excluded_commands:
70
- self.excluded_commands.remove(command)
63
+ shell_path = shutil.which(shell_type)
64
+ if shell_path is None:
65
+ # Shell not found, fall back to default
66
+ self._log(f"Requested shell '{shell_type}' not found, using system default")
67
+ return self._get_system_shell()
71
68
 
72
- def deny_command(self, command: str) -> None:
73
- """Deny a specific command, adding it to the excluded list.
69
+ if sys.platform == "win32":
70
+ shell_path = shell_path.lower()
71
+
72
+ shell_basename = os.path.basename(shell_path).lower()
73
+ return shell_basename, shell_path
74
+
75
+ def _get_system_shell(self, shell_type: str | None = None) -> tuple[str, str]:
76
+ """Get the system's default shell based on the platform.
74
77
 
75
78
  Args:
76
- command: The command to deny
77
- """
78
- if command not in self.excluded_commands:
79
- self.excluded_commands.append(command)
79
+ shell_type: Optional specific shell to use instead of system default
80
80
 
81
- def _get_preferred_shell(self) -> str:
82
- """Get the best available shell for the current environment.
83
-
84
- Tries to find the best shell in this order:
85
- 1. User's SHELL environment variable
86
- 2. zsh
87
- 3. bash
88
- 4. fish
89
- 5. sh (fallback)
90
-
91
81
  Returns:
92
- Path to the preferred shell
82
+ Tuple of (shell_basename, shell_path)
93
83
  """
94
- # First check the SHELL environment variable
95
- user_shell = os.environ.get("SHELL")
96
- if user_shell and os.path.exists(user_shell):
97
- return user_shell
98
-
99
- # Try common shells in order of preference
100
- shell_paths = [
101
- "/bin/zsh",
102
- "/usr/bin/zsh",
103
- "/bin/bash",
104
- "/usr/bin/bash",
105
- "/bin/fish",
106
- "/usr/bin/fish",
107
- "/bin/sh",
108
- "/usr/bin/sh",
109
- ]
110
-
111
- for shell_path in shell_paths:
112
- if os.path.exists(shell_path) and os.access(shell_path, os.X_OK):
113
- return shell_path
114
-
115
- # Fallback to /bin/sh which should exist on most systems
116
- return "/bin/sh"
117
-
118
- def get_session_manager(self, session_id: str) -> SessionManager:
119
- """Get the session manager for the given session ID.
84
+ # If a specific shell is requested, use that
85
+ if shell_type is not None:
86
+ return self._get_shell_by_type(shell_type)
87
+
88
+ # Otherwise use system default
89
+ if sys.platform == "win32":
90
+ # On Windows, default to Command Prompt
91
+ comspec = os.environ.get("COMSPEC", "cmd.exe").lower()
92
+ return "cmd", comspec
93
+ else:
94
+ # Unix systems
95
+ user_shell = os.environ.get("SHELL", "/bin/bash")
96
+ return os.path.basename(user_shell).lower(), user_shell
97
+
98
+ def _format_win32_shell_command(
99
+ self, shell_basename, user_shell, command, use_login_shell=True
100
+ ):
101
+ """Format a command for execution with the appropriate Windows shell.
120
102
 
121
103
  Args:
122
- session_id: The session ID
104
+ shell_basename: The basename of the shell
105
+ user_shell: The full path to the shell
106
+ command: The command to execute
107
+ use_login_shell: Whether to use login shell settings
123
108
 
124
109
  Returns:
125
- The session manager instance
110
+ Formatted shell command string
126
111
  """
127
- return SessionManager.get_instance(session_id)
112
+ formatted_command = ""
113
+
114
+ if shell_basename in ["wsl", "wsl.exe"]:
115
+ # For WSL, handle commands with shell operators differently
116
+ if any(char in command for char in ";&|<>(){}[]$\"'`"):
117
+ # For commands with special characters, use a more reliable approach
118
+ # with bash -c and double quotes around the entire command
119
+ escaped_command = command.replace('"', '\\"')
120
+ if use_login_shell:
121
+ formatted_command = f'{user_shell} bash -l -c "{escaped_command}"'
122
+ else:
123
+ formatted_command = f'{user_shell} bash -c "{escaped_command}"'
124
+ else:
125
+ # # For simple commands without special characters
126
+ # # Still respect login shell preference
127
+ # if use_login_shell:
128
+ # formatted_command = f"{user_shell} bash -l -c \"{command}\""
129
+ # else:
130
+ formatted_command = f"{user_shell} {command}"
131
+
132
+ elif shell_basename in ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]:
133
+ # For PowerShell, escape double quotes with backslash most robust
134
+ escaped_command = command.replace('"', '\\"')
135
+ formatted_command = f'"{user_shell}" -Command "{escaped_command}"'
128
136
 
129
- def set_working_dir(self, session_id: str, path: str) -> None:
130
- """Set the current working directory for the session.
137
+ else:
138
+ # For CMD, use the /c parameter and wrap in double quotes
139
+ # CMD doesn't have an explicit login shell concept
140
+ formatted_command = f'"{user_shell}" /c "{command}"'
141
+
142
+ self._log(
143
+ "Win32 Shell Results",
144
+ {
145
+ "shell_basename": shell_basename,
146
+ "user_shell": user_shell,
147
+ "command": command,
148
+ "use_login_shell": use_login_shell,
149
+ "formatted_command": formatted_command,
150
+ },
151
+ )
152
+
153
+ return formatted_command
154
+
155
+ def allow_command(self, command: str) -> None:
156
+ """Allow a specific command that might otherwise be excluded.
131
157
 
132
158
  Args:
133
- session_id: The session ID
134
- path: The path to set as the current working directory
159
+ command: The command to allow
135
160
  """
136
- session = self.get_session_manager(session_id)
137
- expanded_path = os.path.expanduser(path)
138
- session.set_working_dir(Path(expanded_path))
161
+ if command in self.excluded_commands:
162
+ self.excluded_commands.remove(command)
139
163
 
140
- def get_working_dir(self, session_id: str) -> str:
141
- """Get the current working directory for the session.
164
+ def deny_command(self, command: str) -> None:
165
+ """Deny a specific command, adding it to the excluded list.
142
166
 
143
167
  Args:
144
- session_id: The session ID
145
-
146
- Returns:
147
- The current working directory
168
+ command: The command to deny
148
169
  """
149
- session = self.get_session_manager(session_id)
150
- return str(session.current_working_dir)
170
+ if command not in self.excluded_commands:
171
+ self.excluded_commands.append(command)
151
172
 
152
173
  def _log(self, message: str, data: object | None = None) -> None:
153
174
  """Log a message if verbose logging is enabled.
@@ -155,9 +176,6 @@ class CommandExecutor:
155
176
  Args:
156
177
  message: The message to log
157
178
  data: Optional data to include with the message
158
-
159
- Note:
160
- Always logs to stderr to avoid interfering with stdio transport
161
179
  """
162
180
  if not self.verbose:
163
181
  return
@@ -208,20 +226,20 @@ class CommandExecutor:
208
226
  self,
209
227
  command: str,
210
228
  cwd: str | None = None,
229
+ shell_type: str | None = None,
211
230
  env: dict[str, str] | None = None,
212
231
  timeout: float | None = 60.0,
213
232
  use_login_shell: bool = True,
214
- session_id: str | None = None,
215
233
  ) -> CommandResult:
216
234
  """Execute a shell command with safety checks.
217
235
 
218
236
  Args:
219
237
  command: The command to execute
220
- cwd: Optional working directory (if None, uses session's current working directory)
238
+ cwd: Optional working directory
221
239
  env: Optional environment variables
222
240
  timeout: Optional timeout in seconds
223
241
  use_login_shell: Whether to use login shell. default true (loads ~/.zshrc, ~/.bashrc, etc.)
224
- session_id: Optional session ID for persistent working directory
242
+ shell_type: Optional shell to use (e.g., "cmd", "powershell", "wsl", "bash")
225
243
 
226
244
  Returns:
227
245
  CommandResult containing execution results
@@ -234,50 +252,17 @@ class CommandExecutor:
234
252
  return_code=1, error_message=f"Command not allowed: {command}"
235
253
  )
236
254
 
237
- # Use session working directory if no cwd specified and session_id provided
238
- effective_cwd = cwd
239
- if session_id and not cwd:
240
- effective_cwd = self.get_working_dir(session_id)
241
- self._log(f"Using session working directory: {effective_cwd}")
242
-
243
- # Check if it's a cd command and update session working directory
244
- is_cd_command = False
245
- if session_id and command.strip().startswith("cd "):
246
- is_cd_command = True
247
- args = shlex.split(command)
248
- if len(args) > 1:
249
- target_dir = args[1]
250
- # Handle relative paths
251
- if not os.path.isabs(target_dir):
252
- session_cwd = self.get_working_dir(session_id)
253
- target_dir = os.path.join(session_cwd, target_dir)
254
-
255
- # Expand user paths
256
- target_dir = os.path.expanduser(target_dir)
257
-
258
- # Normalize path
259
- target_dir = os.path.normpath(target_dir)
260
-
261
- if os.path.isdir(target_dir):
262
- self.set_working_dir(session_id, target_dir)
263
- self._log(f"Updated session working directory: {target_dir}")
264
- return CommandResult(return_code=0, stdout="")
265
- else:
266
- return CommandResult(
267
- return_code=1, error_message=f"Directory does not exist: {target_dir}"
268
- )
269
-
270
255
  # Check working directory permissions if specified
271
- if effective_cwd:
272
- if not os.path.isdir(effective_cwd):
256
+ if cwd:
257
+ if not os.path.isdir(cwd):
273
258
  return CommandResult(
274
259
  return_code=1,
275
- error_message=f"Working directory does not exist: {effective_cwd}",
260
+ error_message=f"Working directory does not exist: {cwd}",
276
261
  )
277
262
 
278
- if not self.permission_manager.is_path_allowed(effective_cwd):
263
+ if not self.permission_manager.is_path_allowed(cwd):
279
264
  return CommandResult(
280
- return_code=1, error_message=f"Working directory not allowed: {effective_cwd}"
265
+ return_code=1, error_message=f"Working directory not allowed: {cwd}"
281
266
  )
282
267
 
283
268
  # Set up environment
@@ -286,56 +271,84 @@ class CommandExecutor:
286
271
  command_env.update(env)
287
272
 
288
273
  try:
289
- # Check if command uses shell features like &&, ||, |, etc. or $ for env vars
290
- shell_operators = ["&&", "||", "|", ";", ">", "<", "$(", "`", "$"]
291
- needs_shell = any(op in command for op in shell_operators)
274
+ # Get shell information
275
+ shell_basename, user_shell = self._get_system_shell(shell_type)
292
276
 
293
- if needs_shell or use_login_shell:
294
- # Determine which shell to use
295
- shell_cmd = command
277
+ if sys.platform == "win32":
278
+ # On Windows, always use shell execution
279
+ self._log(f"Using shell on Windows: {user_shell} ({shell_basename})")
296
280
 
297
- if use_login_shell:
298
- # Try to find the best available shell
299
- user_shell = self._get_preferred_shell()
300
- shell_basename = os.path.basename(user_shell)
301
-
302
- self._log(f"Using login shell: {user_shell}")
303
-
304
- # Wrap command with appropriate shell invocation
305
- if shell_basename == "zsh":
306
- shell_cmd = f"{user_shell} -l -c '{command}'"
307
- elif shell_basename == "bash":
308
- shell_cmd = f"{user_shell} -l -c '{command}'"
309
- elif shell_basename == "fish":
310
- shell_cmd = f"{user_shell} -l -c '{command}'"
311
- else:
312
- # Default fallback
313
- shell_cmd = f"{user_shell} -c '{command}'"
314
- else:
315
- self._log(
316
- f"Using shell for command with shell operators: {command}"
317
- )
281
+ # Format command using helper method
282
+ shell_cmd = self._format_win32_shell_command(
283
+ shell_basename, user_shell, command, use_login_shell
284
+ )
318
285
 
319
286
  # Use shell for command execution
320
287
  process = await asyncio.create_subprocess_shell(
321
288
  shell_cmd,
322
289
  stdout=asyncio.subprocess.PIPE,
323
290
  stderr=asyncio.subprocess.PIPE,
324
- cwd=effective_cwd,
291
+ cwd=cwd,
325
292
  env=command_env,
326
293
  )
327
294
  else:
328
- # Split the command into arguments for regular commands
329
- args: list[str] = shlex.split(command)
330
-
331
- # Create and run the process without shell
332
- process = await asyncio.create_subprocess_exec(
333
- *args,
334
- stdout=asyncio.subprocess.PIPE,
335
- stderr=asyncio.subprocess.PIPE,
336
- cwd=effective_cwd,
337
- env=command_env,
338
- )
295
+ # Unix systems - original logic
296
+ shell_operators = ["&&", "||", "|", ";", ">", "<", "$(", "`", "$"]
297
+ needs_shell = any(op in command for op in shell_operators)
298
+
299
+ if needs_shell or use_login_shell:
300
+ # Determine which shell to use
301
+ shell_cmd = command
302
+
303
+ if use_login_shell:
304
+ self._log(f"Using login shell: {user_shell} ({shell_basename})")
305
+
306
+ # Escape single quotes in command for shell -c wrapper
307
+ # The standard way to escape a single quote within a single-quoted string in POSIX shells is '\''
308
+ escaped_command = command.replace("'", "'\\''")
309
+ self._log(f"Original command: {command}")
310
+ self._log(f"Escaped command: {escaped_command}")
311
+
312
+ # Wrap command with appropriate shell invocation
313
+ if shell_basename == "zsh":
314
+ shell_cmd = f"{user_shell} -l -c '{escaped_command}'"
315
+ elif shell_basename == "bash":
316
+ shell_cmd = f"{user_shell} -l -c '{escaped_command}'"
317
+ elif shell_basename == "fish":
318
+ shell_cmd = f"{user_shell} -l -c '{escaped_command}'"
319
+ else:
320
+ # Default fallback
321
+ shell_cmd = f"{user_shell} -c '{escaped_command}'"
322
+ else:
323
+ self._log(
324
+ f"Using shell for command with shell operators: {command}"
325
+ )
326
+ # Escape single quotes in command for shell execution
327
+ escaped_command = command.replace("'", "'\\''")
328
+ self._log(f"Original command: {command}")
329
+ self._log(f"Escaped command: {escaped_command}")
330
+ shell_cmd = f"{user_shell} -c '{escaped_command}'"
331
+
332
+ # Use shell for command execution
333
+ process = await asyncio.create_subprocess_shell(
334
+ shell_cmd,
335
+ stdout=asyncio.subprocess.PIPE,
336
+ stderr=asyncio.subprocess.PIPE,
337
+ cwd=cwd,
338
+ env=command_env,
339
+ )
340
+ else:
341
+ # Split the command into arguments for regular commands
342
+ args: list[str] = shlex.split(command)
343
+
344
+ # Create and run the process without shell
345
+ process = await asyncio.create_subprocess_exec(
346
+ *args,
347
+ stdout=asyncio.subprocess.PIPE,
348
+ stderr=asyncio.subprocess.PIPE,
349
+ cwd=cwd,
350
+ env=command_env,
351
+ )
339
352
 
340
353
  # Wait for the process to complete with timeout
341
354
  try:
@@ -370,44 +383,38 @@ class CommandExecutor:
370
383
  script: str,
371
384
  interpreter: str = "bash",
372
385
  cwd: str | None = None,
386
+ shell_type: str | None = None,
373
387
  env: dict[str, str] | None = None,
374
388
  timeout: float | None = 60.0,
375
389
  use_login_shell: bool = True,
376
- session_id: str | None = None,
377
390
  ) -> CommandResult:
378
391
  """Execute a script with the specified interpreter.
379
392
 
380
393
  Args:
381
394
  script: The script content to execute
382
395
  interpreter: The interpreter to use (bash, python, etc.)
383
- cwd: Optional working directory (if None, uses session's current working directory)
396
+ cwd: Optional working directory
397
+ shell_type: Optional shell to use (e.g., "cmd", "powershell", "wsl", "bash")
384
398
  env: Optional environment variables
385
399
  timeout: Optional timeout in seconds
386
400
  use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
387
- session_id: Optional session ID for persistent working directory
388
401
 
389
402
  Returns:
390
403
  CommandResult containing execution results
391
404
  """
392
405
  self._log(f"Executing script with interpreter: {interpreter}")
393
406
 
394
- # Use session working directory if no cwd specified and session_id provided
395
- effective_cwd = cwd
396
- if session_id and not cwd:
397
- effective_cwd = self.get_working_dir(session_id)
398
- self._log(f"Using session working directory: {effective_cwd}")
399
-
400
407
  # Check working directory permissions if specified
401
- if effective_cwd:
402
- if not os.path.isdir(effective_cwd):
408
+ if cwd:
409
+ if not os.path.isdir(cwd):
403
410
  return CommandResult(
404
411
  return_code=1,
405
- error_message=f"Working directory does not exist: {effective_cwd}",
412
+ error_message=f"Working directory does not exist: {cwd}",
406
413
  )
407
414
 
408
- if not self.permission_manager.is_path_allowed(effective_cwd):
415
+ if not self.permission_manager.is_path_allowed(cwd):
409
416
  return CommandResult(
410
- return_code=1, error_message=f"Working directory not allowed: {effective_cwd}"
417
+ return_code=1, error_message=f"Working directory not allowed: {cwd}"
411
418
  )
412
419
 
413
420
  # Check if we need special handling for this interpreter
@@ -415,11 +422,11 @@ class CommandExecutor:
415
422
  if interpreter_name in self.special_interpreters:
416
423
  self._log(f"Using special handler for interpreter: {interpreter_name}")
417
424
  special_handler = self.special_interpreters[interpreter_name]
418
- return await special_handler(interpreter, script, effective_cwd, env, timeout)
425
+ return await special_handler(interpreter, script, cwd, env, timeout)
419
426
 
420
427
  # Regular execution
421
428
  return await self._execute_script_with_stdin(
422
- interpreter, script, effective_cwd, env, timeout, use_login_shell
429
+ interpreter, script, cwd, shell_type, env, timeout, use_login_shell
423
430
  )
424
431
 
425
432
  async def _execute_script_with_stdin(
@@ -427,6 +434,7 @@ class CommandExecutor:
427
434
  interpreter: str,
428
435
  script: str,
429
436
  cwd: str | None = None,
437
+ shell_type: str | None = None,
430
438
  env: dict[str, str] | None = None,
431
439
  timeout: float | None = 60.0,
432
440
  use_login_shell: bool = True,
@@ -437,6 +445,7 @@ class CommandExecutor:
437
445
  interpreter: The interpreter command
438
446
  script: The script content
439
447
  cwd: Optional working directory
448
+ shell_type: Optional shell to use (e.g., "cmd", "powershell", "wsl", "bash")
440
449
  env: Optional environment variables
441
450
  timeout: Optional timeout in seconds
442
451
  use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
@@ -450,38 +459,57 @@ class CommandExecutor:
450
459
  command_env.update(env)
451
460
 
452
461
  try:
453
- # Determine if we should use a login shell
454
- if use_login_shell:
455
- # Try to find the best available shell
456
- user_shell = self._get_preferred_shell()
457
- shell_basename = os.path.basename(user_shell)
462
+ # Get shell information
463
+ shell_basename, user_shell = self._get_system_shell(shell_type)
458
464
 
459
- self._log(f"Using login shell for interpreter: {user_shell}")
465
+ if sys.platform == "win32":
466
+ # On Windows, always use shell
467
+ self._log(f"Using shell on Windows for interpreter: {user_shell}")
460
468
 
461
- # Create command that pipes script to interpreter through login shell
462
- shell_cmd = f"{user_shell} -l -c '{interpreter}'"
469
+ # Format command using helper method for the interpreter
470
+ shell_cmd = self._format_win32_shell_command(
471
+ shell_basename, user_shell, interpreter, use_login_shell
472
+ )
463
473
 
464
474
  # Create and run the process with shell
465
475
  process = await asyncio.create_subprocess_shell(
466
476
  shell_cmd,
467
- stdout=asyncio.subprocess.PIPE,
468
- stderr=asyncio.subprocess.PIPE,
469
- cwd=effective_cwd,
470
- env=command_env,
471
- )
472
- else:
473
- # Parse the interpreter command to get arguments
474
- interpreter_parts = shlex.split(interpreter)
475
-
476
- # Create and run the process normally
477
- process = await asyncio.create_subprocess_exec(
478
- *interpreter_parts,
479
477
  stdin=asyncio.subprocess.PIPE,
480
478
  stdout=asyncio.subprocess.PIPE,
481
479
  stderr=asyncio.subprocess.PIPE,
482
- cwd=effective_cwd,
480
+ cwd=cwd,
483
481
  env=command_env,
484
482
  )
483
+ else:
484
+ # Unix systems - original logic
485
+ if use_login_shell:
486
+ self._log(f"Using login shell for interpreter: {user_shell}")
487
+
488
+ # Create command that pipes script to interpreter through login shell
489
+ shell_cmd = f"{user_shell} -l -c '{interpreter}'"
490
+
491
+ # Create and run the process with shell
492
+ process = await asyncio.create_subprocess_shell(
493
+ shell_cmd,
494
+ stdin=asyncio.subprocess.PIPE,
495
+ stdout=asyncio.subprocess.PIPE,
496
+ stderr=asyncio.subprocess.PIPE,
497
+ cwd=cwd,
498
+ env=command_env,
499
+ )
500
+ else:
501
+ # Parse the interpreter command to get arguments
502
+ interpreter_parts = shlex.split(interpreter)
503
+
504
+ # Create and run the process normally
505
+ process = await asyncio.create_subprocess_exec(
506
+ *interpreter_parts,
507
+ stdin=asyncio.subprocess.PIPE,
508
+ stdout=asyncio.subprocess.PIPE,
509
+ stderr=asyncio.subprocess.PIPE,
510
+ cwd=cwd,
511
+ env=command_env,
512
+ )
485
513
 
486
514
  # Wait for the process to complete with timeout
487
515
  try:
@@ -592,11 +620,11 @@ class CommandExecutor:
592
620
  script: str,
593
621
  language: str,
594
622
  cwd: str | None = None,
623
+ shell_type: str | None = None,
595
624
  env: dict[str, str] | None = None,
596
625
  timeout: float | None = 60.0,
597
626
  args: list[str] | None = None,
598
627
  use_login_shell: bool = True,
599
- session_id: str | None = None,
600
628
  ) -> CommandResult:
601
629
  """Execute a script by writing it to a temporary file and executing it.
602
630
 
@@ -606,60 +634,18 @@ class CommandExecutor:
606
634
  Args:
607
635
  script: The script content
608
636
  language: The script language (determines file extension and interpreter)
609
- cwd: Optional working directory (if None, uses session's current working directory)
637
+ cwd: Optional working directory
638
+ shell_type: Optional shell to use (e.g., "cmd", "powershell", "wsl", "bash")
610
639
  env: Optional environment variables
611
640
  timeout: Optional timeout in seconds
612
641
  args: Optional command-line arguments
613
642
  use_login_shell: Whether to use login shell. default true (loads ~/.zshrc, ~/.bashrc, etc.)
614
- session_id: Optional session ID for persistent working directory
615
643
 
616
644
  Returns:
617
645
  CommandResult containing execution results
618
646
  """
619
- # Use session working directory if no cwd specified and session_id provided
620
- effective_cwd = cwd
621
- if session_id and not cwd:
622
- effective_cwd = self.get_working_dir(session_id)
623
- self._log(f"Using session working directory: {effective_cwd}")
624
- # Language to interpreter mapping
625
- language_map: dict[str, dict[str, str]] = {
626
- "python": {
627
- "command": "python",
628
- "extension": ".py",
629
- },
630
- "javascript": {
631
- "command": "node",
632
- "extension": ".js",
633
- },
634
- "typescript": {
635
- "command": "ts-node",
636
- "extension": ".ts",
637
- },
638
- "bash": {
639
- "command": "bash",
640
- "extension": ".sh",
641
- },
642
- "fish": {
643
- "command": "fish",
644
- "extension": ".fish",
645
- },
646
- "ruby": {
647
- "command": "ruby",
648
- "extension": ".rb",
649
- },
650
- "php": {
651
- "command": "php",
652
- "extension": ".php",
653
- },
654
- "perl": {
655
- "command": "perl",
656
- "extension": ".pl",
657
- },
658
- "r": {
659
- "command": "Rscript",
660
- "extension": ".R",
661
- },
662
- }
647
+ # Get language info from the centralized language map
648
+ language_map = self._get_language_map()
663
649
 
664
650
  # Check if the language is supported
665
651
  if language not in language_map:
@@ -670,9 +656,13 @@ class CommandExecutor:
670
656
 
671
657
  # Get language info
672
658
  language_info = language_map[language]
673
- command = language_info["command"]
674
659
  extension = language_info["extension"]
675
660
 
661
+ # Get interpreter command with full path if possible
662
+ command, language_args = self._get_interpreter_path(language, shell_type)
663
+
664
+ self._log(f"Interpreter path: {command} :: {language_args}")
665
+
676
666
  # Set up environment
677
667
  command_env: dict[str, str] = os.environ.copy()
678
668
  if env:
@@ -686,48 +676,97 @@ class CommandExecutor:
686
676
  _ = temp.write(script) # Explicitly ignore the return value
687
677
 
688
678
  try:
689
- # Determine if we should use a login shell
690
- if use_login_shell:
691
- # Try to find the best available shell
692
- user_shell = self._get_preferred_shell()
693
- shell_basename = os.path.basename(user_shell)
679
+ # Normalize path for the current OS
680
+ temp_path = os.path.normpath(temp_path)
681
+ original_temp_path = temp_path
682
+
683
+ if sys.platform == "win32":
684
+ # Windows always uses shell
685
+ shell_basename, user_shell = self._get_system_shell(shell_type)
686
+
687
+ # Convert Windows path to WSL path if using WSL
688
+ if shell_basename in ["wsl", "wsl.exe"]:
689
+ match = re.match(r"([a-zA-Z]):\\(.*)", temp_path)
690
+ if match:
691
+ drive, path = match.groups()
692
+ wsl_path = f"/mnt/{drive.lower()}/{path.replace('\\', '/')}"
693
+ else:
694
+ wsl_path = temp_path.replace("\\", "/")
695
+ self._log(f"WSL path conversion may be incomplete: {wsl_path}")
694
696
 
695
- self._log(f"Using login shell for script execution: {user_shell}")
697
+ self._log(
698
+ f"Converted Windows path '{temp_path}' to WSL path '{wsl_path}'"
699
+ )
700
+ temp_path = wsl_path
696
701
 
697
702
  # Build the command including args
698
703
  cmd = f"{command} {temp_path}"
704
+ if language_args:
705
+ cmd = f"{command} {' '.join(language_args)} {temp_path}"
699
706
  if args:
700
707
  cmd += " " + " ".join(args)
701
708
 
702
- # Create command that runs script through login shell
703
- shell_cmd = f"{user_shell} -l -c '{cmd}'"
709
+ # Format command using helper method
710
+ shell_cmd = self._format_win32_shell_command(
711
+ shell_basename, user_shell, cmd, use_login_shell
712
+ )
704
713
 
705
- self._log(f"Executing script from file with login shell: {shell_cmd}")
714
+ self._log(
715
+ f"Executing script from file on Windows with shell: {shell_cmd}"
716
+ )
706
717
 
707
718
  # Create and run the process with shell
708
719
  process = await asyncio.create_subprocess_shell(
709
720
  shell_cmd,
710
721
  stdout=asyncio.subprocess.PIPE,
711
722
  stderr=asyncio.subprocess.PIPE,
712
- cwd=effective_cwd,
723
+ cwd=cwd,
713
724
  env=command_env,
714
725
  )
715
726
  else:
716
- # Build command arguments
717
- cmd_args = [command, temp_path]
718
- if args:
719
- cmd_args.extend(args)
727
+ # Unix systems - original logic
728
+ if use_login_shell:
729
+ # Get the user's login shell
730
+ shell_basename, user_shell = self._get_system_shell(shell_type)
720
731
 
721
- self._log(f"Executing script from file with: {' '.join(cmd_args)}")
732
+ # Build the command including args
733
+ cmd = f"{command} {temp_path}"
734
+ if language_args:
735
+ cmd = f"{command} {' '.join(language_args)} {temp_path}"
736
+ if args:
737
+ cmd += " " + " ".join(args)
722
738
 
723
- # Create and run the process normally
724
- process = await asyncio.create_subprocess_exec(
725
- *cmd_args,
726
- stdout=asyncio.subprocess.PIPE,
727
- stderr=asyncio.subprocess.PIPE,
728
- cwd=effective_cwd,
729
- env=command_env,
730
- )
739
+ # Create command that runs script through login shell
740
+ shell_cmd = f"{user_shell} -l -c '{cmd}'"
741
+
742
+ self._log(
743
+ f"Executing script from file with login shell: {shell_cmd}"
744
+ )
745
+
746
+ # Create and run the process with shell
747
+ process = await asyncio.create_subprocess_shell(
748
+ shell_cmd,
749
+ stdout=asyncio.subprocess.PIPE,
750
+ stderr=asyncio.subprocess.PIPE,
751
+ cwd=cwd,
752
+ env=command_env,
753
+ )
754
+ else:
755
+ # Build command arguments
756
+ cmd_args = [command] + language_args + [temp_path]
757
+ if args:
758
+ cmd_args.extend(args)
759
+
760
+ self._log(f"Executing script from file with: {' '.join(cmd_args)}")
761
+
762
+ # Create and run the process normally
763
+ process = await asyncio.create_subprocess_exec(
764
+ *cmd_args,
765
+ stdout=asyncio.subprocess.PIPE,
766
+ stderr=asyncio.subprocess.PIPE,
767
+ cwd=cwd,
768
+ env=command_env,
769
+ )
731
770
 
732
771
  # Wait for the process to complete with timeout
733
772
  try:
@@ -759,127 +798,139 @@ class CommandExecutor:
759
798
  finally:
760
799
  # Clean up temporary file
761
800
  try:
762
- os.unlink(temp_path)
801
+ os.unlink(original_temp_path)
763
802
  except Exception as e:
764
803
  self._log(f"Error cleaning up temporary file: {str(e)}")
765
804
 
766
- def get_available_languages(self) -> list[str]:
767
- """Get a list of available script languages.
805
+ def _get_language_map(self) -> dict[str, dict[str, str | list[str]]]:
806
+ """Get the mapping of languages to interpreter information.
807
+
808
+ This is a single source of truth for language mappings used by
809
+ both execute_script_from_file and get_available_languages.
768
810
 
769
811
  Returns:
770
- List of supported language names
812
+ Dictionary mapping language names to interpreter information
771
813
  """
772
- # Use the same language map as in execute_script_from_file method
773
- language_map = {
774
- "python": {"command": "python", "extension": ".py"},
775
- "javascript": {"command": "node", "extension": ".js"},
776
- "typescript": {"command": "ts-node", "extension": ".ts"},
777
- "bash": {"command": "bash", "extension": ".sh"},
778
- "fish": {"command": "fish", "extension": ".fish"},
779
- "ruby": {"command": "ruby", "extension": ".rb"},
780
- "php": {"command": "php", "extension": ".php"},
781
- "perl": {"command": "perl", "extension": ".pl"},
782
- "r": {"command": "Rscript", "extension": ".R"},
814
+ return {
815
+ "python": {
816
+ "command": "python",
817
+ "extension": ".py",
818
+ "alternatives": ["python3"], # Alternative command names to try
819
+ },
820
+ "javascript": {
821
+ "command": "node",
822
+ "extension": ".js",
823
+ "alternatives": ["nodejs"],
824
+ },
825
+ "typescript": {
826
+ "command": "ts-node",
827
+ "extension": ".ts",
828
+ },
829
+ "bash": {
830
+ "command": "bash",
831
+ "extension": ".sh",
832
+ },
833
+ "fish": {
834
+ "command": "fish",
835
+ "extension": ".fish",
836
+ },
837
+ "ruby": {
838
+ "command": "ruby",
839
+ "extension": ".rb",
840
+ },
841
+ "php": {
842
+ "command": "php",
843
+ "extension": ".php",
844
+ },
845
+ "perl": {
846
+ "command": "perl",
847
+ "extension": ".pl",
848
+ },
849
+ "r": {"command": "Rscript", "extension": ".R", "alternatives": ["R"]},
850
+ # Windows-specific languages
851
+ "batch": {
852
+ "command": "cmd.exe",
853
+ "extension": ".bat",
854
+ "args": ["/c"],
855
+ },
856
+ "powershell": {
857
+ "command": "powershell.exe",
858
+ "extension": ".ps1",
859
+ "args": ["-ExecutionPolicy", "Bypass", "-File"],
860
+ "alternatives": ["pwsh.exe", "pwsh"],
861
+ },
783
862
  }
784
- return list(language_map.keys())
785
863
 
786
- # Legacy method to keep backwards compatibility with tests
787
- def register_tools(self, mcp_server: FastMCP) -> None:
788
- """Register command execution tools with the MCP server.
789
-
790
- Legacy method for backwards compatibility with existing tests.
791
- New code should use the modular tool classes instead.
864
+ def _get_interpreter_path(
865
+ self, language: str, shell_type: str | None = None
866
+ ) -> tuple[str, list[str]]:
867
+ """Get the full path to the interpreter for the given language.
868
+
869
+ Attempts to find the full path to the interpreter command, but only for
870
+ Windows shell types (cmd, powershell). For WSL, just returns the command name.
792
871
 
793
872
  Args:
794
- mcp_server: The FastMCP server instance
873
+ language: The language name (e.g., "python", "javascript")
874
+ shell_type: Optional shell type (e.g., "wsl", "cmd", "powershell")
875
+
876
+ Returns:
877
+ Tuple of (interpreter_command, args) where:
878
+ - interpreter_command is either the full path to the interpreter or the command name
879
+ - args is a list of additional arguments to pass to the interpreter
795
880
  """
796
- # Run Command Tool - keep original method names for test compatibility
797
- @mcp_server.tool()
798
- async def run_command(
799
- command: str,
800
- cwd: str,
801
- ctx: MCPContext,
802
- use_login_shell: bool = True,
803
- ) -> str:
804
- tool_ctx = create_tool_context(ctx)
805
- tool_ctx.set_tool_info("run_command")
806
- await tool_ctx.info(f"Executing command: {command}")
807
-
808
- # Use request_id as session_id for persistent working directory
809
- session_id = ctx.request_id
810
-
811
- # Run validations and execute
812
- result = await self.execute_command(
813
- command,
814
- cwd,
815
- timeout=30.0,
816
- use_login_shell=use_login_shell,
817
- session_id=session_id
818
- )
819
-
820
- if result.is_success:
821
- return result.stdout
822
- else:
823
- return result.format_output()
824
-
825
- # Run Script Tool
826
- @mcp_server.tool()
827
- async def run_script(
828
- script: str,
829
- cwd: str,
830
- ctx: MCPContext,
831
- interpreter: str = "bash",
832
- use_login_shell: bool = True,
833
- ) -> str:
834
- tool_ctx = create_tool_context(ctx)
835
- tool_ctx.set_tool_info("run_script")
836
-
837
- # Use request_id as session_id for persistent working directory
838
- session_id = ctx.request_id
839
-
840
- # Execute the script
841
- result = await self.execute_script(
842
- script=script,
843
- interpreter=interpreter,
844
- cwd=cwd,
845
- timeout=30.0,
846
- use_login_shell=use_login_shell,
847
- session_id=session_id,
848
- )
849
-
850
- if result.is_success:
851
- return result.stdout
852
- else:
853
- return result.format_output()
854
-
855
- # Script tool for executing scripts in various languages
856
- @mcp_server.tool()
857
- async def script_tool(
858
- language: str,
859
- script: str,
860
- cwd: str,
861
- ctx: MCPContext,
862
- args: list[str] | None = None,
863
- use_login_shell: bool = True,
864
- ) -> str:
865
- tool_ctx = create_tool_context(ctx)
866
- tool_ctx.set_tool_info("script_tool")
867
-
868
- # Use request_id as session_id for persistent working directory
869
- session_id = ctx.request_id
870
-
871
- # Execute the script
872
- result = await self.execute_script_from_file(
873
- script=script,
874
- language=language,
875
- cwd=cwd,
876
- timeout=30.0,
877
- args=args,
878
- use_login_shell=use_login_shell,
879
- session_id=session_id
880
- )
881
-
882
- if result.is_success:
883
- return result.stdout
884
- else:
885
- return result.format_output()
881
+ language_map = self._get_language_map()
882
+
883
+ if language not in language_map:
884
+ # Return the language name itself as a fallback
885
+ return language, []
886
+
887
+ language_info = language_map[language]
888
+ command = language_info["command"]
889
+ args = language_info.get("args", [])
890
+ alternatives = language_info.get("alternatives", [])
891
+
892
+ # Special handling for WSL - use command name directly (not Windows paths)
893
+ if shell_type and shell_type.lower() in ["wsl", "wsl.exe"]:
894
+ # For Python specifically, use python3 in WSL environments
895
+ if language.lower() == "python":
896
+ return "python3", args
897
+ # For other languages, just use the command name
898
+ return command, args
899
+
900
+ # For Windows shell types, try to find the full path
901
+ if sys.platform == "win32" and (
902
+ not shell_type
903
+ or shell_type.lower() in ["cmd", "powershell", "cmd.exe", "powershell.exe"]
904
+ ):
905
+ try:
906
+ # Try to find the full path to the command
907
+ full_path = shutil.which(command)
908
+ if full_path:
909
+ self._log(
910
+ f"Found full path for {language} interpreter: {full_path}"
911
+ )
912
+ return full_path, args
913
+
914
+ # If primary command not found, try alternatives
915
+ for alt_command in alternatives:
916
+ alt_path = shutil.which(alt_command)
917
+ if alt_path:
918
+ self._log(
919
+ f"Found alternative path for {language} interpreter: {alt_path}"
920
+ )
921
+ return alt_path, args
922
+ except Exception as e:
923
+ self._log(f"Error finding path for {language} interpreter: {str(e)}")
924
+
925
+ # If we can't find the full path or it's not appropriate, return the command name
926
+ self._log(f"Using command name for {language} interpreter: {command}")
927
+ return command, args
928
+
929
+ def get_available_languages(self) -> list[str]:
930
+ """Get a list of available script languages.
931
+
932
+ Returns:
933
+ List of supported language names
934
+ """
935
+ # Use the centralized language map
936
+ return list(self._get_language_map().keys())