Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
coverage:
status:
project:
default:
target: 70%
patch:
default:
target: 70%
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
.DEFAULT_GOAL := all

test:
uv run pytest
pytest

format:
black .
Expand All @@ -19,7 +19,7 @@ typecheck:
mypy src/mcp_shell_server tests

coverage:
pytest --cov=src/mcp_shell_server tests
pytest --cov=src/mcp_shell_server --cov-report=xml --cov-report=term-missing tests

# Run all checks required before pushing
check: lint typecheck
Expand Down
10 changes: 8 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ markers = [
]
filterwarnings = [
"ignore::RuntimeWarning:selectors:",
"ignore::pytest.PytestUnhandledCoroutineWarning:",
"ignore::pytest.PytestUnraisableExceptionWarning:",
"ignore::DeprecationWarning:pytest_asyncio.plugin:",
]

Expand Down Expand Up @@ -106,3 +104,11 @@ omit = [
"src/mcp_shell_server/__init__.py",
"src/mcp_shell_server/version.py",
]

[dependency-groups]
dev = [
"black>=23.3.0",
"isort>=5.12.0",
"mypy>=1.2.0",
"ruff>=0.0.262",
]
5 changes: 4 additions & 1 deletion src/mcp_shell_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,14 @@ async def run_tool(self, arguments: dict) -> Sequence[TextContent]:
try:
# Handle execution with timeout
try:
# Add small buffer to timeout for CI scheduling delays if timeout is specified
actual_timeout = timeout + 0.5 if timeout is not None else None

result = await asyncio.wait_for(
self.executor.execute(
command, directory, stdin, None
), # Pass None for timeout
timeout=timeout,
timeout=actual_timeout,
)
except asyncio.TimeoutError as e:
raise ValueError("Command execution timed out") from e
Expand Down
8 changes: 4 additions & 4 deletions src/mcp_shell_server/shell_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ async def execute(
"stderr": f"Not a directory: {directory}",
"execution_time": time.time() - start_time,
}
if not cleaned_command:
if not cleaned_command: # pragma: no cover
raise ValueError("Empty command")

# Initialize stdout_handle with default value
Expand Down Expand Up @@ -232,10 +232,10 @@ async def execute(
"execution_time": time.time() - start_time,
}

# Execute the command with interactive shell
# Execute the command with shell
shell = self._get_default_shell()
shell_cmd = self.preprocessor.create_shell_command(cmd)
shell_cmd = f"{shell} -i -c {shlex.quote(shell_cmd)}"
shell_cmd = f"{shell} -c {shlex.quote(shell_cmd)}"

