hanzo-mcp 0.1.25__py3-none-any.whl → 0.1.30__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hanzo-mcp might be problematic. Click here for more details.
- hanzo_mcp/__init__.py +2 -2
- hanzo_mcp/cli.py +80 -9
- hanzo_mcp/server.py +41 -10
- hanzo_mcp/tools/__init__.py +51 -32
- hanzo_mcp/tools/agent/__init__.py +59 -0
- hanzo_mcp/tools/agent/agent_tool.py +474 -0
- hanzo_mcp/tools/agent/prompt.py +137 -0
- hanzo_mcp/tools/agent/tool_adapter.py +75 -0
- hanzo_mcp/tools/common/__init__.py +17 -0
- hanzo_mcp/tools/common/base.py +216 -0
- hanzo_mcp/tools/common/context.py +7 -3
- hanzo_mcp/tools/common/permissions.py +63 -119
- hanzo_mcp/tools/common/session.py +91 -0
- hanzo_mcp/tools/common/thinking_tool.py +123 -0
- hanzo_mcp/tools/filesystem/__init__.py +85 -5
- hanzo_mcp/tools/filesystem/base.py +113 -0
- hanzo_mcp/tools/filesystem/content_replace.py +287 -0
- hanzo_mcp/tools/filesystem/directory_tree.py +286 -0
- hanzo_mcp/tools/filesystem/edit_file.py +287 -0
- hanzo_mcp/tools/filesystem/get_file_info.py +170 -0
- hanzo_mcp/tools/filesystem/read_files.py +198 -0
- hanzo_mcp/tools/filesystem/search_content.py +275 -0
- hanzo_mcp/tools/filesystem/write_file.py +162 -0
- hanzo_mcp/tools/jupyter/__init__.py +67 -4
- hanzo_mcp/tools/jupyter/base.py +284 -0
- hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
- hanzo_mcp/tools/jupyter/notebook_operations.py +72 -112
- hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
- hanzo_mcp/tools/project/__init__.py +64 -1
- hanzo_mcp/tools/project/analysis.py +9 -6
- hanzo_mcp/tools/project/base.py +66 -0
- hanzo_mcp/tools/project/project_analyze.py +173 -0
- hanzo_mcp/tools/shell/__init__.py +58 -1
- hanzo_mcp/tools/shell/base.py +148 -0
- hanzo_mcp/tools/shell/command_executor.py +203 -322
- hanzo_mcp/tools/shell/run_command.py +204 -0
- hanzo_mcp/tools/shell/run_script.py +215 -0
- hanzo_mcp/tools/shell/script_tool.py +244 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/METADATA +72 -77
- hanzo_mcp-0.1.30.dist-info/RECORD +45 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/licenses/LICENSE +2 -2
- hanzo_mcp/tools/common/thinking.py +0 -65
- hanzo_mcp/tools/filesystem/file_operations.py +0 -1050
- hanzo_mcp-0.1.25.dist-info/RECORD +0 -24
- hanzo_mcp-0.1.25.dist-info/zip-safe +0 -1
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/top_level.txt +0 -0
|
@@ -11,77 +11,16 @@ import shlex
|
|
|
11
11
|
import sys
|
|
12
12
|
import tempfile
|
|
13
13
|
from collections.abc import Awaitable, Callable
|
|
14
|
-
from
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, Optional, final
|
|
15
16
|
|
|
16
17
|
from mcp.server.fastmcp import Context as MCPContext
|
|
17
18
|
from mcp.server.fastmcp import FastMCP
|
|
18
19
|
|
|
19
20
|
from hanzo_mcp.tools.common.context import create_tool_context
|
|
20
21
|
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)
|
|
22
|
+
from hanzo_mcp.tools.common.session import SessionManager
|
|
23
|
+
from hanzo_mcp.tools.shell.base import CommandResult
|
|
85
24
|
|
|
86
25
|
|
|
87
26
|
@final
|
|
@@ -104,14 +43,17 @@ class CommandExecutor:
|
|
|
104
43
|
self.permission_manager: PermissionManager = permission_manager
|
|
105
44
|
self.verbose: bool = verbose
|
|
106
45
|
|
|
46
|
+
# Session management (initialized on first use with session ID)
|
|
47
|
+
self.session_manager: Optional[SessionManager] = None
|
|
48
|
+
|
|
107
49
|
# Excluded commands or patterns
|
|
108
50
|
self.excluded_commands: list[str] = ["rm"]
|
|
109
51
|
|
|
110
52
|
# Map of supported interpreters with special handling
|
|
111
|
-
self.special_interpreters:
|
|
53
|
+
self.special_interpreters: Dict[
|
|
112
54
|
str,
|
|
113
55
|
Callable[
|
|
114
|
-
[str, str, str
|
|
56
|
+
[str, str, str], dict[str, str]], Optional[float | None | None,
|
|
115
57
|
Awaitable[CommandResult],
|
|
116
58
|
],
|
|
117
59
|
] = {
|
|
@@ -136,7 +78,78 @@ class CommandExecutor:
|
|
|
136
78
|
if command not in self.excluded_commands:
|
|
137
79
|
self.excluded_commands.append(command)
|
|
138
80
|
|
|
139
|
-
def
|
|
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
|
+
Returns:
|
|
92
|
+
Path to the preferred shell
|
|
93
|
+
"""
|
|
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.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
session_id: The session ID
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The session manager instance
|
|
126
|
+
"""
|
|
127
|
+
return SessionManager.get_instance(session_id)
|
|
128
|
+
|
|
129
|
+
def set_working_dir(self, session_id: str, path: str) -> None:
|
|
130
|
+
"""Set the current working directory for the session.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
session_id: The session ID
|
|
134
|
+
path: The path to set as the current working directory
|
|
135
|
+
"""
|
|
136
|
+
session = self.get_session_manager(session_id)
|
|
137
|
+
expanded_path = os.path.expanduser(path)
|
|
138
|
+
session.set_working_dir(Path(expanded_path))
|
|
139
|
+
|
|
140
|
+
def get_working_dir(self, session_id: str) -> str:
|
|
141
|
+
"""Get the current working directory for the session.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
session_id: The session ID
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The current working directory
|
|
148
|
+
"""
|
|
149
|
+
session = self.get_session_manager(session_id)
|
|
150
|
+
return str(session.current_working_dir)
|
|
151
|
+
|
|
152
|
+
def _log(self, message: str, data: object | None = None) -> None:
|
|
140
153
|
"""Log a message if verbose logging is enabled.
|
|
141
154
|
|
|
142
155
|
Args:
|
|
@@ -193,17 +206,19 @@ class CommandExecutor:
|
|
|
193
206
|
command: str,
|
|
194
207
|
cwd: str | None = None,
|
|
195
208
|
env: dict[str, str] | None = None,
|
|
196
|
-
timeout: float | None =
|
|
209
|
+
timeout: float | None = 60.0,
|
|
197
210
|
use_login_shell: bool = True,
|
|
211
|
+
session_id: str | None = None,
|
|
198
212
|
) -> CommandResult:
|
|
199
213
|
"""Execute a shell command with safety checks.
|
|
200
214
|
|
|
201
215
|
Args:
|
|
202
216
|
command: The command to execute
|
|
203
|
-
cwd: Optional working directory
|
|
217
|
+
cwd: Optional working directory (if None, uses session's current working directory)
|
|
204
218
|
env: Optional environment variables
|
|
205
219
|
timeout: Optional timeout in seconds
|
|
206
220
|
use_login_shell: Whether to use login shell. default true (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
221
|
+
session_id: Optional session ID for persistent working directory
|
|
207
222
|
|
|
208
223
|
Returns:
|
|
209
224
|
CommandResult containing execution results
|
|
@@ -216,17 +231,50 @@ class CommandExecutor:
|
|
|
216
231
|
return_code=1, error_message=f"Command not allowed: {command}"
|
|
217
232
|
)
|
|
218
233
|
|
|
234
|
+
# Use session working directory if no cwd specified and session_id provided
|
|
235
|
+
effective_cwd = cwd
|
|
236
|
+
if session_id and not cwd:
|
|
237
|
+
effective_cwd = self.get_working_dir(session_id)
|
|
238
|
+
self._log(f"Using session working directory: {effective_cwd}")
|
|
239
|
+
|
|
240
|
+
# Check if it's a cd command and update session working directory
|
|
241
|
+
is_cd_command = False
|
|
242
|
+
if session_id and command.strip().startswith("cd "):
|
|
243
|
+
is_cd_command = True
|
|
244
|
+
args = shlex.split(command)
|
|
245
|
+
if len(args) > 1:
|
|
246
|
+
target_dir = args[1]
|
|
247
|
+
# Handle relative paths
|
|
248
|
+
if not os.path.isabs(target_dir):
|
|
249
|
+
session_cwd = self.get_working_dir(session_id)
|
|
250
|
+
target_dir = os.path.join(session_cwd, target_dir)
|
|
251
|
+
|
|
252
|
+
# Expand user paths
|
|
253
|
+
target_dir = os.path.expanduser(target_dir)
|
|
254
|
+
|
|
255
|
+
# Normalize path
|
|
256
|
+
target_dir = os.path.normpath(target_dir)
|
|
257
|
+
|
|
258
|
+
if os.path.isdir(target_dir):
|
|
259
|
+
self.set_working_dir(session_id, target_dir)
|
|
260
|
+
self._log(f"Updated session working directory: {target_dir}")
|
|
261
|
+
return CommandResult(return_code=0, stdout="")
|
|
262
|
+
else:
|
|
263
|
+
return CommandResult(
|
|
264
|
+
return_code=1, error_message=f"Directory does not exist: {target_dir}"
|
|
265
|
+
)
|
|
266
|
+
|
|
219
267
|
# Check working directory permissions if specified
|
|
220
|
-
if
|
|
221
|
-
if not os.path.isdir(
|
|
268
|
+
if effective_cwd:
|
|
269
|
+
if not os.path.isdir(effective_cwd):
|
|
222
270
|
return CommandResult(
|
|
223
271
|
return_code=1,
|
|
224
|
-
error_message=f"Working directory does not exist: {
|
|
272
|
+
error_message=f"Working directory does not exist: {effective_cwd}",
|
|
225
273
|
)
|
|
226
274
|
|
|
227
|
-
if not self.permission_manager.is_path_allowed(
|
|
275
|
+
if not self.permission_manager.is_path_allowed(effective_cwd):
|
|
228
276
|
return CommandResult(
|
|
229
|
-
return_code=1, error_message=f"Working directory not allowed: {
|
|
277
|
+
return_code=1, error_message=f"Working directory not allowed: {effective_cwd}"
|
|
230
278
|
)
|
|
231
279
|
|
|
232
280
|
# Set up environment
|
|
@@ -244,8 +292,8 @@ class CommandExecutor:
|
|
|
244
292
|
shell_cmd = command
|
|
245
293
|
|
|
246
294
|
if use_login_shell:
|
|
247
|
-
#
|
|
248
|
-
user_shell =
|
|
295
|
+
# Try to find the best available shell
|
|
296
|
+
user_shell = self._get_preferred_shell()
|
|
249
297
|
shell_basename = os.path.basename(user_shell)
|
|
250
298
|
|
|
251
299
|
self._log(f"Using login shell: {user_shell}")
|
|
@@ -270,7 +318,7 @@ class CommandExecutor:
|
|
|
270
318
|
shell_cmd,
|
|
271
319
|
stdout=asyncio.subprocess.PIPE,
|
|
272
320
|
stderr=asyncio.subprocess.PIPE,
|
|
273
|
-
cwd=
|
|
321
|
+
cwd=effective_cwd,
|
|
274
322
|
env=command_env,
|
|
275
323
|
)
|
|
276
324
|
else:
|
|
@@ -282,7 +330,7 @@ class CommandExecutor:
|
|
|
282
330
|
*args,
|
|
283
331
|
stdout=asyncio.subprocess.PIPE,
|
|
284
332
|
stderr=asyncio.subprocess.PIPE,
|
|
285
|
-
cwd=
|
|
333
|
+
cwd=effective_cwd,
|
|
286
334
|
env=command_env,
|
|
287
335
|
)
|
|
288
336
|
|
|
@@ -320,34 +368,43 @@ class CommandExecutor:
|
|
|
320
368
|
interpreter: str = "bash",
|
|
321
369
|
cwd: str | None = None,
|
|
322
370
|
env: dict[str, str] | None = None,
|
|
323
|
-
timeout: float | None =
|
|
371
|
+
timeout: float | None = 60.0,
|
|
324
372
|
use_login_shell: bool = True,
|
|
373
|
+
session_id: str | None = None,
|
|
325
374
|
) -> CommandResult:
|
|
326
375
|
"""Execute a script with the specified interpreter.
|
|
327
376
|
|
|
328
377
|
Args:
|
|
329
378
|
script: The script content to execute
|
|
330
379
|
interpreter: The interpreter to use (bash, python, etc.)
|
|
331
|
-
cwd: Optional working directory
|
|
380
|
+
cwd: Optional working directory (if None, uses session's current working directory)
|
|
332
381
|
env: Optional environment variables
|
|
333
382
|
timeout: Optional timeout in seconds
|
|
383
|
+
use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
384
|
+
session_id: Optional session ID for persistent working directory
|
|
334
385
|
|
|
335
386
|
Returns:
|
|
336
387
|
CommandResult containing execution results
|
|
337
388
|
"""
|
|
338
389
|
self._log(f"Executing script with interpreter: {interpreter}")
|
|
339
390
|
|
|
391
|
+
# Use session working directory if no cwd specified and session_id provided
|
|
392
|
+
effective_cwd = cwd
|
|
393
|
+
if session_id and not cwd:
|
|
394
|
+
effective_cwd = self.get_working_dir(session_id)
|
|
395
|
+
self._log(f"Using session working directory: {effective_cwd}")
|
|
396
|
+
|
|
340
397
|
# Check working directory permissions if specified
|
|
341
|
-
if
|
|
342
|
-
if not os.path.isdir(
|
|
398
|
+
if effective_cwd:
|
|
399
|
+
if not os.path.isdir(effective_cwd):
|
|
343
400
|
return CommandResult(
|
|
344
401
|
return_code=1,
|
|
345
|
-
error_message=f"Working directory does not exist: {
|
|
402
|
+
error_message=f"Working directory does not exist: {effective_cwd}",
|
|
346
403
|
)
|
|
347
404
|
|
|
348
|
-
if not self.permission_manager.is_path_allowed(
|
|
405
|
+
if not self.permission_manager.is_path_allowed(effective_cwd):
|
|
349
406
|
return CommandResult(
|
|
350
|
-
return_code=1, error_message=f"Working directory not allowed: {
|
|
407
|
+
return_code=1, error_message=f"Working directory not allowed: {effective_cwd}"
|
|
351
408
|
)
|
|
352
409
|
|
|
353
410
|
# Check if we need special handling for this interpreter
|
|
@@ -355,11 +412,11 @@ class CommandExecutor:
|
|
|
355
412
|
if interpreter_name in self.special_interpreters:
|
|
356
413
|
self._log(f"Using special handler for interpreter: {interpreter_name}")
|
|
357
414
|
special_handler = self.special_interpreters[interpreter_name]
|
|
358
|
-
return await special_handler(interpreter, script,
|
|
415
|
+
return await special_handler(interpreter, script, effective_cwd, env, timeout)
|
|
359
416
|
|
|
360
417
|
# Regular execution
|
|
361
418
|
return await self._execute_script_with_stdin(
|
|
362
|
-
interpreter, script,
|
|
419
|
+
interpreter, script, effective_cwd, env, timeout, use_login_shell
|
|
363
420
|
)
|
|
364
421
|
|
|
365
422
|
async def _execute_script_with_stdin(
|
|
@@ -368,7 +425,7 @@ class CommandExecutor:
|
|
|
368
425
|
script: str,
|
|
369
426
|
cwd: str | None = None,
|
|
370
427
|
env: dict[str, str] | None = None,
|
|
371
|
-
timeout: float | None =
|
|
428
|
+
timeout: float | None = 60.0,
|
|
372
429
|
use_login_shell: bool = True,
|
|
373
430
|
) -> CommandResult:
|
|
374
431
|
"""Execute a script by passing it to stdin of the interpreter.
|
|
@@ -379,6 +436,7 @@ class CommandExecutor:
|
|
|
379
436
|
cwd: Optional working directory
|
|
380
437
|
env: Optional environment variables
|
|
381
438
|
timeout: Optional timeout in seconds
|
|
439
|
+
use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
382
440
|
|
|
383
441
|
Returns:
|
|
384
442
|
CommandResult containing execution results
|
|
@@ -391,9 +449,9 @@ class CommandExecutor:
|
|
|
391
449
|
try:
|
|
392
450
|
# Determine if we should use a login shell
|
|
393
451
|
if use_login_shell:
|
|
394
|
-
#
|
|
395
|
-
user_shell =
|
|
396
|
-
os.path.basename(user_shell)
|
|
452
|
+
# Try to find the best available shell
|
|
453
|
+
user_shell = self._get_preferred_shell()
|
|
454
|
+
shell_basename = os.path.basename(user_shell)
|
|
397
455
|
|
|
398
456
|
self._log(f"Using login shell for interpreter: {user_shell}")
|
|
399
457
|
|
|
@@ -403,10 +461,9 @@ class CommandExecutor:
|
|
|
403
461
|
# Create and run the process with shell
|
|
404
462
|
process = await asyncio.create_subprocess_shell(
|
|
405
463
|
shell_cmd,
|
|
406
|
-
stdin=asyncio.subprocess.PIPE,
|
|
407
464
|
stdout=asyncio.subprocess.PIPE,
|
|
408
465
|
stderr=asyncio.subprocess.PIPE,
|
|
409
|
-
cwd=
|
|
466
|
+
cwd=effective_cwd,
|
|
410
467
|
env=command_env,
|
|
411
468
|
)
|
|
412
469
|
else:
|
|
@@ -419,7 +476,7 @@ class CommandExecutor:
|
|
|
419
476
|
stdin=asyncio.subprocess.PIPE,
|
|
420
477
|
stdout=asyncio.subprocess.PIPE,
|
|
421
478
|
stderr=asyncio.subprocess.PIPE,
|
|
422
|
-
cwd=
|
|
479
|
+
cwd=effective_cwd,
|
|
423
480
|
env=command_env,
|
|
424
481
|
)
|
|
425
482
|
|
|
@@ -458,7 +515,7 @@ class CommandExecutor:
|
|
|
458
515
|
script: str,
|
|
459
516
|
cwd: str | None = None,
|
|
460
517
|
env: dict[str, str] | None = None,
|
|
461
|
-
timeout: float | None =
|
|
518
|
+
timeout: float | None = 60.0,
|
|
462
519
|
) -> CommandResult:
|
|
463
520
|
"""Special handler for Fish shell scripts.
|
|
464
521
|
|
|
@@ -533,9 +590,10 @@ class CommandExecutor:
|
|
|
533
590
|
language: str,
|
|
534
591
|
cwd: str | None = None,
|
|
535
592
|
env: dict[str, str] | None = None,
|
|
536
|
-
timeout: float | None =
|
|
593
|
+
timeout: float | None = 60.0,
|
|
537
594
|
args: list[str] | None = None,
|
|
538
595
|
use_login_shell: bool = True,
|
|
596
|
+
session_id: str | None = None,
|
|
539
597
|
) -> CommandResult:
|
|
540
598
|
"""Execute a script by writing it to a temporary file and executing it.
|
|
541
599
|
|
|
@@ -545,16 +603,21 @@ class CommandExecutor:
|
|
|
545
603
|
Args:
|
|
546
604
|
script: The script content
|
|
547
605
|
language: The script language (determines file extension and interpreter)
|
|
548
|
-
cwd: Optional working directory
|
|
606
|
+
cwd: Optional working directory (if None, uses session's current working directory)
|
|
549
607
|
env: Optional environment variables
|
|
550
608
|
timeout: Optional timeout in seconds
|
|
551
609
|
args: Optional command-line arguments
|
|
552
610
|
use_login_shell: Whether to use login shell. default true (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
553
|
-
|
|
611
|
+
session_id: Optional session ID for persistent working directory
|
|
554
612
|
|
|
555
613
|
Returns:
|
|
556
614
|
CommandResult containing execution results
|
|
557
615
|
"""
|
|
616
|
+
# Use session working directory if no cwd specified and session_id provided
|
|
617
|
+
effective_cwd = cwd
|
|
618
|
+
if session_id and not cwd:
|
|
619
|
+
effective_cwd = self.get_working_dir(session_id)
|
|
620
|
+
self._log(f"Using session working directory: {effective_cwd}")
|
|
558
621
|
# Language to interpreter mapping
|
|
559
622
|
language_map: dict[str, dict[str, str]] = {
|
|
560
623
|
"python": {
|
|
@@ -622,9 +685,9 @@ class CommandExecutor:
|
|
|
622
685
|
try:
|
|
623
686
|
# Determine if we should use a login shell
|
|
624
687
|
if use_login_shell:
|
|
625
|
-
#
|
|
626
|
-
user_shell =
|
|
627
|
-
os.path.basename(user_shell)
|
|
688
|
+
# Try to find the best available shell
|
|
689
|
+
user_shell = self._get_preferred_shell()
|
|
690
|
+
shell_basename = os.path.basename(user_shell)
|
|
628
691
|
|
|
629
692
|
self._log(f"Using login shell for script execution: {user_shell}")
|
|
630
693
|
|
|
@@ -643,7 +706,7 @@ class CommandExecutor:
|
|
|
643
706
|
shell_cmd,
|
|
644
707
|
stdout=asyncio.subprocess.PIPE,
|
|
645
708
|
stderr=asyncio.subprocess.PIPE,
|
|
646
|
-
cwd=
|
|
709
|
+
cwd=effective_cwd,
|
|
647
710
|
env=command_env,
|
|
648
711
|
)
|
|
649
712
|
else:
|
|
@@ -659,7 +722,7 @@ class CommandExecutor:
|
|
|
659
722
|
*cmd_args,
|
|
660
723
|
stdout=asyncio.subprocess.PIPE,
|
|
661
724
|
stderr=asyncio.subprocess.PIPE,
|
|
662
|
-
cwd=
|
|
725
|
+
cwd=effective_cwd,
|
|
663
726
|
env=command_env,
|
|
664
727
|
)
|
|
665
728
|
|
|
@@ -717,14 +780,17 @@ class CommandExecutor:
|
|
|
717
780
|
}
|
|
718
781
|
return list(language_map.keys())
|
|
719
782
|
|
|
783
|
+
# Legacy method to keep backwards compatibility with tests
|
|
720
784
|
def register_tools(self, mcp_server: FastMCP) -> None:
|
|
721
785
|
"""Register command execution tools with the MCP server.
|
|
786
|
+
|
|
787
|
+
Legacy method for backwards compatibility with existing tests.
|
|
788
|
+
New code should use the modular tool classes instead.
|
|
722
789
|
|
|
723
790
|
Args:
|
|
724
791
|
mcp_server: The FastMCP server instance
|
|
725
792
|
"""
|
|
726
|
-
|
|
727
|
-
# Run Command Tool
|
|
793
|
+
# Run Command Tool - keep original method names for test compatibility
|
|
728
794
|
@mcp_server.tool()
|
|
729
795
|
async def run_command(
|
|
730
796
|
command: str,
|
|
@@ -732,68 +798,27 @@ class CommandExecutor:
|
|
|
732
798
|
ctx: MCPContext,
|
|
733
799
|
use_login_shell: bool = True,
|
|
734
800
|
) -> 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
801
|
tool_ctx = create_tool_context(ctx)
|
|
747
802
|
tool_ctx.set_tool_info("run_command")
|
|
748
803
|
await tool_ctx.info(f"Executing command: {command}")
|
|
749
804
|
|
|
750
|
-
#
|
|
751
|
-
|
|
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=600.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
|
-
)
|
|
805
|
+
# Use request_id as session_id for persistent working directory
|
|
806
|
+
session_id = ctx.request_id
|
|
786
807
|
|
|
787
|
-
#
|
|
808
|
+
# Run validations and execute
|
|
809
|
+
result = await self.execute_command(
|
|
810
|
+
command,
|
|
811
|
+
cwd,
|
|
812
|
+
timeout=30.0,
|
|
813
|
+
use_login_shell=use_login_shell,
|
|
814
|
+
session_id=session_id
|
|
815
|
+
)
|
|
816
|
+
|
|
788
817
|
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
818
|
return result.stdout
|
|
793
819
|
else:
|
|
794
|
-
# For failed commands, include all available information
|
|
795
820
|
return result.format_output()
|
|
796
|
-
|
|
821
|
+
|
|
797
822
|
# Run Script Tool
|
|
798
823
|
@mcp_server.tool()
|
|
799
824
|
async def run_script(
|
|
@@ -803,94 +828,27 @@ class CommandExecutor:
|
|
|
803
828
|
interpreter: str = "bash",
|
|
804
829
|
use_login_shell: bool = True,
|
|
805
830
|
) -> 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
831
|
tool_ctx = create_tool_context(ctx)
|
|
819
832
|
tool_ctx.set_tool_info("run_script")
|
|
820
|
-
|
|
821
|
-
#
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
833
|
+
|
|
834
|
+
# Use request_id as session_id for persistent working directory
|
|
835
|
+
session_id = ctx.request_id
|
|
836
|
+
|
|
867
837
|
# Execute the script
|
|
868
|
-
result
|
|
838
|
+
result = await self.execute_script(
|
|
869
839
|
script=script,
|
|
870
840
|
interpreter=interpreter,
|
|
871
|
-
cwd=cwd,
|
|
872
|
-
timeout=
|
|
841
|
+
cwd=cwd,
|
|
842
|
+
timeout=30.0,
|
|
873
843
|
use_login_shell=use_login_shell,
|
|
844
|
+
session_id=session_id,
|
|
874
845
|
)
|
|
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
|
|
846
|
+
|
|
885
847
|
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
848
|
return result.stdout
|
|
890
849
|
else:
|
|
891
|
-
# For failed scripts, include all available information
|
|
892
850
|
return result.format_output()
|
|
893
|
-
|
|
851
|
+
|
|
894
852
|
# Script tool for executing scripts in various languages
|
|
895
853
|
@mcp_server.tool()
|
|
896
854
|
async def script_tool(
|
|
@@ -901,101 +859,24 @@ class CommandExecutor:
|
|
|
901
859
|
args: list[str] | None = None,
|
|
902
860
|
use_login_shell: bool = True,
|
|
903
861
|
) -> 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
862
|
tool_ctx = create_tool_context(ctx)
|
|
918
863
|
tool_ctx.set_tool_info("script_tool")
|
|
919
|
-
|
|
920
|
-
#
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
864
|
+
|
|
865
|
+
# Use request_id as session_id for persistent working directory
|
|
866
|
+
session_id = ctx.request_id
|
|
867
|
+
|
|
972
868
|
# Execute the script
|
|
973
869
|
result = await self.execute_script_from_file(
|
|
974
870
|
script=script,
|
|
975
871
|
language=language,
|
|
976
|
-
cwd=cwd,
|
|
977
|
-
timeout=
|
|
872
|
+
cwd=cwd,
|
|
873
|
+
timeout=30.0,
|
|
978
874
|
args=args,
|
|
979
875
|
use_login_shell=use_login_shell,
|
|
876
|
+
session_id=session_id
|
|
980
877
|
)
|
|
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
|
|
878
|
+
|
|
991
879
|
if result.is_success:
|
|
992
|
-
|
|
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()
|
|
880
|
+
return result.stdout
|
|
999
881
|
else:
|
|
1000
|
-
# For failed scripts, include all available information
|
|
1001
882
|
return result.format_output()
|