Skip to content

Commit 345cdd1

Browse files
committed
merge: Join command-validator and io-redirection-handler into shell-executor
Resolved conflicts: - Fixed error message format in IORedirectionHandler - Updated test cases to match new error handling
2 parents 4dac512 + 8a3dcf0 commit 345cdd1

7 files changed

+328
-114
lines changed

src/mcp_shell_server/io_redirection_handler.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ async def setup_redirects(
113113
handles["stdin"] = asyncio.subprocess.PIPE
114114
handles["stdin_data"] = file.read()
115115
file.close()
116-
except IOError as e:
117-
raise ValueError(f"Failed to open input file: {e}") from e
116+
except (FileNotFoundError, IOError) as e:
117+
raise ValueError("Failed to open input file") from e
118118

119119
# Handle output redirection
120120
if redirects["stdout"]:

src/mcp_shell_server/shell_executor.py

Lines changed: 108 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import logging
23
import os
34
import pwd
45
import shlex
@@ -284,8 +285,27 @@ async def execute(
284285
}
285286

286287
# Single command execution
287-
cmd, redirects = self._parse_command(cleaned_command)
288-
self.validator.validate_command(cmd)
288+
try:
289+
cmd, redirects = self._parse_command(cleaned_command)
290+
except ValueError as e:
291+
return {
292+
"error": str(e),
293+
"status": 1,
294+
"stdout": "",
295+
"stderr": str(e),
296+
"execution_time": time.time() - start_time,
297+
}
298+
299+
try:
300+
self.validator.validate_command(cmd)
301+
except ValueError as e:
302+
return {
303+
"error": str(e),
304+
"status": 1,
305+
"stdout": "",
306+
"stderr": str(e),
307+
"execution_time": time.time() - start_time,
308+
}
289309

290310
# Directory validation
291311
if directory:
@@ -305,19 +325,25 @@ async def execute(
305325
"stderr": f"Not a directory: {directory}",
306326
"execution_time": time.time() - start_time,
307327
}
308-
309-
# Clean and validate command
310-
cleaned_command = self._clean_command(command)
311328
if not cleaned_command:
312329
raise ValueError("Empty command")
313330

314-
# Process redirections
315-
cmd, redirects = self.io_handler.process_redirections(cleaned_command)
331+
try:
332+
# Process redirections
333+
cmd, redirects = self.io_handler.process_redirections(cleaned_command)
316334

317-
# Setup handles for redirection
318-
handles = await self.io_handler.setup_redirects(redirects, directory)
335+
# Setup handles for redirection
336+
handles = await self.io_handler.setup_redirects(redirects, directory)
337+
except ValueError as e:
338+
return {
339+
"error": str(e),
340+
"status": 1,
341+
"stdout": "",
342+
"stderr": str(e),
343+
"execution_time": time.time() - start_time,
344+
}
319345

320-
# Get stdin from handles if present
346+
# Get stdin from handles if present
321347
stdin = handles.get("stdin_data", stdin)
322348
stdout_handle = handles.get("stdout", asyncio.subprocess.PIPE)
323349

@@ -328,6 +354,7 @@ async def execute(
328354

329355
process = await asyncio.create_subprocess_shell(
330356
shell_cmd,
357+
stdin=asyncio.subprocess.PIPE,
331358
stdout=stdout_handle,
332359
stderr=asyncio.subprocess.PIPE,
333360
env={**os.environ, **(envs or {})}, # Merge environment variables
@@ -357,7 +384,10 @@ async def communicate_with_timeout():
357384

358385
# Close file handle if using file redirection
359386
if isinstance(stdout_handle, IO):
360-
stdout_handle.close()
387+
try:
388+
stdout_handle.close()
389+
except (IOError, OSError) as e:
390+
logging.warning(f"Error closing stdout: {e}")
361391

362392
return {
363393
"error": None,
@@ -413,78 +443,109 @@ async def _execute_pipeline(
413443
) -> Dict[str, Any]:
414444
start_time = time.time()
415445
processes: List[asyncio.subprocess.Process] = []
416-
417446
try:
447+
# Validate all commands before execution
448+
for cmd in commands:
449+
# Make sure each command is allowed
450+
self.validator.validate_command(cmd)
451+
452+
# Initialize IO variables
418453
parsed_commands = []
419454
first_stdin: Optional[bytes] = None
420455
last_stdout: Optional[IO[Any]] = None
456+
first_redirects = None
457+
last_redirects = None
458+
459+
# Process redirections for all commands
460+
for i, command in enumerate(commands):
461+
cmd, redirects = self.io_handler.process_redirections(command)
462+
parsed_commands.append(cmd)
463+
464+
if i == 0: # First command
465+
first_redirects = redirects
466+
elif i == len(commands) - 1: # Last command
467+
last_redirects = redirects
468+
469+
# Setup first and last command redirections
470+
if first_redirects:
471+
handles = await self.io_handler.setup_redirects(
472+
first_redirects, directory
473+
)
474+
if handles.get("stdin_data"):
475+
first_stdin = handles["stdin_data"].encode()
421476

422-
for cmd in commands:
423-
parsed_cmd, redirects = self.io_handler.process_redirections(cmd)
424-
parsed_commands.append(parsed_cmd)
425-
426-
if commands.index(cmd) == 0: # First command
427-
handles = await self.io_handler.setup_redirects(
428-
redirects, directory
429-
)
430-
if handles.get("stdin_data"):
431-
first_stdin = handles["stdin_data"].encode()
432-
433-
if commands.index(cmd) == len(commands) - 1: # Last command
434-
handles = await self.io_handler.setup_redirects(
435-
redirects, directory
436-
)
437-
last_stdout = handles.get("stdout")
477+
if last_redirects:
478+
handles = await self.io_handler.setup_redirects(
479+
last_redirects, directory
480+
)
481+
last_stdout = handles.get("stdout")
438482

439483
# Execute pipeline
440484
prev_stdout: Optional[bytes] = first_stdin
441485
final_stderr: bytes = b""
442486
final_stdout: bytes = b""
443487

488+
# Execute each command in the pipeline
444489
for i, cmd in enumerate(parsed_commands):
490+
# Create the shell command
445491
shell_cmd = self._create_shell_command(cmd)
446492

447-
# Get default shell for the first command and set interactive mode
493+
# Apply interactive mode to first command only
448494
if i == 0:
449495
shell = self._get_default_shell()
450496
shell_cmd = f"{shell} -i -c {shlex.quote(shell_cmd)}"
451497

498+
# Create subprocess with proper IO configuration
452499
process = await asyncio.create_subprocess_shell(
453500
shell_cmd,
454-
stdin=asyncio.subprocess.PIPE if prev_stdout is not None else None,
455-
stdout=asyncio.subprocess.PIPE,
501+
stdin=asyncio.subprocess.PIPE,
502+
stdout=(
503+
asyncio.subprocess.PIPE
504+
if i < len(parsed_commands) - 1 or not last_stdout
505+
else last_stdout
506+
),
456507
stderr=asyncio.subprocess.PIPE,
457-
env={**os.environ, **(envs or {})}, # Merge environment variables
508+
env={**os.environ, **(envs or {})},
458509
cwd=directory,
459510
)
460511

461512
try:
513+
# Execute the current command
462514
stdout, stderr = await asyncio.wait_for(
463515
process.communicate(input=prev_stdout), timeout=timeout
464516
)
465517

466-
prev_stdout = stdout if stdout else b""
467-
518+
# Collect stderr and check return code
519+
final_stderr += stderr if stderr else b""
520+
if process.returncode != 0:
521+
error_msg = stderr.decode("utf-8", errors="replace").strip()
522+
if not error_msg:
523+
error_msg = (
524+
f"Command failed with exit code {process.returncode}"
525+
)
526+
raise ValueError(error_msg)
527+
528+
# Pass output to next command or store it
468529
if i == len(parsed_commands) - 1:
469-
final_stdout = stdout if stdout else b""
530+
if last_stdout and isinstance(last_stdout, IO):
531+
last_stdout.write(stdout.decode("utf-8", errors="replace"))
532+
final_output = ""
533+
else:
534+
final_stdout = stdout if stdout else b""
535+
final_output = final_stdout.decode(
536+
"utf-8", errors="replace"
537+
)
538+
else:
539+
prev_stdout = stdout if stdout else b""
470540

471-
final_stderr += stderr if stderr else b""
472541
processes.append(process)
473542

474-
if process.returncode != 0:
475-
raise ValueError(
476-
f"Command failed with exit code {process.returncode}"
477-
)
478-
479543
except asyncio.TimeoutError:
480544
process.kill()
481545
raise
482-
483-
if last_stdout:
484-
last_stdout.write(final_stdout.decode("utf-8", errors="replace"))
485-
final_output = ""
486-
else:
487-
final_output = final_stdout.decode("utf-8", errors="replace")
546+
except Exception:
547+
process.kill()
548+
raise
488549

489550
return {
490551
"error": None,

tests/test_io_redirection_handler.py

Whitespace-only changes.

tests/test_shell_executor.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ async def test_redirection_error_cases(executor, temp_test_dir, monkeypatch):
361361
result = await executor.execute(
362362
["cat", "<", "nonexistent.txt"], directory=temp_test_dir
363363
)
364-
assert "No such file or directory" in result["error"]
364+
assert result["error"] == "Failed to open input file"
365365

366366
# Invalid redirection operator
367367
result = await executor.execute(
@@ -420,16 +420,20 @@ async def test_complex_pipeline_with_redirections(executor, temp_test_dir, monke
420420
def test_validate_redirection_syntax(executor):
421421
"""Test validation of redirection syntax with various input combinations"""
422422
# Valid cases
423-
executor._validate_redirection_syntax(["echo", "hello", ">", "file.txt"])
424-
executor._validate_redirection_syntax(["cat", "<", "input.txt", ">", "output.txt"])
423+
executor.io_handler.validate_redirection_syntax(["echo", "hello", ">", "file.txt"])
424+
executor.io_handler.validate_redirection_syntax(
425+
["cat", "<", "input.txt", ">", "output.txt"]
426+
)
425427

426428
# Test consecutive operators
427429
with pytest.raises(ValueError) as exc:
428-
executor._validate_redirection_syntax(["echo", "text", ">", ">", "file.txt"])
430+
executor.io_handler.validate_redirection_syntax(
431+
["echo", "text", ">", ">", "file.txt"]
432+
)
429433
assert str(exc.value) == "Invalid redirection syntax: consecutive operators"
430434

431435
with pytest.raises(ValueError) as exc:
432-
executor._validate_redirection_syntax(["cat", "<", "<", "input.txt"])
436+
executor.io_handler.validate_redirection_syntax(["cat", "<", "<", "input.txt"])
433437
assert str(exc.value) == "Invalid redirection syntax: consecutive operators"
434438

435439

@@ -545,10 +549,17 @@ async def test_io_handle_close(executor, temp_test_dir, monkeypatch, mocker):
545549

546550
# Patch the open function to return our mock
547551
mocker.patch("builtins.open", return_value=mock_file)
552+
553+
# Mock logging.warning to capture the warning
554+
mock_warning = mocker.patch("logging.warning")
555+
556+
# Execute should not raise an error
548557
await executor.execute(["echo", "hello", ">", test_file], directory=temp_test_dir)
549558

550559
# Verify our mock's close method was called
551560
assert mock_file.close.called
561+
# Verify warning was logged
562+
mock_warning.assert_called_once_with("Error closing stdout: Failed to close file")
552563

553564

554565
def test_preprocess_command_pipeline(executor):

tests/test_shell_executor_new_tests.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,21 @@ async def test_redirection_validation():
2020

2121
# Missing path for output redirection
2222
with pytest.raises(ValueError, match="Missing path for output redirection"):
23-
executor._process_redirections(["echo", "test", ">"])
23+
executor.io_handler.process_redirections(["echo", "test", ">"])
2424

2525
# Invalid redirection target (operator)
2626
with pytest.raises(
2727
ValueError, match="Invalid redirection syntax: consecutive operators"
2828
):
29-
executor._process_redirections(["echo", "test", ">", ">"])
29+
executor.io_handler.process_redirections(["echo", "test", ">", ">"])
3030

3131
# Missing path for input redirection
3232
with pytest.raises(ValueError, match="Missing path for input redirection"):
33-
executor._process_redirections(["cat", "<"])
33+
executor.io_handler.process_redirections(["cat", "<"])
3434

3535
# Missing path for output redirection after input redirection
3636
with pytest.raises(ValueError, match="Missing path for output redirection"):
37-
executor._process_redirections(["cat", "<", "input.txt", ">"])
37+
executor.io_handler.process_redirections(["cat", "<", "input.txt", ">"])
3838

3939

4040
@pytest.mark.asyncio
@@ -45,15 +45,15 @@ async def test_directory_validation(monkeypatch):
4545

4646
# Directory validation is performed in the _validate_directory method
4747
with pytest.raises(ValueError, match="Directory is required"):
48-
executor._validate_directory(None)
48+
executor.directory_manager.validate_directory(None)
4949

5050
# Directory is not absolute path
5151
with pytest.raises(ValueError, match="Directory must be an absolute path"):
52-
executor._validate_directory("relative/path")
52+
executor.directory_manager.validate_directory("relative/path")
5353

5454
# Directory does not exist
5555
with pytest.raises(ValueError, match="Directory does not exist"):
56-
executor._validate_directory("/path/does/not/exist")
56+
executor.directory_manager.validate_directory("/path/does/not/exist")
5757

5858

5959
@pytest.mark.asyncio

0 commit comments

Comments
 (0)