process = await self.process_manager.create_process(
shell_cmd, directory, stdout_handle=stdout_handle, envs=envs
Expand All @@ -245,7 +245,7 @@ async def execute(
# Send input if provided
stdin_bytes = stdin.encode() if stdin else None

async def communicate_with_timeout():
async def communicate_with_timeout(): # pragma: no cover
try:
return await process.communicate(input=stdin_bytes)
except Exception as e:
Expand Down
264 changes: 264 additions & 0 deletions tests/test_process_manager_additional.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
"""Additional tests for the ProcessManager class to improve coverage."""

import os
import signal
from typing import IO
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from mcp_shell_server.process_manager import ProcessManager


def create_mock_process(returncode=0):
"""Create a mock process with all required attributes."""
process = MagicMock()
process.returncode = returncode
process.communicate = AsyncMock(return_value=(b"output", b"error"))
process.wait = AsyncMock(return_value=returncode)
process.terminate = MagicMock()
process.kill = MagicMock()
return process


@pytest.fixture
def process_manager():
"""Fixture for ProcessManager instance."""
return ProcessManager()


@pytest.mark.asyncio
async def test_start_process_sets_is_running(process_manager):
"""Test that start_process sets is_running attribute correctly."""
mock_proc = create_mock_process()
with patch(
"mcp_shell_server.process_manager.asyncio.create_subprocess_shell",
new_callable=AsyncMock,
return_value=mock_proc,
):
# Test start_process
process = await process_manager.start_process(["echo", "test"])

# Verify is_running attribute is set
assert hasattr(process, "is_running")

# Test is_running when returncode is None (running)
mock_proc.returncode = None
assert process.is_running() is True

# Test is_running when returncode is set (finished)
mock_proc.returncode = 0
assert process.is_running() is False


@pytest.mark.asyncio
async def test_start_process_async_sets_is_running(process_manager):
"""Test that start_process_async sets is_running attribute correctly."""
mock_proc = create_mock_process()
with patch(
"mcp_shell_server.process_manager.asyncio.create_subprocess_shell",
new_callable=AsyncMock,
return_value=mock_proc,
):
# Test start_process_async
process = await process_manager.start_process_async(["echo", "test"])

# Verify is_running attribute is set
assert hasattr(process, "is_running")

# Test is_running when returncode is None (running)
mock_proc.returncode = None
assert process.is_running() is True

# Test is_running when returncode is set (finished)
mock_proc.returncode = 0
assert process.is_running() is False


@pytest.mark.asyncio
async def test_cleanup_all_clears_and_kills(process_manager):
"""Test that cleanup_all kills tracked processes and clears the set."""
# Create mock processes
mock_proc1 = create_mock_process()
mock_proc2 = create_mock_process()
mock_proc1.returncode = None # Still running
mock_proc2.returncode = None # Still running

# Add processes to tracked set
process_manager._processes.add(mock_proc1)
process_manager._processes.add(mock_proc2)

# Mock cleanup_processes to simulate killing processes
with patch.object(
process_manager, "cleanup_processes", new_callable=AsyncMock
) as mock_cleanup:
await process_manager.cleanup_all()

# Verify cleanup_processes was called with the tracked processes
mock_cleanup.assert_called_once()
called_processes = list(mock_cleanup.call_args[0][0])
assert len(called_processes) == 2
assert mock_proc1 in called_processes
assert mock_proc2 in called_processes

# Verify _processes set is cleared
assert len(process_manager._processes) == 0


@pytest.mark.asyncio
async def test_execute_with_timeout_generic_exception(process_manager):
"""Test that generic exceptions in communicate() cause process to be killed and exception re-raised."""
mock_proc = create_mock_process()

# Make communicate raise a generic exception
generic_error = RuntimeError("Communication failed")
mock_proc.communicate = AsyncMock(side_effect=generic_error)
mock_proc.returncode = None # Process is running

# Execute with timeout should kill process and re-raise exception
with pytest.raises(RuntimeError, match="Communication failed"):
await process_manager.execute_with_timeout(mock_proc, timeout=10)

# Verify process was terminated/killed
mock_proc.terminate.assert_called_once()


@pytest.mark.asyncio
async def test_create_process_unexpected_exception():
"""Test that unexpected exceptions in create_subprocess_shell are converted to ValueError."""
process_manager = ProcessManager()

# Mock asyncio.create_subprocess_shell to raise an unexpected exception
unexpected_error = RuntimeError("Unexpected system error")
with patch(
"mcp_shell_server.process_manager.asyncio.create_subprocess_shell",
new_callable=AsyncMock,
side_effect=unexpected_error,
):
# Should convert to ValueError with specific message
with pytest.raises(
ValueError, match="Unexpected error during process creation"
):
await process_manager.create_process("echo test", directory="/tmp")


@pytest.mark.asyncio
async def test_execute_pipeline_last_stdout_handle(process_manager):
"""Test that execute_pipeline writes to IO handle when last_stdout is provided."""
# Create a mock IO object
mock_io = MagicMock(spec=IO)

# Create a mock process that succeeds
mock_proc = create_mock_process(returncode=0)
mock_proc.communicate = AsyncMock(return_value=(b"test output", b""))

with patch.object(
process_manager,
"create_process",
new_callable=AsyncMock,
return_value=mock_proc,
):
with patch.object(
process_manager,
"execute_with_timeout",
new_callable=AsyncMock,
return_value=(b"test output", b""),
):
with patch.object(
process_manager, "cleanup_processes", new_callable=AsyncMock
):

# Execute pipeline with IO handle
stdout, stderr, returncode = await process_manager.execute_pipeline(
["echo test"], last_stdout=mock_io
)

# Verify write was called on the IO handle
mock_io.write.assert_called_once_with("test output")

# Verify return values
assert stderr == b""
assert returncode == 0


@pytest.mark.asyncio
async def test_execute_pipeline_empty_stderr_nonzero_return(process_manager):
"""Test that execute_pipeline provides default error message when stderr is empty and returncode != 0."""
# Create a mock process that fails with empty stderr
mock_proc = create_mock_process(returncode=1)
mock_proc.communicate = AsyncMock(return_value=(b"", b"")) # Empty stderr

with patch.object(
process_manager,
"create_process",
new_callable=AsyncMock,
return_value=mock_proc,
):
with patch.object(
process_manager,
"execute_with_timeout",
new_callable=AsyncMock,
return_value=(b"", b""),
):
with patch.object(
process_manager, "cleanup_processes", new_callable=AsyncMock
):

# Should raise ValueError with default message
with pytest.raises(ValueError, match="Command failed with exit code 1"):
await process_manager.execute_pipeline(["failing_command"])


@pytest.mark.asyncio
async def test_signal_handler_termination(process_manager):
"""Test that signal handler terminates tracked processes and calls os.kill."""
if os.name != "posix":
pytest.skip("Signal handling only available on POSIX systems")

# Create mock processes
mock_proc1 = create_mock_process()
mock_proc2 = create_mock_process()
mock_proc1.returncode = None # Still running
mock_proc2.returncode = None # Still running

# Add processes to tracked set
process_manager._processes.add(mock_proc1)
process_manager._processes.add(mock_proc2)

# Mock os.kill to prevent actual signal sending
with patch("mcp_shell_server.process_manager.os.kill") as mock_os_kill:
with patch("mcp_shell_server.process_manager.signal.signal") as mock_signal:
# Get the signal handler that was registered
original_handler = MagicMock()
mock_signal.return_value = original_handler

# Re-initialize to set up signal handlers
new_process_manager = ProcessManager()

# Get the registered signal handler
signal_handler_calls = mock_signal.call_args_list
sigint_handler = None
for call in signal_handler_calls:
if call[0][0] == signal.SIGINT:
sigint_handler = call[0][1]
break

assert sigint_handler is not None, "SIGINT handler should be registered"

# Add processes to the new manager
new_process_manager._processes.add(mock_proc1)
new_process_manager._processes.add(mock_proc2)
new_process_manager._original_sigint_handler = original_handler

# Trigger the signal handler
sigint_handler(signal.SIGINT, None)

# Verify processes were terminated
mock_proc1.terminate.assert_called_once()
mock_proc2.terminate.assert_called_once()

# Verify os.kill was called to re-raise the signal
mock_os_kill.assert_called_once()
call_args = mock_os_kill.call_args
assert call_args[0][0] == os.getpid() # Current process PID
assert call_args[0][1] == signal.SIGINT # SIGINT signal
Loading