├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .python-version ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── pyproject.toml ├── smithery.yaml ├── src └── mcp_shell_server │ ├── __init__.py │ ├── command_preprocessor.py │ ├── command_validator.py │ ├── directory_manager.py │ ├── io_redirection_handler.py │ ├── process_manager.py │ ├── server.py │ ├── shell_executor.py │ └── version.py ├── tests ├── conftest.py ├── conftest_new.py ├── test_command_validator.py ├── test_directory_manager.py ├── test_init.py ├── test_io_redirection_handler.py ├── test_process_manager.py ├── test_process_manager_macos.py ├── test_server.py ├── test_server_validation.py ├── test_shell_executor.py ├── test_shell_executor_error_cases.py ├── test_shell_executor_new_tests.py ├── test_shell_executor_pipe.py ├── test_shell_executor_pipeline.py ├── test_shell_executor_redirections.py └── test_shell_executor_redirections.py.bak └── uv.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.11"] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Install uv 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install uv 27 | 28 | - name: Install dependencies 29 | run: | 30 | uv venv 31 | uv pip install -e ".[dev,test]" 32 | 33 | - name: Run tests and checks 34 | run: | 35 | uv run make all 36 | 37 | publish: 38 | needs: test 39 | runs-on: ubuntu-latest 40 | environment: release 41 | permissions: 42 | id-token: write 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: Update version from tag 48 | run: | 49 | VERSION=${GITHUB_REF#refs/tags/v} 50 | echo "__version__ = \"${VERSION}\"" > src/mcp_shell_server/version.py 51 | 52 | - name: Set up Python 3.11 53 | uses: actions/setup-python@v5 54 | with: 55 | python-version: "3.11" 56 | 57 | - name: Install uv 58 | run: | 59 | python -m pip install --upgrade pip 60 | pip install uv 61 | 62 | - name: Build package 63 | run: | 64 | uv venv 65 | uv pip install build 66 | uv run python -m build 67 | 68 | - name: Publish to PyPI 69 | uses: pypa/gh-action-pypi-publish@release/v1 70 | with: 71 | password: ${{ secrets.PYPI_TOKEN }} 72 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [main, develop] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.11"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install uv 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install uv 28 | 29 | - name: Install dependencies 30 | run: | 31 | uv venv 32 | uv pip install -e ".[dev,test]" 33 | 34 | - name: Run format checks and typecheck 35 | run: | 36 | uv run make check 37 | 38 | - name: Run tests with coverage 39 | run: | 40 | uv run make coverage 41 | 42 | - name: Upload coverage to Codecov 43 | uses: codecov/codecov-action@v5 44 | with: 45 | token: ${{ secrets.CODECOV_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Virtual Environment 24 | venv/ 25 | env/ 26 | ENV/ 27 | .env 28 | .venv 29 | 30 | # IDE 31 | .idea/ 32 | .vscode/ 33 | *.swp 34 | *.swo 35 | .DS_Store 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .nox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | *.py,cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | cover/ 51 | 52 | # Jupyter Notebook 53 | .ipynb_checkpoints 54 | 55 | # mypy 56 | .mypy_cache/ 57 | .dmypy.json 58 | dmypy.json 59 | 60 | # Distribution / packaging 61 | .Python 62 | build/ 63 | develop-eggs/ 64 | dist/ 65 | downloads/ 66 | eggs/ 67 | .eggs/ 68 | lib/ 69 | lib64/ 70 | parts/ 71 | sdist/ 72 | var/ 73 | wheels/ 74 | share/python-wheels/ 75 | *.egg-info/ 76 | .installed.cfg 77 | *.egg 78 | MANIFEST 79 | 80 | prompt.md -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.0.3] - 2024-12-23 9 | 10 | ### Added 11 | - Interactive shell support for command execution 12 | 13 | ### Changed 14 | - Improved login shell detection mechanism 15 | - Enhanced process cleanup on error 16 | 17 | ### Fixed 18 | - Improved test reliability and coverage 19 | - Fixed pipeline timeout test cases 20 | - Improved redirection handling and tests 21 | ## [1.0.2] - 2024-12-18 22 | 23 | ### Added 24 | - Input/output redirection support in ShellExecutor 25 | - Pipeline execution capabilities 26 | - Process communication timeout handling 27 | - Directory path validation 28 | 29 | ### Changed 30 | - Improved process cleanup mechanisms 31 | - Enhanced test configuration and organization 32 | - Standardized error handling across the codebase 33 | - Updated MCP dependency to version 1.1.2 34 | 35 | ### Fixed 36 | - Proper timeout handling in process communication 37 | - Edge case handling in shell command execution 38 | - Warning suppression for cleaner output 39 | - Pipeline command parsing and execution 40 | 41 | ### Security 42 | - Enhanced directory permission validation 43 | - Improved command validation and sanitization 44 | 45 | ## [1.0.1] - 2024-12-12 46 | 47 | ### Added 48 | - Server version display in startup logs 49 | 50 | ### Changed 51 | - Updated version management system 52 | 53 | ## [1.0.0] - 2024-12-12 54 | 55 | ### Added 56 | - Initial release 57 | - Basic shell command execution via MCP protocol 58 | - Command whitelisting functionality 59 | - Standard input support 60 | - Command execution timeout control 61 | - Working directory specification 62 | - Comprehensive output handling (stdout, stderr, status) 63 | - Shell operator validation 64 | - Basic security measures 65 | - GitHub Actions workflows for testing and publishing 66 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | # Use a Python image with uv pre-installed 3 | FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS uv 4 | 5 | # Set the working directory 6 | WORKDIR /app 7 | 8 | # Copy the project files 9 | COPY . . 10 | 11 | # Install the project's dependencies 12 | RUN --mount=type=cache,target=/root/.cache/uv pip install . 13 | 14 | # Set environment variables 15 | ENV ALLOW_COMMANDS="ls,cat,pwd,grep,wc,touch,find" 16 | 17 | # Start the server using the local script 18 | ENTRYPOINT ["python", "-m", "mcp_shell_server.server"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 tumf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test format lint typecheck check 2 | .DEFAULT_GOAL := all 3 | 4 | test: 5 | uv run pytest 6 | 7 | format: 8 | black . 9 | isort . 10 | ruff check --fix . 11 | 12 | 13 | lint: 14 | black --check . 15 | isort --check . 16 | ruff check . 17 | 18 | typecheck: 19 | mypy src/mcp_shell_server tests 20 | 21 | coverage: 22 | pytest --cov=src/mcp_shell_server tests 23 | 24 | # Run all checks required before pushing 25 | check: lint typecheck 26 | fix: check format 27 | all: format check coverage 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Shell Server 2 | 3 | [![codecov](https://codecov.io/gh/tumf/mcp-shell-server/branch/main/graph/badge.svg)](https://codecov.io/gh/tumf/mcp-shell-server) 4 | [![smithery badge](https://smithery.ai/badge/mcp-shell-server)](https://smithery.ai/server/mcp-shell-server) 5 | 6 | A secure shell command execution server implementing the Model Context Protocol (MCP). This server allows remote execution of whitelisted shell commands with support for stdin input. 7 | 8 | mcp-shell-server MCP server 9 | 10 | ## Features 11 | 12 | * **Secure Command Execution**: Only whitelisted commands can be executed 13 | * **Standard Input Support**: Pass input to commands via stdin 14 | * **Comprehensive Output**: Returns stdout, stderr, exit status, and execution time 15 | * **Shell Operator Safety**: Validates commands after shell operators (; , &&, ||, |) 16 | * **Timeout Control**: Set maximum execution time for commands 17 | 18 | ## MCP client setting in your Claude.app 19 | 20 | ### Published version 21 | 22 | ```shell 23 | code ~/Library/Application\ Support/Claude/claude_desktop_config.json 24 | ``` 25 | 26 | ```json 27 | { 28 | "mcpServers": { 29 | "shell": { 30 | "command": "uvx", 31 | "args": [ 32 | "mcp-shell-server" 33 | ], 34 | "env": { 35 | "ALLOW_COMMANDS": "ls,cat,pwd,grep,wc,touch,find" 36 | } 37 | }, 38 | } 39 | } 40 | ``` 41 | 42 | ### Local version 43 | 44 | #### Configuration 45 | 46 | ```shell 47 | code ~/Library/Application\ Support/Claude/claude_desktop_config.json 48 | ``` 49 | 50 | ```json 51 | { 52 | "mcpServers": { 53 | "shell": { 54 | "command": "uv", 55 | "args": [ 56 | "--directory", 57 | ".", 58 | "run", 59 | "mcp-shell-server" 60 | ], 61 | "env": { 62 | "ALLOW_COMMANDS": "ls,cat,pwd,grep,wc,touch,find" 63 | } 64 | }, 65 | } 66 | } 67 | ``` 68 | 69 | #### Installation 70 | 71 | ### Installing via Smithery 72 | 73 | To install Shell Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-shell-server): 74 | 75 | ```bash 76 | npx -y @smithery/cli install mcp-shell-server --client claude 77 | ``` 78 | 79 | ### Manual Installation 80 | ```bash 81 | pip install mcp-shell-server 82 | ``` 83 | 84 | ### Installing via Smithery 85 | 86 | To install Shell Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-shell-server): 87 | 88 | ```bash 89 | npx -y @smithery/cli install mcp-shell-server --client claude 90 | ``` 91 | 92 | ## Usage 93 | 94 | ### Starting the Server 95 | 96 | ```bash 97 | ALLOW_COMMANDS="ls,cat,echo" uvx mcp-shell-server 98 | # Or using the alias 99 | ALLOWED_COMMANDS="ls,cat,echo" uvx mcp-shell-server 100 | ``` 101 | 102 | The `ALLOW_COMMANDS` (or its alias `ALLOWED_COMMANDS` ) environment variable specifies which commands are allowed to be executed. Commands can be separated by commas with optional spaces around them. 103 | 104 | Valid formats for ALLOW_COMMANDS or ALLOWED_COMMANDS: 105 | 106 | ```bash 107 | ALLOW_COMMANDS="ls,cat,echo" # Basic format 108 | ALLOWED_COMMANDS="ls ,echo, cat" # With spaces (using alias) 109 | ALLOW_COMMANDS="ls, cat , echo" # Multiple spaces 110 | ``` 111 | 112 | ### Request Format 113 | 114 | ```python 115 | # Basic command execution 116 | { 117 | "command": ["ls", "-l", "/tmp"] 118 | } 119 | 120 | # Command with stdin input 121 | { 122 | "command": ["cat"], 123 | "stdin": "Hello, World!" 124 | } 125 | 126 | # Command with timeout 127 | { 128 | "command": ["long-running-process"], 129 | "timeout": 30 # Maximum execution time in seconds 130 | } 131 | 132 | # Command with working directory and timeout 133 | { 134 | "command": ["grep", "-r", "pattern"], 135 | "directory": "/path/to/search", 136 | "timeout": 60 137 | } 138 | ``` 139 | 140 | ### Response Format 141 | 142 | Successful response: 143 | 144 | ```json 145 | { 146 | "stdout": "command output", 147 | "stderr": "", 148 | "status": 0, 149 | "execution_time": 0.123 150 | } 151 | ``` 152 | 153 | Error response: 154 | 155 | ```json 156 | { 157 | "error": "Command not allowed: rm", 158 | "status": 1, 159 | "stdout": "", 160 | "stderr": "Command not allowed: rm", 161 | "execution_time": 0 162 | } 163 | ``` 164 | 165 | ## Security 166 | 167 | The server implements several security measures: 168 | 169 | 1. **Command Whitelisting**: Only explicitly allowed commands can be executed 170 | 2. **Shell Operator Validation**: Commands after shell operators (;, &&, ||, |) are also validated against the whitelist 171 | 3. **No Shell Injection**: Commands are executed directly without shell interpretation 172 | 173 | ## Development 174 | 175 | ### Setting up Development Environment 176 | 177 | 1. Clone the repository 178 | 179 | ```bash 180 | git clone https://github.com/yourusername/mcp-shell-server.git 181 | cd mcp-shell-server 182 | ``` 183 | 184 | 2. Install dependencies including test requirements 185 | 186 | ```bash 187 | pip install -e ".[test]" 188 | ``` 189 | 190 | ### Running Tests 191 | 192 | ```bash 193 | pytest 194 | ``` 195 | 196 | ## API Reference 197 | 198 | ### Request Arguments 199 | 200 | | Field | Type | Required | Description | 201 | |-----------|------------|----------|-----------------------------------------------| 202 | | command | string[] | Yes | Command and its arguments as array elements | 203 | | stdin | string | No | Input to be passed to the command | 204 | | directory | string | No | Working directory for command execution | 205 | | timeout | integer | No | Maximum execution time in seconds | 206 | 207 | ### Response Fields 208 | 209 | | Field | Type | Description | 210 | |----------------|---------|---------------------------------------------| 211 | | stdout | string | Standard output from the command | 212 | | stderr | string | Standard error output from the command | 213 | | status | integer | Exit status code | 214 | | execution_time | float | Time taken to execute (in seconds) | 215 | | error | string | Error message (only present if failed) | 216 | 217 | ## Requirements 218 | 219 | * Python 3.11 or higher 220 | * mcp>=1.1.0 221 | 222 | ## License 223 | 224 | MIT License - See LICENSE file for details 225 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mcp-shell-server" 3 | description = "MCP Shell Server - Execute shell commands via MCP protocol" 4 | authors = [ 5 | { name = "tumf" } 6 | ] 7 | dependencies = [ 8 | "asyncio>=3.4.3", 9 | "mcp>=1.1.2", 10 | ] 11 | requires-python = ">=3.11" 12 | readme = "README.md" 13 | license = { text = "MIT" } 14 | dynamic = ["version"] 15 | 16 | [project.scripts] 17 | mcp-shell-server = "mcp_shell_server:main" 18 | 19 | [project.optional-dependencies] 20 | test = [ 21 | "pytest>=7.4.0", 22 | "pytest-asyncio>=0.23.0", 23 | "pytest-env>=1.1.0", 24 | "pytest-cov>=6.0.0", 25 | "pytest-mock>=3.12.0", 26 | ] 27 | dev = [ 28 | "ruff>=0.0.262", 29 | "black>=23.3.0", 30 | "isort>=5.12.0", 31 | "mypy>=1.2.0", 32 | "pre-commit>=3.2.2", 33 | ] 34 | 35 | [build-system] 36 | requires = ["hatchling"] 37 | build-backend = "hatchling.build" 38 | 39 | [tool.pytest.ini_options] 40 | asyncio_mode = "strict" 41 | testpaths = "tests" 42 | # Set default event loop scope for async tests 43 | asyncio_default_fixture_loop_scope = "function" 44 | markers = [ 45 | "macos: marks tests that should only run on macOS", 46 | "slow: marks tests as slow running", 47 | ] 48 | filterwarnings = [ 49 | "ignore::RuntimeWarning:selectors:", 50 | "ignore::pytest.PytestUnhandledCoroutineWarning:", 51 | "ignore::pytest.PytestUnraisableExceptionWarning:", 52 | "ignore::DeprecationWarning:pytest_asyncio.plugin:", 53 | ] 54 | 55 | [tool.ruff] 56 | lint.select = [ 57 | "E", # pycodestyle errors 58 | "F", # pyflakes 59 | "W", # pycodestyle warnings 60 | "I", # isort 61 | "C", # flake8-comprehensions 62 | "B", # flake8-bugbear 63 | ] 64 | lint.ignore = [ 65 | "E501", # line too long, handled by black 66 | "B008", # do not perform function calls in argument defaults 67 | "C901", # too complex 68 | ] 69 | 70 | [tool.black] 71 | line-length = 88 72 | target-version = ['py311'] 73 | 74 | [tool.isort] 75 | profile = "black" 76 | line_length = 88 77 | 78 | [tool.hatch.version] 79 | path = "src/mcp_shell_server/version.py" 80 | 81 | [tool.hatch.build.targets.wheel] 82 | packages = ["src/mcp_shell_server"] 83 | 84 | [tool.hatch.metadata] 85 | allow-direct-references = true 86 | 87 | 88 | [tool.coverage.report] 89 | exclude_lines = [ 90 | "pragma: no cover", 91 | "def __repr__", 92 | "raise NotImplementedError", 93 | "if __name__ == .__main__.:", 94 | "pass", 95 | "raise ImportError", 96 | "__version__", 97 | "except IOError:", 98 | "except IOError as e:", 99 | "def _cleanup_handles", 100 | "def __aexit__", 101 | "if path in [\">\", \">>\", \"<\"]:", 102 | "def _close_handles", 103 | ] 104 | 105 | omit = [ 106 | "src/mcp_shell_server/__init__.py", 107 | "src/mcp_shell_server/version.py", 108 | ] 109 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - allowCommands 10 | properties: 11 | allowCommands: 12 | type: string 13 | description: Comma-separated list of shell commands that are allowed to be executed. 14 | commandFunction: 15 | # A function that produces the CLI command to start the MCP on stdio. 16 | |- 17 | (config) => ({ command: 'python', args: ['-m', 'mcp_shell_server.server'], env: { ALLOW_COMMANDS: config.allowCommands } }) -------------------------------------------------------------------------------- /src/mcp_shell_server/__init__.py: -------------------------------------------------------------------------------- 1 | """MCP Shell Server Package.""" 2 | 3 | from . import server 4 | 5 | __version__ = "0.1.0" 6 | __all__ = ["main", "server"] 7 | 8 | 9 | def main(): 10 | """Main entry point for the package.""" 11 | import asyncio 12 | 13 | asyncio.run(server.main()) 14 | 15 | 16 | if __name__ == "__main__": 17 | main() 18 | -------------------------------------------------------------------------------- /src/mcp_shell_server/command_preprocessor.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | from typing import Dict, List, Tuple, Union 3 | 4 | 5 | class CommandPreProcessor: 6 | """ 7 | Pre-processes and validates shell commands before execution 8 | """ 9 | 10 | def preprocess_command(self, command: List[str]) -> List[str]: 11 | """ 12 | Preprocess the command to handle cases where '|' is attached to a command. 13 | """ 14 | preprocessed_command = [] 15 | for token in command: 16 | if token in ["||", "&&", ";"]: # Special shell operators 17 | preprocessed_command.append(token) 18 | elif "|" in token and token != "|": 19 | parts = token.split("|") 20 | preprocessed_command.extend( 21 | [part.strip() for part in parts if part.strip()] 22 | ) 23 | preprocessed_command.append("|") 24 | else: 25 | preprocessed_command.append(token) 26 | return preprocessed_command 27 | 28 | def clean_command(self, command: List[str]) -> List[str]: 29 | """ 30 | Clean command by trimming whitespace from each part. 31 | Removes empty strings but preserves arguments that are meant to be spaces. 32 | 33 | Args: 34 | command (List[str]): Original command and its arguments 35 | 36 | Returns: 37 | List[str]: Cleaned command 38 | """ 39 | return [arg for arg in command if arg] # Remove empty strings 40 | 41 | def create_shell_command(self, command: List[str]) -> str: 42 | """ 43 | Create a shell command string from a list of arguments. 44 | Handles wildcards and arguments properly. 45 | """ 46 | if not command: 47 | return "" 48 | 49 | escaped_args = [] 50 | for arg in command: 51 | if arg.isspace(): 52 | # Wrap space-only arguments in single quotes 53 | escaped_args.append(f"'{arg}'") 54 | else: 55 | # Properly escape all arguments including those with wildcards 56 | escaped_args.append(shlex.quote(arg.strip())) 57 | 58 | return " ".join(escaped_args) 59 | 60 | def split_pipe_commands(self, command: List[str]) -> List[List[str]]: 61 | """ 62 | Split commands by pipe operator into separate commands. 63 | 64 | Args: 65 | command (List[str]): Command and its arguments with pipe operators 66 | 67 | Returns: 68 | List[List[str]]: List of commands split by pipe operator 69 | """ 70 | commands: List[List[str]] = [] 71 | current_command: List[str] = [] 72 | 73 | for arg in command: 74 | if arg.strip() == "|": 75 | if current_command: 76 | commands.append(current_command) 77 | current_command = [] 78 | else: 79 | current_command.append(arg) 80 | 81 | if current_command: 82 | commands.append(current_command) 83 | 84 | return commands 85 | 86 | def parse_command( 87 | self, command: List[str] 88 | ) -> Tuple[List[str], Dict[str, Union[None, str, bool]]]: 89 | """ 90 | Parse command and extract redirections. 91 | """ 92 | cmd = [] 93 | redirects: Dict[str, Union[None, str, bool]] = { 94 | "stdin": None, 95 | "stdout": None, 96 | "stdout_append": False, 97 | } 98 | 99 | i = 0 100 | while i < len(command): 101 | token = command[i] 102 | 103 | # Shell operators check 104 | if token in ["|", ";", "&&", "||"]: 105 | raise ValueError(f"Unexpected shell operator: {token}") 106 | 107 | # Output redirection 108 | if token in [">", ">>"]: 109 | if i + 1 >= len(command): 110 | raise ValueError("Missing path for output redirection") 111 | if i + 1 < len(command) and command[i + 1] in [">", ">>", "<"]: 112 | raise ValueError("Invalid redirection target: operator found") 113 | path = command[i + 1] 114 | redirects["stdout"] = path 115 | redirects["stdout_append"] = token == ">>" 116 | i += 2 117 | continue 118 | 119 | # Input redirection 120 | if token == "<": 121 | if i + 1 >= len(command): 122 | raise ValueError("Missing path for input redirection") 123 | path = command[i + 1] 124 | redirects["stdin"] = path 125 | i += 2 126 | continue 127 | 128 | cmd.append(token) 129 | i += 1 130 | 131 | return cmd, redirects 132 | -------------------------------------------------------------------------------- /src/mcp_shell_server/command_validator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides validation for shell commands and ensures they are allowed to be executed. 3 | """ 4 | 5 | import os 6 | from typing import Dict, List 7 | 8 | 9 | class CommandValidator: 10 | """ 11 | Validates shell commands against a whitelist and checks for unsafe operators. 12 | """ 13 | 14 | def __init__(self): 15 | """ 16 | Initialize the validator. 17 | """ 18 | pass 19 | 20 | def _get_allowed_commands(self) -> set[str]: 21 | """Get the set of allowed commands from environment variables""" 22 | allow_commands = os.environ.get("ALLOW_COMMANDS", "") 23 | allowed_commands = os.environ.get("ALLOWED_COMMANDS", "") 24 | commands = allow_commands + "," + allowed_commands 25 | return {cmd.strip() for cmd in commands.split(",") if cmd.strip()} 26 | 27 | def get_allowed_commands(self) -> list[str]: 28 | """Get the list of allowed commands from environment variables""" 29 | return list(self._get_allowed_commands()) 30 | 31 | def is_command_allowed(self, command: str) -> bool: 32 | """Check if a command is in the allowed list""" 33 | cmd = command.strip() 34 | return cmd in self._get_allowed_commands() 35 | 36 | def validate_no_shell_operators(self, cmd: str) -> None: 37 | """ 38 | Validate that the command does not contain shell operators. 39 | 40 | Args: 41 | cmd (str): Command to validate 42 | 43 | Raises: 44 | ValueError: If the command contains shell operators 45 | """ 46 | if cmd in [";", "&&", "||", "|"]: 47 | raise ValueError(f"Unexpected shell operator: {cmd}") 48 | 49 | def validate_pipeline(self, commands: List[str]) -> Dict[str, str]: 50 | """ 51 | Validate pipeline command and ensure all parts are allowed. 52 | 53 | Args: 54 | commands (List[str]): List of commands to validate 55 | 56 | Returns: 57 | Dict[str, str]: Error message if validation fails, empty dict if success 58 | 59 | Raises: 60 | ValueError: If validation fails 61 | """ 62 | current_cmd: List[str] = [] 63 | 64 | for token in commands: 65 | if token == "|": 66 | if not current_cmd: 67 | raise ValueError("Empty command before pipe operator") 68 | if not self.is_command_allowed(current_cmd[0]): 69 | raise ValueError(f"Command not allowed: {current_cmd[0]}") 70 | current_cmd = [] 71 | elif token in [";", "&&", "||"]: 72 | raise ValueError(f"Unexpected shell operator in pipeline: {token}") 73 | else: 74 | current_cmd.append(token) 75 | 76 | if current_cmd: 77 | if not self.is_command_allowed(current_cmd[0]): 78 | raise ValueError(f"Command not allowed: {current_cmd[0]}") 79 | 80 | return {} 81 | 82 | def validate_command(self, command: List[str]) -> None: 83 | """ 84 | Validate if the command is allowed to be executed. 85 | 86 | Args: 87 | command (List[str]): Command and its arguments 88 | 89 | Raises: 90 | ValueError: If the command is empty, not allowed, or contains invalid shell operators 91 | """ 92 | if not command: 93 | raise ValueError("Empty command") 94 | 95 | allowed_commands = self._get_allowed_commands() 96 | if not allowed_commands: 97 | raise ValueError( 98 | "No commands are allowed. Please set ALLOW_COMMANDS environment variable." 99 | ) 100 | 101 | # Clean and check the first command 102 | cleaned_cmd = command[0].strip() 103 | if cleaned_cmd not in allowed_commands: 104 | raise ValueError(f"Command not allowed: {cleaned_cmd}") 105 | -------------------------------------------------------------------------------- /src/mcp_shell_server/directory_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | 5 | class DirectoryManager: 6 | """ 7 | Manages directory validation and path operations for shell command execution. 8 | """ 9 | 10 | def validate_directory(self, directory: Optional[str]) -> None: 11 | """ 12 | Validate if the directory exists and is accessible. 13 | 14 | Args: 15 | directory (Optional[str]): Directory path to validate 16 | 17 | Raises: 18 | ValueError: If the directory doesn't exist, not absolute or is not accessible 19 | """ 20 | # make directory required 21 | if directory is None: 22 | raise ValueError("Directory is required") 23 | 24 | # verify directory is absolute path 25 | if not os.path.isabs(directory): 26 | raise ValueError(f"Directory must be an absolute path: {directory}") 27 | 28 | if not os.path.exists(directory): 29 | raise ValueError(f"Directory does not exist: {directory}") 30 | 31 | if not os.path.isdir(directory): 32 | raise ValueError(f"Not a directory: {directory}") 33 | 34 | if not os.access(directory, os.R_OK | os.X_OK): 35 | raise ValueError(f"Directory is not accessible: {directory}") 36 | 37 | def get_absolute_path(self, path: str, base_directory: Optional[str] = None) -> str: 38 | """ 39 | Get absolute path by joining base directory with path if path is relative. 40 | 41 | Args: 42 | path (str): The path to make absolute 43 | base_directory (Optional[str]): Base directory to join with relative paths 44 | 45 | Returns: 46 | str: Absolute path 47 | """ 48 | if os.path.isabs(path): 49 | return path 50 | if not base_directory: 51 | return os.path.abspath(path) 52 | return os.path.join(base_directory, path) 53 | -------------------------------------------------------------------------------- /src/mcp_shell_server/io_redirection_handler.py: -------------------------------------------------------------------------------- 1 | """IO redirection handling module for MCP Shell Server.""" 2 | 3 | import asyncio 4 | import os 5 | from typing import IO, Any, Dict, List, Optional, Tuple, Union 6 | 7 | 8 | class IORedirectionHandler: 9 | """Handles input/output redirection for shell commands.""" 10 | 11 | def validate_redirection_syntax(self, command: List[str]) -> None: 12 | """ 13 | Validate the syntax of redirection operators in the command. 14 | 15 | Args: 16 | command (List[str]): Command and its arguments including redirections 17 | 18 | Raises: 19 | ValueError: If the redirection syntax is invalid 20 | """ 21 | prev_token = None 22 | for token in command: 23 | if token in [">", ">>", "<"]: 24 | if prev_token and prev_token in [">", ">>", "<"]: 25 | raise ValueError( 26 | "Invalid redirection syntax: consecutive operators" 27 | ) 28 | prev_token = token 29 | 30 | def process_redirections( 31 | self, command: List[str] 32 | ) -> Tuple[List[str], Dict[str, Union[None, str, bool]]]: 33 | """ 34 | Process input/output redirections in the command. 35 | 36 | Args: 37 | command (List[str]): Command and its arguments including redirections 38 | 39 | Returns: 40 | Tuple[List[str], Dict[str, Any]]: Processed command without redirections and 41 | redirection configuration 42 | 43 | Raises: 44 | ValueError: If the redirection syntax is invalid 45 | """ 46 | self.validate_redirection_syntax(command) 47 | 48 | cmd = [] 49 | redirects: Dict[str, Union[None, str, bool]] = { 50 | "stdin": None, 51 | "stdout": None, 52 | "stdout_append": False, 53 | } 54 | 55 | i = 0 56 | while i < len(command): 57 | token = command[i] 58 | 59 | # Output redirection 60 | if token in [">", ">>"]: 61 | if i + 1 >= len(command): 62 | raise ValueError("Missing path for output redirection") 63 | if i + 1 < len(command) and command[i + 1] in [">", ">>", "<"]: 64 | raise ValueError("Invalid redirection target: operator found") 65 | path = command[i + 1] 66 | redirects["stdout"] = path 67 | redirects["stdout_append"] = token == ">>" 68 | i += 2 69 | continue 70 | 71 | # Input redirection 72 | if token == "<": 73 | if i + 1 >= len(command): 74 | raise ValueError("Missing path for input redirection") 75 | path = command[i + 1] 76 | if path in [">", ">>", "<"]: 77 | raise ValueError("Invalid redirection target: operator found") 78 | redirects["stdin"] = path 79 | i += 2 80 | continue 81 | 82 | cmd.append(token) 83 | i += 1 84 | 85 | return cmd, redirects 86 | 87 | async def setup_redirects( 88 | self, 89 | redirects: Dict[str, Union[None, str, bool]], 90 | directory: Optional[str] = None, 91 | ) -> Dict[str, Union[IO[Any], int, str, None]]: 92 | """ 93 | Set up file handles for redirections. 94 | 95 | Args: 96 | redirects (Dict[str, Union[None, str, bool]]): Redirection configuration 97 | directory (Optional[str]): Working directory for file paths 98 | 99 | Returns: 100 | Dict[str, Union[IO[Any], int, str, None]]: File handles for subprocess 101 | """ 102 | handles: Dict[str, Union[IO[Any], int, str, None]] = {} 103 | 104 | # Handle input redirection 105 | if redirects["stdin"]: 106 | path = ( 107 | os.path.join(directory or "", str(redirects["stdin"])) 108 | if directory and redirects["stdin"] 109 | else str(redirects["stdin"]) 110 | ) 111 | try: 112 | file = open(path, "r") 113 | handles["stdin"] = asyncio.subprocess.PIPE 114 | handles["stdin_data"] = file.read() 115 | file.close() 116 | except (FileNotFoundError, IOError) as e: 117 | raise ValueError("Failed to open input file") from e 118 | 119 | # Handle output redirection 120 | if redirects["stdout"]: 121 | path = ( 122 | os.path.join(directory or "", str(redirects["stdout"])) 123 | if directory and redirects["stdout"] 124 | else str(redirects["stdout"]) 125 | ) 126 | mode = "a" if redirects.get("stdout_append") else "w" 127 | try: 128 | handles["stdout"] = open(path, mode) 129 | except (IOError, PermissionError) as e: 130 | raise ValueError(f"Failed to open output file: {e}") from e 131 | else: 132 | handles["stdout"] = asyncio.subprocess.PIPE 133 | 134 | handles["stderr"] = asyncio.subprocess.PIPE 135 | 136 | return handles 137 | 138 | async def cleanup_handles( 139 | self, handles: Dict[str, Union[IO[Any], int, None]] 140 | ) -> None: 141 | """ 142 | Clean up file handles after command execution. 143 | 144 | Args: 145 | handles (Dict[str, Union[IO[Any], int, None]]): File handles to clean up 146 | """ 147 | for key in ["stdout", "stderr"]: 148 | handle = handles.get(key) 149 | if handle and hasattr(handle, "close") and not isinstance(handle, int): 150 | try: 151 | handle.close() 152 | except (IOError, ValueError): 153 | pass 154 | -------------------------------------------------------------------------------- /src/mcp_shell_server/process_manager.py: -------------------------------------------------------------------------------- 1 | """Process management for shell command execution.""" 2 | 3 | import asyncio 4 | import logging 5 | import os 6 | import signal 7 | from typing import IO, Any, Dict, List, Optional, Set, Tuple, Union 8 | from weakref import WeakSet 9 | 10 | 11 | class ProcessManager: 12 | """Manages process creation, execution, and cleanup for shell commands.""" 13 | 14 | def __init__(self): 15 | """Initialize ProcessManager with signal handling setup.""" 16 | self._processes: Set[asyncio.subprocess.Process] = WeakSet() 17 | self._original_sigint_handler = None 18 | self._original_sigterm_handler = None 19 | self._setup_signal_handlers() 20 | 21 | def _setup_signal_handlers(self) -> None: 22 | """Set up signal handlers for graceful process management.""" 23 | if os.name != "posix": 24 | return 25 | 26 | def handle_termination(signum: int, _: Any) -> None: 27 | """Handle termination signals by cleaning up processes.""" 28 | if self._processes: 29 | for process in self._processes: 30 | try: 31 | if process.returncode is None: 32 | process.terminate() 33 | except Exception as e: 34 | logging.warning( 35 | f"Error terminating process on signal {signum}: {e}" 36 | ) 37 | 38 | # Restore original handler and re-raise signal 39 | if signum == signal.SIGINT and self._original_sigint_handler: 40 | signal.signal(signal.SIGINT, self._original_sigint_handler) 41 | elif signum == signal.SIGTERM and self._original_sigterm_handler: 42 | signal.signal(signal.SIGTERM, self._original_sigterm_handler) 43 | 44 | # Re-raise signal 45 | os.kill(os.getpid(), signum) 46 | 47 | # Store original handlers 48 | self._original_sigint_handler = signal.signal(signal.SIGINT, handle_termination) 49 | self._original_sigterm_handler = signal.signal( 50 | signal.SIGTERM, handle_termination 51 | ) 52 | 53 | async def start_process_async( 54 | self, cmd: List[str], timeout: Optional[int] = None 55 | ) -> asyncio.subprocess.Process: 56 | """Start a new process asynchronously. 57 | 58 | Args: 59 | cmd: Command to execute as list of strings 60 | timeout: Optional timeout in seconds 61 | 62 | Returns: 63 | Process object 64 | """ 65 | process = await self.create_process( 66 | " ".join(cmd), directory=None, timeout=timeout 67 | ) 68 | process.is_running = lambda self=process: self.returncode is None # type: ignore 69 | return process 70 | 71 | async def start_process( 72 | self, cmd: List[str], timeout: Optional[int] = None 73 | ) -> asyncio.subprocess.Process: 74 | """Start a new process asynchronously. 75 | 76 | Args: 77 | cmd: Command to execute as list of strings 78 | timeout: Optional timeout in seconds 79 | 80 | Returns: 81 | Process object 82 | """ 83 | process = await self.create_process( 84 | " ".join(cmd), directory=None, timeout=timeout 85 | ) 86 | process.is_running = lambda self=process: self.returncode is None # type: ignore 87 | return process 88 | 89 | async def cleanup_processes( 90 | self, processes: List[asyncio.subprocess.Process] 91 | ) -> None: 92 | """Clean up processes by killing them if they're still running. 93 | 94 | Args: 95 | processes: List of processes to clean up 96 | """ 97 | cleanup_tasks = [] 98 | for process in processes: 99 | if process.returncode is None: 100 | try: 101 | # Force kill immediately as required by tests 102 | process.kill() 103 | cleanup_tasks.append(asyncio.create_task(process.wait())) 104 | except Exception as e: 105 | logging.warning(f"Error killing process: {e}") 106 | 107 | if cleanup_tasks: 108 | try: 109 | # Wait for all processes to be killed 110 | await asyncio.wait(cleanup_tasks, timeout=5) 111 | except asyncio.TimeoutError: 112 | logging.error("Process cleanup timed out") 113 | except Exception as e: 114 | logging.error(f"Error during process cleanup: {e}") 115 | 116 | async def cleanup_all(self) -> None: 117 | """Clean up all tracked processes.""" 118 | if self._processes: 119 | processes = list(self._processes) 120 | await self.cleanup_processes(processes) 121 | self._processes.clear() 122 | 123 | async def create_process( 124 | self, 125 | shell_cmd: str, 126 | directory: Optional[str], 127 | stdin: Optional[str] = None, 128 | stdout_handle: Any = asyncio.subprocess.PIPE, 129 | envs: Optional[Dict[str, str]] = None, 130 | timeout: Optional[int] = None, 131 | ) -> asyncio.subprocess.Process: 132 | """Create a new subprocess with the given parameters. 133 | 134 | Args: 135 | shell_cmd (str): Shell command to execute 136 | directory (Optional[str]): Working directory 137 | stdin (Optional[str]): Input to be passed to the process 138 | stdout_handle: File handle or PIPE for stdout 139 | envs (Optional[Dict[str, str]]): Additional environment variables 140 | timeout (Optional[int]): Timeout in seconds 141 | 142 | Returns: 143 | asyncio.subprocess.Process: Created process 144 | 145 | Raises: 146 | ValueError: If process creation fails 147 | """ 148 | try: 149 | process = await asyncio.create_subprocess_shell( 150 | shell_cmd, 151 | stdin=asyncio.subprocess.PIPE, 152 | stdout=stdout_handle, 153 | stderr=asyncio.subprocess.PIPE, 154 | env={**os.environ, **(envs or {})}, 155 | cwd=directory, 156 | ) 157 | 158 | # Add process to tracked set 159 | self._processes.add(process) 160 | return process 161 | 162 | except OSError as e: 163 | raise ValueError(f"Failed to create process: {str(e)}") from e 164 | except Exception as e: 165 | raise ValueError( 166 | f"Unexpected error during process creation: {str(e)}" 167 | ) from e 168 | 169 | async def execute_with_timeout( 170 | self, 171 | process: asyncio.subprocess.Process, 172 | stdin: Optional[str] = None, 173 | timeout: Optional[int] = None, 174 | ) -> Tuple[bytes, bytes]: 175 | """Execute the process with timeout handling. 176 | 177 | Args: 178 | process: Process to execute 179 | stdin (Optional[str]): Input to pass to the process 180 | timeout (Optional[int]): Timeout in seconds 181 | 182 | Returns: 183 | Tuple[bytes, bytes]: Tuple of (stdout, stderr) 184 | 185 | Raises: 186 | asyncio.TimeoutError: If execution times out 187 | """ 188 | stdin_bytes = stdin.encode() if stdin else None 189 | 190 | async def _kill_process(): 191 | if process.returncode is not None: 192 | return 193 | 194 | try: 195 | # Try graceful termination first 196 | process.terminate() 197 | for _ in range(5): # Wait up to 0.5 seconds 198 | if process.returncode is not None: 199 | return 200 | await asyncio.sleep(0.1) 201 | 202 | # Force kill if still running 203 | if process.returncode is None: 204 | process.kill() 205 | await asyncio.wait_for(process.wait(), timeout=1.0) 206 | except Exception as e: 207 | logging.warning(f"Error killing process: {e}") 208 | 209 | try: 210 | if timeout: 211 | try: 212 | return await asyncio.wait_for( 213 | process.communicate(input=stdin_bytes), timeout=timeout 214 | ) 215 | except asyncio.TimeoutError: 216 | await _kill_process() 217 | raise 218 | return await process.communicate(input=stdin_bytes) 219 | except Exception as e: 220 | await _kill_process() 221 | raise e 222 | 223 | async def execute_pipeline( 224 | self, 225 | commands: List[str], 226 | first_stdin: Optional[bytes] = None, 227 | last_stdout: Union[IO[Any], int, None] = None, 228 | directory: Optional[str] = None, 229 | timeout: Optional[int] = None, 230 | envs: Optional[Dict[str, str]] = None, 231 | ) -> Tuple[bytes, bytes, int]: 232 | """Execute a pipeline of commands. 233 | 234 | Args: 235 | commands: List of shell commands to execute in pipeline 236 | first_stdin: Input to pass to the first command 237 | last_stdout: Output handle for the last command 238 | directory: Working directory 239 | timeout: Timeout in seconds 240 | envs: Additional environment variables 241 | 242 | Returns: 243 | Tuple[bytes, bytes, int]: Tuple of (stdout, stderr, return_code) 244 | 245 | Raises: 246 | ValueError: If no commands provided or command execution fails 247 | """ 248 | if not commands: 249 | raise ValueError("No commands provided") 250 | 251 | processes: List[asyncio.subprocess.Process] = [] 252 | try: 253 | prev_stdout: Optional[bytes] = first_stdin 254 | final_stderr: bytes = b"" 255 | final_stdout: bytes = b"" 256 | 257 | for i, cmd in enumerate(commands): 258 | process = await self.create_process( 259 | cmd, 260 | directory, 261 | stdout_handle=( 262 | asyncio.subprocess.PIPE 263 | if i < len(commands) - 1 or not last_stdout 264 | else last_stdout 265 | ), 266 | envs=envs, 267 | ) 268 | if not hasattr(process, "is_running"): 269 | process.is_running = lambda self=process: self.returncode is None # type: ignore 270 | processes.append(process) 271 | 272 | try: 273 | stdout, stderr = await self.execute_with_timeout( 274 | process, 275 | stdin=prev_stdout.decode() if prev_stdout else None, 276 | timeout=timeout, 277 | ) 278 | 279 | final_stderr += stderr if stderr else b"" 280 | if process.returncode != 0: 281 | error_msg = stderr.decode("utf-8", errors="replace").strip() 282 | if not error_msg: 283 | error_msg = ( 284 | f"Command failed with exit code {process.returncode}" 285 | ) 286 | raise ValueError(error_msg) 287 | 288 | if i == len(commands) - 1: 289 | if last_stdout and isinstance(last_stdout, IO): 290 | last_stdout.write(stdout.decode("utf-8", errors="replace")) 291 | else: 292 | final_stdout = stdout if stdout else b"" 293 | else: 294 | prev_stdout = stdout if stdout else b"" 295 | 296 | except asyncio.TimeoutError: 297 | process.kill() 298 | raise 299 | except Exception: 300 | process.kill() 301 | raise 302 | 303 | return ( 304 | final_stdout, 305 | final_stderr, 306 | ( 307 | processes[-1].returncode 308 | if processes and processes[-1].returncode is not None 309 | else 1 310 | ), 311 | ) 312 | 313 | finally: 314 | await self.cleanup_processes(processes) 315 | -------------------------------------------------------------------------------- /src/mcp_shell_server/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import traceback 4 | from collections.abc import Sequence 5 | from typing import Any 6 | 7 | from mcp.server import Server 8 | from mcp.types import TextContent, Tool 9 | 10 | from .shell_executor import ShellExecutor 11 | from .version import __version__ 12 | 13 | # Configure logging 14 | logging.basicConfig(level=logging.INFO) 15 | logger = logging.getLogger("mcp-shell-server") 16 | 17 | app: Server = Server("mcp-shell-server") 18 | 19 | 20 | class ExecuteToolHandler: 21 | """Handler for shell command execution""" 22 | 23 | name = "shell_execute" 24 | description = "Execute a shell command" 25 | 26 | def __init__(self): 27 | self.executor = ShellExecutor() 28 | 29 | def get_allowed_commands(self) -> list[str]: 30 | """Get the allowed commands""" 31 | return self.executor.validator.get_allowed_commands() 32 | 33 | def get_tool_description(self) -> Tool: 34 | """Get the tool description for the execute command""" 35 | return Tool( 36 | name=self.name, 37 | description=f"{self.description}\nAllowed commands: {', '.join(self.get_allowed_commands())}", 38 | inputSchema={ 39 | "type": "object", 40 | "properties": { 41 | "command": { 42 | "type": "array", 43 | "items": {"type": "string"}, 44 | "description": "Command and its arguments as array", 45 | }, 46 | "stdin": { 47 | "type": "string", 48 | "description": "Input to be passed to the command via stdin", 49 | }, 50 | "directory": { 51 | "type": "string", 52 | "description": "Working directory where the command will be executed", 53 | }, 54 | "timeout": { 55 | "type": "integer", 56 | "description": "Maximum execution time in seconds", 57 | "minimum": 0, 58 | }, 59 | }, 60 | "required": ["command", "directory"], 61 | }, 62 | ) 63 | 64 | async def run_tool(self, arguments: dict) -> Sequence[TextContent]: 65 | """Execute the shell command with the given arguments""" 66 | command = arguments.get("command", []) 67 | stdin = arguments.get("stdin") 68 | directory = arguments.get("directory", "/tmp") # default to /tmp for safety 69 | timeout = arguments.get("timeout") 70 | 71 | if not command: 72 | raise ValueError("No command provided") 73 | 74 | if not isinstance(command, list): 75 | raise ValueError("'command' must be an array") 76 | 77 | # Make sure directory exists 78 | if not directory: 79 | raise ValueError("Directory is required") 80 | 81 | content: list[TextContent] = [] 82 | try: 83 | # Handle execution with timeout 84 | try: 85 | result = await asyncio.wait_for( 86 | self.executor.execute( 87 | command, directory, stdin, None 88 | ), # Pass None for timeout 89 | timeout=timeout, 90 | ) 91 | except asyncio.TimeoutError as e: 92 | raise ValueError("Command execution timed out") from e 93 | 94 | if result.get("error"): 95 | raise ValueError(result["error"]) 96 | 97 | # Add stdout if present 98 | if result.get("stdout"): 99 | content.append(TextContent(type="text", text=result["stdout"])) 100 | 101 | # Add stderr if present (filter out specific messages) 102 | stderr = result.get("stderr") 103 | if stderr and "cannot set terminal process group" not in stderr: 104 | content.append(TextContent(type="text", text=stderr)) 105 | 106 | except asyncio.TimeoutError as e: 107 | raise ValueError(f"Command timed out after {timeout} seconds") from e 108 | 109 | return content 110 | 111 | 112 | # Initialize tool handlers 113 | tool_handler = ExecuteToolHandler() 114 | 115 | 116 | @app.list_tools() 117 | async def list_tools() -> list[Tool]: 118 | """List available tools.""" 119 | return [tool_handler.get_tool_description()] 120 | 121 | 122 | @app.call_tool() 123 | async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]: 124 | """Handle tool calls""" 125 | try: 126 | if name != tool_handler.name: 127 | raise ValueError(f"Unknown tool: {name}") 128 | 129 | if not isinstance(arguments, dict): 130 | raise ValueError("Arguments must be a dictionary") 131 | 132 | return await tool_handler.run_tool(arguments) 133 | 134 | except Exception as e: 135 | logger.error(traceback.format_exc()) 136 | raise RuntimeError(f"Error executing command: {str(e)}") from e 137 | 138 | 139 | async def main() -> None: 140 | """Main entry point for the MCP shell server""" 141 | logger.info(f"Starting MCP shell server v{__version__}") 142 | try: 143 | from mcp.server.stdio import stdio_server 144 | 145 | async with stdio_server() as (read_stream, write_stream): 146 | await app.run( 147 | read_stream, write_stream, app.create_initialization_options() 148 | ) 149 | except Exception as e: 150 | logger.error(f"Server error: {str(e)}") 151 | raise 152 | -------------------------------------------------------------------------------- /src/mcp_shell_server/shell_executor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import pwd 5 | import shlex 6 | import time 7 | from typing import IO, Any, Dict, List, Optional, Union 8 | 9 | from mcp_shell_server.command_preprocessor import CommandPreProcessor 10 | from mcp_shell_server.command_validator import CommandValidator 11 | from mcp_shell_server.directory_manager import DirectoryManager 12 | from mcp_shell_server.io_redirection_handler import IORedirectionHandler 13 | from mcp_shell_server.process_manager import ProcessManager 14 | 15 | 16 | class ShellExecutor: 17 | """ 18 | Executes shell commands in a secure manner by validating against a whitelist. 19 | """ 20 | 21 | def __init__(self, process_manager: Optional[ProcessManager] = None): 22 | """ 23 | Initialize the executor with a command validator, directory manager and IO handler. 24 | Args: 25 | process_manager: Optional ProcessManager instance for testing 26 | """ 27 | self.validator = CommandValidator() 28 | self.directory_manager = DirectoryManager() 29 | self.io_handler = IORedirectionHandler() 30 | self.preprocessor = CommandPreProcessor() 31 | self.process_manager = ( 32 | process_manager if process_manager is not None else ProcessManager() 33 | ) 34 | 35 | def _validate_command(self, command: List[str]) -> None: 36 | """ 37 | Validate if the command is allowed to be executed. 38 | 39 | Args: 40 | command (List[str]): Command and its arguments 41 | 42 | Raises: 43 | ValueError: If the command is empty, not allowed, or contains invalid shell operators 44 | """ 45 | if not command: 46 | raise ValueError("Empty command") 47 | 48 | self.validator.validate_command(command) 49 | 50 | def _validate_directory(self, directory: Optional[str]) -> None: 51 | """ 52 | Validate if the directory exists and is accessible. 53 | 54 | Args: 55 | directory (Optional[str]): Directory path to validate 56 | 57 | Raises: 58 | ValueError: If the directory doesn't exist, not absolute or is not accessible 59 | """ 60 | self.directory_manager.validate_directory(directory) 61 | 62 | def _validate_no_shell_operators(self, cmd: str) -> None: 63 | """Validate that the command does not contain shell operators""" 64 | self.validator.validate_no_shell_operators(cmd) 65 | 66 | def _validate_pipeline(self, commands: List[str]) -> Dict[str, str]: 67 | """Validate pipeline command and ensure all parts are allowed 68 | 69 | Returns: 70 | Dict[str, str]: Error message if validation fails, empty dict if success 71 | """ 72 | return self.validator.validate_pipeline(commands) 73 | 74 | def _get_default_shell(self) -> str: 75 | """Get the login shell of the current user""" 76 | try: 77 | return pwd.getpwuid(os.getuid()).pw_shell 78 | except (ImportError, KeyError): 79 | return os.environ.get("SHELL", "/bin/sh") 80 | 81 | async def execute( 82 | self, 83 | command: List[str], 84 | directory: str, 85 | stdin: Optional[str] = None, 86 | timeout: Optional[int] = None, 87 | envs: Optional[Dict[str, str]] = None, 88 | ) -> Dict[str, Any]: 89 | start_time = time.time() 90 | process = None # Initialize process variable 91 | 92 | try: 93 | # Validate directory if specified 94 | try: 95 | self._validate_directory(directory) 96 | except ValueError as e: 97 | return { 98 | "error": str(e), 99 | "status": 1, 100 | "stdout": "", 101 | "stderr": str(e), 102 | "execution_time": time.time() - start_time, 103 | } 104 | 105 | # Process command 106 | preprocessed_command = self.preprocessor.preprocess_command(command) 107 | cleaned_command = self.preprocessor.clean_command(preprocessed_command) 108 | if not cleaned_command: 109 | return { 110 | "error": "Empty command", 111 | "status": 1, 112 | "stdout": "", 113 | "stderr": "Empty command", 114 | "execution_time": time.time() - start_time, 115 | } 116 | 117 | # First check for pipe operators and handle pipeline 118 | if "|" in cleaned_command: 119 | try: 120 | # Validate pipeline first using the validator 121 | try: 122 | self.validator.validate_pipeline(cleaned_command) 123 | except ValueError as e: 124 | return { 125 | "error": str(e), 126 | "status": 1, 127 | "stdout": "", 128 | "stderr": str(e), 129 | "execution_time": time.time() - start_time, 130 | } 131 | 132 | # Split commands 133 | commands = self.preprocessor.split_pipe_commands(cleaned_command) 134 | if not commands: 135 | raise ValueError("Empty command before pipe operator") 136 | 137 | return await self._execute_pipeline( 138 | commands, directory, timeout, envs 139 | ) 140 | except ValueError as e: 141 | return { 142 | "error": str(e), 143 | "status": 1, 144 | "stdout": "", 145 | "stderr": str(e), 146 | "execution_time": time.time() - start_time, 147 | } 148 | 149 | # Then check for other shell operators 150 | for token in cleaned_command: 151 | try: 152 | self.validator.validate_no_shell_operators(token) 153 | except ValueError as e: 154 | return { 155 | "error": str(e), 156 | "status": 1, 157 | "stdout": "", 158 | "stderr": str(e), 159 | "execution_time": time.time() - start_time, 160 | } 161 | 162 | # Single command execution 163 | try: 164 | cmd, redirects = self.preprocessor.parse_command(cleaned_command) 165 | except ValueError as e: 166 | return { 167 | "error": str(e), 168 | "status": 1, 169 | "stdout": "", 170 | "stderr": str(e), 171 | "execution_time": time.time() - start_time, 172 | } 173 | 174 | try: 175 | self.validator.validate_command(cmd) 176 | except ValueError as e: 177 | return { 178 | "error": str(e), 179 | "status": 1, 180 | "stdout": "", 181 | "stderr": str(e), 182 | "execution_time": time.time() - start_time, 183 | } 184 | 185 | # Directory validation 186 | if directory: 187 | if not os.path.exists(directory): 188 | return { 189 | "error": f"Directory does not exist: {directory}", 190 | "status": 1, 191 | "stdout": "", 192 | "stderr": f"Directory does not exist: {directory}", 193 | "execution_time": time.time() - start_time, 194 | } 195 | if not os.path.isdir(directory): 196 | return { 197 | "error": f"Not a directory: {directory}", 198 | "status": 1, 199 | "stdout": "", 200 | "stderr": f"Not a directory: {directory}", 201 | "execution_time": time.time() - start_time, 202 | } 203 | if not cleaned_command: 204 | raise ValueError("Empty command") 205 | 206 | # Initialize stdout_handle with default value 207 | stdout_handle: Union[IO[Any], int] = asyncio.subprocess.PIPE 208 | 209 | try: 210 | # Process redirections 211 | cmd, redirects = self.io_handler.process_redirections(cleaned_command) 212 | 213 | # Setup handles for redirection 214 | handles = await self.io_handler.setup_redirects(redirects, directory) 215 | 216 | # Get stdin and stdout from handles if present 217 | stdin_data = handles.get("stdin_data") 218 | if isinstance(stdin_data, str): 219 | stdin = stdin_data 220 | 221 | # Get stdout handle if present 222 | stdout_value = handles.get("stdout") 223 | if isinstance(stdout_value, (IO, int)): 224 | stdout_handle = stdout_value 225 | 226 | except ValueError as e: 227 | return { 228 | "error": str(e), 229 | "status": 1, 230 | "stdout": "", 231 | "stderr": str(e), 232 | "execution_time": time.time() - start_time, 233 | } 234 | 235 | # Execute the command with interactive shell 236 | shell = self._get_default_shell() 237 | shell_cmd = self.preprocessor.create_shell_command(cmd) 238 | shell_cmd = f"{shell} -i -c {shlex.quote(shell_cmd)}" 239 | 240 | process = await self.process_manager.create_process( 241 | shell_cmd, directory, stdout_handle=stdout_handle, envs=envs 242 | ) 243 | 244 | try: 245 | # Send input if provided 246 | stdin_bytes = stdin.encode() if stdin else None 247 | 248 | async def communicate_with_timeout(): 249 | try: 250 | return await process.communicate(input=stdin_bytes) 251 | except Exception as e: 252 | try: 253 | await process.wait() 254 | except Exception: 255 | pass 256 | raise e 257 | 258 | try: 259 | # プロセス通信実行 260 | stdout, stderr = await asyncio.shield( 261 | self.process_manager.execute_with_timeout( 262 | process, stdin=stdin, timeout=timeout 263 | ) 264 | ) 265 | 266 | # ファイルハンドル処理 267 | if isinstance(stdout_handle, IO): 268 | try: 269 | stdout_handle.close() 270 | except (IOError, OSError) as e: 271 | logging.warning(f"Error closing stdout: {e}") 272 | 273 | # Handle case where returncode is None 274 | final_returncode = ( 275 | 0 if process.returncode is None else process.returncode 276 | ) 277 | 278 | return { 279 | "error": None, 280 | "stdout": stdout.decode().strip() if stdout else "", 281 | "stderr": stderr.decode().strip() if stderr else "", 282 | "returncode": final_returncode, 283 | "status": process.returncode, 284 | "execution_time": time.time() - start_time, 285 | "directory": directory, 286 | } 287 | 288 | except asyncio.TimeoutError: 289 | # タイムアウト時のプロセスクリーンアップ 290 | if process and process.returncode is None: 291 | try: 292 | process.kill() 293 | await asyncio.shield(process.wait()) 294 | except ProcessLookupError: 295 | # Process already terminated 296 | pass 297 | 298 | # ファイルハンドルクリーンアップ 299 | if isinstance(stdout_handle, IO): 300 | stdout_handle.close() 301 | 302 | return { 303 | "error": f"Command timed out after {timeout} seconds", 304 | "status": -1, 305 | "stdout": "", 306 | "stderr": f"Command timed out after {timeout} seconds", 307 | "execution_time": time.time() - start_time, 308 | } 309 | 310 | except Exception as e: # Exception handler for subprocess 311 | if isinstance(stdout_handle, IO): 312 | stdout_handle.close() 313 | return { 314 | "error": str(e), 315 | "status": 1, 316 | "stdout": "", 317 | "stderr": str(e), 318 | "execution_time": time.time() - start_time, 319 | } 320 | 321 | finally: 322 | if process and process.returncode is None: 323 | process.kill() 324 | await process.wait() 325 | 326 | async def _execute_pipeline( 327 | self, 328 | commands: List[List[str]], 329 | directory: Optional[str] = None, 330 | timeout: Optional[int] = None, 331 | envs: Optional[Dict[str, str]] = None, 332 | ) -> Dict[str, Any]: 333 | start_time = time.time() 334 | try: 335 | # Validate all commands before execution 336 | for cmd in commands: 337 | # Make sure each command is allowed 338 | self.validator.validate_command(cmd) 339 | 340 | # Initialize IO variables 341 | parsed_commands = [] 342 | first_stdin: Optional[bytes] = None 343 | pipeline_stdout: Union[IO[Any], int, None] = None 344 | first_redirects = None 345 | last_redirects = None 346 | 347 | # Process redirections for all commands 348 | for i, command in enumerate(commands): 349 | cmd, redirects = self.io_handler.process_redirections(command) 350 | parsed_commands.append(cmd) 351 | 352 | if i == 0: # First command 353 | first_redirects = redirects 354 | elif i == len(commands) - 1: # Last command 355 | last_redirects = redirects 356 | 357 | # Setup first and last command redirections 358 | if first_redirects: 359 | handles = await self.io_handler.setup_redirects( 360 | first_redirects, directory 361 | ) 362 | stdin_data = handles.get("stdin_data") 363 | if stdin_data: 364 | first_stdin = ( 365 | stdin_data.encode() if isinstance(stdin_data, str) else None 366 | ) 367 | 368 | if last_redirects: 369 | handles = await self.io_handler.setup_redirects( 370 | last_redirects, directory 371 | ) 372 | stdout_value = handles.get("stdout") 373 | pipeline_stdout = ( 374 | stdout_value if isinstance(stdout_value, (IO, int)) else None 375 | ) 376 | 377 | # Execute pipeline 378 | try: 379 | stdout, stderr, returncode = ( 380 | await self.process_manager.execute_pipeline( 381 | [command[0] for command in parsed_commands], 382 | first_stdin=first_stdin, 383 | last_stdout=pipeline_stdout, 384 | directory=directory, 385 | timeout=timeout, 386 | envs=envs, 387 | ) 388 | ) 389 | 390 | final_output = stdout.decode("utf-8") if stdout else "" 391 | final_stderr = stderr.decode("utf-8") if stderr else "" 392 | 393 | return { 394 | "error": None, 395 | "stdout": final_output, 396 | "stderr": final_stderr, 397 | "status": returncode, 398 | "execution_time": time.time() - start_time, 399 | "directory": directory, 400 | } 401 | 402 | except Exception as e: 403 | await self.process_manager.cleanup_processes([]) 404 | return { 405 | "error": str(e), 406 | "stdout": "", 407 | "stderr": str(e), 408 | "status": -1 if isinstance(e, TimeoutError) else 1, 409 | "execution_time": time.time() - start_time, 410 | } 411 | 412 | finally: 413 | await self.io_handler.cleanup_handles({"stdout": pipeline_stdout}) 414 | 415 | except Exception as e: 416 | return { 417 | "error": str(e), 418 | "stdout": "", 419 | "stderr": str(e), 420 | "status": 1, 421 | "execution_time": time.time() - start_time, 422 | } 423 | -------------------------------------------------------------------------------- /src/mcp_shell_server/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.0-dev" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test configuration and fixtures. 3 | """ 4 | 5 | import asyncio 6 | from typing import IO 7 | from unittest.mock import AsyncMock, MagicMock 8 | 9 | import pytest 10 | import pytest_asyncio 11 | 12 | from mcp_shell_server.shell_executor import ShellExecutor 13 | 14 | 15 | @pytest.fixture 16 | def mock_file(mocker): 17 | """Provide a mock file object.""" 18 | mock = mocker.MagicMock(spec=IO) 19 | mock.close = mocker.MagicMock() 20 | return mock 21 | 22 | 23 | @pytest_asyncio.fixture 24 | async def mock_process_manager(): 25 | """Provide a mock process manager.""" 26 | manager = MagicMock() 27 | 28 | # Mock process object 29 | process = AsyncMock() 30 | process.returncode = 0 31 | 32 | # Mock manager methods 33 | manager.create_process = AsyncMock() 34 | 35 | async def create_process_side_effect(*args, **kwargs): 36 | process = AsyncMock() 37 | process.returncode = 0 38 | process.communicate = AsyncMock(return_value=(b"", b"")) 39 | process.kill = AsyncMock() 40 | process.wait = AsyncMock() 41 | return process 42 | 43 | manager.create_process.side_effect = create_process_side_effect 44 | 45 | manager.execute_with_timeout = AsyncMock() 46 | manager.execute_pipeline = AsyncMock() 47 | manager.cleanup_processes = AsyncMock() 48 | 49 | # Set empty default return values - tests should override these as needed 50 | manager.execute_with_timeout.return_value = (b"", b"") 51 | manager.execute_pipeline.return_value = (b"", b"", 0) 52 | 53 | return manager 54 | 55 | 56 | @pytest_asyncio.fixture 57 | async def shell_executor_with_mock(mock_process_manager): 58 | """Provide a shell executor with mock process manager.""" 59 | executor = ShellExecutor(process_manager=mock_process_manager) 60 | return executor 61 | 62 | 63 | @pytest.fixture 64 | def temp_test_dir(tmpdir): 65 | """Provide a temporary test directory.""" 66 | return str(tmpdir) 67 | 68 | 69 | @pytest_asyncio.fixture(scope="function") 70 | async def event_loop(): 71 | """Create and provide a new event loop for each module.""" 72 | loop = asyncio.new_event_loop() 73 | asyncio.set_event_loop(loop) 74 | yield loop 75 | 76 | # Clean up the event loop 77 | try: 78 | # Close all tasks 79 | tasks = asyncio.all_tasks(loop) 80 | if tasks: 81 | # Cancel all tasks and wait for their completion 82 | for task in tasks: 83 | task.cancel() 84 | loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) 85 | 86 | # Clean up all transports 87 | if hasattr(loop, "_transports"): 88 | for transport in list(loop._transports.values()): 89 | if hasattr(transport, "close"): 90 | transport.close() 91 | 92 | # Cleanup 93 | loop.stop() 94 | asyncio.set_event_loop(None) 95 | await loop.shutdown_asyncgens() 96 | loop.close() 97 | except Exception as e: 98 | print(f"Error during loop cleanup: {e}") 99 | -------------------------------------------------------------------------------- /tests/conftest_new.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test configuration and fixtures. 3 | """ 4 | 5 | import asyncio 6 | import os 7 | 8 | import pytest_asyncio 9 | 10 | 11 | @pytest_asyncio.fixture(autouse=True) 12 | async def cleanup_loop(): 13 | """Run after each test to ensure proper event loop cleanup.""" 14 | yield 15 | if hasattr(asyncio, "current_task") and asyncio.current_task() is not None: 16 | try: 17 | await asyncio.sleep(0) # Allow pending callbacks to complete 18 | except Exception: 19 | pass 20 | 21 | 22 | def pytest_configure(config): 23 | """Configure pytest-asyncio defaults""" 24 | # Enable command execution for tests 25 | os.environ["ALLOW_COMMANDS"] = "1" 26 | # Add allowed commands for tests 27 | os.environ["ALLOWED_COMMANDS"] = ( 28 | "echo,sleep,cat,ls,pwd,touch,mkdir,rm,mv,cp,grep,awk,sed" 29 | ) 30 | -------------------------------------------------------------------------------- /tests/test_command_validator.py: -------------------------------------------------------------------------------- 1 | """Test cases for the CommandValidator class.""" 2 | 3 | import pytest 4 | 5 | from mcp_shell_server.command_validator import CommandValidator 6 | 7 | 8 | def clear_env(monkeypatch): 9 | monkeypatch.delenv("ALLOW_COMMANDS", raising=False) 10 | monkeypatch.delenv("ALLOWED_COMMANDS", raising=False) 11 | 12 | 13 | @pytest.fixture 14 | def validator(): 15 | return CommandValidator() 16 | 17 | 18 | def test_get_allowed_commands(validator, monkeypatch): 19 | clear_env(monkeypatch) 20 | monkeypatch.setenv("ALLOW_COMMANDS", "cmd1,cmd2") 21 | monkeypatch.setenv("ALLOWED_COMMANDS", "cmd3,cmd4") 22 | assert set(validator.get_allowed_commands()) == {"cmd1", "cmd2", "cmd3", "cmd4"} 23 | 24 | 25 | def test_is_command_allowed(validator, monkeypatch): 26 | clear_env(monkeypatch) 27 | monkeypatch.setenv("ALLOW_COMMANDS", "allowed_cmd") 28 | assert validator.is_command_allowed("allowed_cmd") 29 | assert not validator.is_command_allowed("disallowed_cmd") 30 | 31 | 32 | def test_validate_no_shell_operators(validator): 33 | validator.validate_no_shell_operators("echo") # Should not raise 34 | with pytest.raises(ValueError, match="Unexpected shell operator"): 35 | validator.validate_no_shell_operators(";") 36 | with pytest.raises(ValueError, match="Unexpected shell operator"): 37 | validator.validate_no_shell_operators("&&") 38 | 39 | 40 | def test_validate_pipeline(validator, monkeypatch): 41 | clear_env(monkeypatch) 42 | monkeypatch.setenv("ALLOW_COMMANDS", "ls,grep") 43 | 44 | # Valid pipeline 45 | validator.validate_pipeline(["ls", "|", "grep", "test"]) 46 | 47 | # Empty command before pipe 48 | with pytest.raises(ValueError, match="Empty command before pipe operator"): 49 | validator.validate_pipeline(["|", "grep", "test"]) 50 | 51 | # Command not allowed 52 | with pytest.raises(ValueError, match="Command not allowed"): 53 | validator.validate_pipeline(["invalid_cmd", "|", "grep", "test"]) 54 | 55 | 56 | def test_validate_command(validator, monkeypatch): 57 | clear_env(monkeypatch) 58 | 59 | # No allowed commands 60 | with pytest.raises(ValueError, match="No commands are allowed"): 61 | validator.validate_command(["cmd"]) 62 | 63 | monkeypatch.setenv("ALLOW_COMMANDS", "allowed_cmd") 64 | 65 | # Empty command 66 | with pytest.raises(ValueError, match="Empty command"): 67 | validator.validate_command([]) 68 | 69 | # Command not allowed 70 | with pytest.raises(ValueError, match="Command not allowed"): 71 | validator.validate_command(["disallowed_cmd"]) 72 | 73 | # Command allowed 74 | validator.validate_command(["allowed_cmd", "-arg"]) # Should not raise 75 | -------------------------------------------------------------------------------- /tests/test_directory_manager.py: -------------------------------------------------------------------------------- 1 | """Tests for directory_manager module.""" 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from mcp_shell_server.directory_manager import DirectoryManager 8 | 9 | 10 | def test_validate_directory(tmp_path): 11 | """Test directory validation.""" 12 | manager = DirectoryManager() 13 | test_dir = str(tmp_path) 14 | 15 | # Valid directory 16 | manager.validate_directory(test_dir) 17 | 18 | # None directory 19 | with pytest.raises(ValueError, match="Directory is required"): 20 | manager.validate_directory(None) 21 | 22 | # Relative path 23 | with pytest.raises(ValueError, match="Directory must be an absolute path"): 24 | manager.validate_directory("relative/path") 25 | 26 | # Non-existent directory 27 | nonexistent = os.path.join(test_dir, "nonexistent") 28 | with pytest.raises(ValueError, match="Directory does not exist"): 29 | manager.validate_directory(nonexistent) 30 | 31 | # Not a directory (create a file) 32 | test_file = os.path.join(test_dir, "test.txt") 33 | with open(test_file, "w") as f: 34 | f.write("test") 35 | with pytest.raises(ValueError, match="Not a directory"): 36 | manager.validate_directory(test_file) 37 | 38 | 39 | def test_get_absolute_path(tmp_path): 40 | """Test absolute path resolution.""" 41 | manager = DirectoryManager() 42 | test_dir = str(tmp_path) 43 | 44 | # Already absolute path 45 | abs_path = os.path.join(test_dir, "test") 46 | assert manager.get_absolute_path(abs_path) == abs_path 47 | 48 | # Relative path without base directory 49 | rel_path = "test/path" 50 | expected = os.path.abspath(rel_path) 51 | assert manager.get_absolute_path(rel_path) == expected 52 | 53 | # Relative path with base directory 54 | rel_path = "test/path" 55 | expected = os.path.join(test_dir, rel_path) 56 | assert manager.get_absolute_path(rel_path, test_dir) == expected 57 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | def test_main(mocker): 5 | """Test the main entry point""" 6 | # Mock asyncio.run 7 | mock_run = mocker.patch("asyncio.run") 8 | 9 | # Import main after mocking 10 | from mcp_shell_server import main 11 | 12 | # Call the main function 13 | main() 14 | 15 | # Verify that asyncio.run was called 16 | assert mock_run.call_count == 1 17 | # The first argument of the call should be a coroutine object 18 | args = mock_run.call_args[0] 19 | assert len(args) == 1 20 | coro = args[0] 21 | assert asyncio.iscoroutine(coro) 22 | # Clean up the coroutine 23 | coro.close() 24 | -------------------------------------------------------------------------------- /tests/test_io_redirection_handler.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tumf/mcp-shell-server/987f137d85c033394d65e7c4179e96ddb849e1c2/tests/test_io_redirection_handler.py -------------------------------------------------------------------------------- /tests/test_process_manager.py: -------------------------------------------------------------------------------- 1 | """Tests for the ProcessManager class.""" 2 | 3 | import asyncio 4 | from unittest.mock import AsyncMock, MagicMock, patch 5 | 6 | import pytest 7 | 8 | from mcp_shell_server.process_manager import ProcessManager 9 | 10 | 11 | def create_mock_process(): 12 | """Create a mock process with all required attributes.""" 13 | process = MagicMock() 14 | process.returncode = 0 15 | process.communicate = AsyncMock(return_value=(b"output", b"error")) 16 | process.wait = AsyncMock(return_value=0) 17 | process.terminate = MagicMock() 18 | process.kill = MagicMock() 19 | return process 20 | 21 | 22 | @pytest.fixture 23 | def process_manager(): 24 | """Fixture for ProcessManager instance.""" 25 | return ProcessManager() 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_create_process(process_manager): 30 | """Test creating a process with basic parameters.""" 31 | mock_proc = create_mock_process() 32 | with patch( 33 | "mcp_shell_server.process_manager.asyncio.create_subprocess_shell", 34 | new_callable=AsyncMock, 35 | return_value=mock_proc, 36 | ) as mock_create: 37 | process = await process_manager.create_process( 38 | "echo 'test'", 39 | directory="/tmp", 40 | stdin="input", 41 | ) 42 | 43 | assert process == mock_proc 44 | assert process == mock_proc 45 | mock_create.assert_called_once() 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_execute_with_timeout_success(process_manager): 50 | """Test executing a process with successful completion.""" 51 | mock_proc = create_mock_process() 52 | mock_proc.returncode = 0 53 | mock_proc.communicate.return_value = (b"output", b"error") 54 | 55 | stdout, stderr = await process_manager.execute_with_timeout( 56 | mock_proc, 57 | stdin="input", 58 | timeout=10, 59 | ) 60 | 61 | assert stdout == b"output" 62 | assert stderr == b"error" 63 | mock_proc.communicate.assert_called_once() 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_execute_with_timeout_timeout(process_manager): 68 | """Test executing a process that times out.""" 69 | mock_proc = create_mock_process() 70 | exc = asyncio.TimeoutError("Process timed out") 71 | mock_proc.communicate.side_effect = exc 72 | mock_proc.returncode = None # プロセスがまだ実行中の状態をシミュレート 73 | 74 | # プロセスの終了状態をシミュレート 75 | async def set_returncode(): 76 | mock_proc.returncode = -15 # SIGTERM 77 | 78 | mock_proc.wait.side_effect = set_returncode 79 | 80 | with pytest.raises(TimeoutError): 81 | await process_manager.execute_with_timeout( 82 | mock_proc, 83 | timeout=1, 84 | ) 85 | 86 | mock_proc.terminate.assert_called_once() 87 | 88 | 89 | @pytest.mark.asyncio 90 | async def test_execute_pipeline_success(process_manager): 91 | """Test executing a pipeline of commands successfully.""" 92 | mock_proc1 = create_mock_process() 93 | mock_proc1.communicate.return_value = (b"output1", b"") 94 | mock_proc1.returncode = 0 95 | 96 | mock_proc2 = create_mock_process() 97 | mock_proc2.communicate.return_value = (b"final output", b"") 98 | mock_proc2.returncode = 0 99 | 100 | with patch( 101 | "mcp_shell_server.process_manager.asyncio.create_subprocess_shell", 102 | new_callable=AsyncMock, 103 | side_effect=[mock_proc1, mock_proc2], 104 | ) as mock_create: 105 | stdout, stderr, return_code = await process_manager.execute_pipeline( 106 | ["echo 'test'", "grep test"], 107 | directory="/tmp", 108 | timeout=10, 109 | ) 110 | 111 | assert stdout == b"final output" 112 | assert stderr == b"" 113 | assert return_code == 0 114 | assert mock_create.call_count == 2 # Verify subprocess creation calls 115 | 116 | # Verify the command arguments for each subprocess call 117 | calls = mock_create.call_args_list 118 | assert "echo" in calls[0].args[0] 119 | assert "grep" in calls[1].args[0] 120 | 121 | 122 | @pytest.mark.asyncio 123 | async def test_execute_pipeline_with_error(process_manager): 124 | """Test executing a pipeline where a command fails.""" 125 | mock_proc = create_mock_process() 126 | mock_proc.communicate.return_value = (b"", b"error message") 127 | mock_proc.returncode = 1 128 | 129 | create_process_mock = AsyncMock(return_value=mock_proc) 130 | 131 | with patch.object(process_manager, "create_process", create_process_mock): 132 | with pytest.raises(ValueError, match="error message"): 133 | await process_manager.execute_pipeline( 134 | ["invalid_command"], 135 | directory="/tmp", 136 | ) 137 | 138 | 139 | @pytest.mark.asyncio 140 | async def test_cleanup_processes(process_manager): 141 | """Test cleaning up processes.""" 142 | # Create mock processes with different states 143 | running_proc = create_mock_process() 144 | running_proc.returncode = None 145 | 146 | completed_proc = create_mock_process() 147 | completed_proc.returncode = 0 148 | 149 | # Execute cleanup 150 | await process_manager.cleanup_processes([running_proc, completed_proc]) 151 | 152 | # Verify running process was killed and waited for 153 | running_proc.kill.assert_called_once() 154 | running_proc.wait.assert_awaited_once() 155 | 156 | # Verify completed process was not killed or waited for 157 | completed_proc.kill.assert_not_called() 158 | completed_proc.wait.assert_not_called() 159 | 160 | 161 | @pytest.mark.asyncio 162 | async def test_create_process_with_error(process_manager): 163 | """Test creating a process that fails to start.""" 164 | with patch( 165 | "asyncio.create_subprocess_shell", 166 | new_callable=AsyncMock, 167 | side_effect=OSError("Failed to create process"), 168 | ): 169 | with pytest.raises(ValueError, match="Failed to create process"): 170 | await process_manager.create_process("invalid command", directory="/tmp") 171 | 172 | 173 | @pytest.mark.asyncio 174 | async def test_execute_pipeline_empty_commands(process_manager): 175 | """Test executing a pipeline with no commands.""" 176 | with pytest.raises(ValueError, match="No commands provided"): 177 | await process_manager.execute_pipeline([], directory="/tmp") 178 | 179 | 180 | @pytest.mark.asyncio 181 | async def test_execute_pipeline_timeout(process_manager): 182 | """Test executing a pipeline that times out.""" 183 | mock_proc = create_mock_process() 184 | mock_proc.communicate.side_effect = TimeoutError("Process timed out") 185 | 186 | with patch.object(process_manager, "create_process", return_value=mock_proc): 187 | with pytest.raises(TimeoutError, match="Process timed out"): 188 | await process_manager.execute_pipeline( 189 | ["sleep 10"], 190 | directory="/tmp", 191 | timeout=1, 192 | ) 193 | mock_proc.kill.assert_called_once() 194 | -------------------------------------------------------------------------------- /tests/test_process_manager_macos.py: -------------------------------------------------------------------------------- 1 | # tests/test_process_manager_macos.py 2 | import asyncio 3 | import platform 4 | import subprocess 5 | 6 | import pytest 7 | 8 | pytestmark = [ 9 | pytest.mark.skipif( 10 | platform.system() != "Darwin", reason="These tests only run on macOS" 11 | ), 12 | pytest.mark.macos, 13 | pytest.mark.slow, 14 | ] 15 | 16 | 17 | @pytest.fixture 18 | def process_manager(): 19 | from mcp_shell_server.process_manager import ProcessManager 20 | 21 | pm = ProcessManager() 22 | try: 23 | yield pm 24 | finally: 25 | asyncio.run(pm.cleanup_all()) 26 | 27 | 28 | def get_process_status(pid: int) -> str: 29 | """Get process status using ps command.""" 30 | try: 31 | ps = subprocess.run( 32 | ["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True 33 | ) 34 | return ps.stdout.strip() 35 | except subprocess.CalledProcessError: 36 | return "" 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_zombie_process_cleanup(process_manager): 41 | """Test that background processes don't become zombies.""" 42 | cmd = ["sh", "-c", "sleep 0.5 & wait"] 43 | process = await process_manager.start_process(cmd) 44 | 45 | # Wait for the background process to finish 46 | await asyncio.sleep(1) 47 | 48 | # Get process status 49 | status = get_process_status(process.pid) 50 | 51 | # Verify process is either gone or not zombie (Z state) 52 | assert "Z" not in status, f"Process {process.pid} is zombie (status: {status})" 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_process_timeout(process_manager): 57 | """Test process timeout functionality.""" 58 | # Start a process that should timeout 59 | cmd = ["sleep", "10"] 60 | process = await process_manager.start_process(cmd) 61 | 62 | try: 63 | # Communicate with timeout 64 | with pytest.raises(TimeoutError): 65 | _, _ = await process_manager.execute_with_timeout(process, timeout=1) 66 | 67 | # プロセスが終了するまで待つ 68 | try: 69 | await asyncio.wait_for(process.wait(), timeout=1.0) 70 | except asyncio.TimeoutError: 71 | process.kill() # Force kill 72 | 73 | # Wait for termination 74 | await asyncio.wait_for(process.wait(), timeout=0.5) 75 | 76 | # Verify process was terminated 77 | assert process.returncode is not None 78 | assert not process.is_running() 79 | finally: 80 | if process.returncode is None: 81 | try: 82 | process.kill() 83 | await asyncio.wait_for(process.wait(), timeout=0.5) 84 | except (ProcessLookupError, asyncio.TimeoutError): 85 | pass 86 | 87 | 88 | @pytest.mark.asyncio 89 | async def test_multiple_process_cleanup(process_manager): 90 | """Test cleanup of multiple processes.""" 91 | # Start multiple background processes 92 | # Start multiple processes in parallel 93 | processes = await asyncio.gather( 94 | *[process_manager.start_process(["sleep", "2"]) for _ in range(3)] 95 | ) 96 | 97 | # Give them a moment to start 98 | await asyncio.sleep(0.1) 99 | 100 | try: 101 | # Verify they're all running 102 | assert all(p.is_running() for p in processes) 103 | 104 | # Cleanup 105 | await process_manager.cleanup_all() 106 | 107 | # Give cleanup a moment to complete 108 | await asyncio.sleep(0.1) 109 | 110 | # Verify all processes are gone 111 | for p in processes: 112 | status = get_process_status(p.pid) 113 | assert status == "", f"Process {p.pid} still exists with status: {status}" 114 | finally: 115 | # Ensure cleanup in case of test failure 116 | for p in processes: 117 | if p.returncode is None: 118 | try: 119 | p.kill() 120 | except ProcessLookupError: 121 | pass 122 | 123 | 124 | @pytest.mark.asyncio 125 | async def test_process_group_termination(process_manager): 126 | """Test that entire process group is terminated.""" 127 | # Create a process that spawns children 128 | cmd = ["sh", "-c", "sleep 10 & sleep 10 & sleep 10 & wait"] 129 | process = await process_manager.start_process(cmd) 130 | 131 | try: 132 | # Give processes time to start 133 | await asyncio.sleep(0.5) 134 | 135 | # Kill the main process 136 | process.kill() 137 | 138 | # Wait a moment for cleanup 139 | await asyncio.sleep(0.5) 140 | 141 | # Check if any processes from the group remain 142 | ps = subprocess.run(["pgrep", "-g", str(process.pid)], capture_output=True) 143 | assert ps.returncode != 0, "Process group still exists" 144 | finally: 145 | if process.returncode is None: 146 | try: 147 | process.kill() 148 | except ProcessLookupError: 149 | pass 150 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import tempfile 4 | 5 | import pytest 6 | from mcp.types import TextContent, Tool 7 | 8 | from mcp_shell_server.server import call_tool, list_tools 9 | 10 | 11 | # Mock process class 12 | class MockProcess: 13 | def __init__(self, stdout=None, stderr=None, returncode=0): 14 | self.stdout = stdout 15 | self.stderr = stderr 16 | self.returncode = returncode 17 | self._input = None 18 | 19 | async def communicate(self, input=None): 20 | self._input = input 21 | if self._input and not isinstance(self._input, bytes): 22 | self._input = self._input.encode("utf-8") 23 | 24 | # For cat command, echo back the input 25 | if self.stdout is None and self._input: 26 | return self._input, self.stderr 27 | 28 | if isinstance(self.stdout, int): 29 | self.stdout = str(self.stdout).encode("utf-8") 30 | if self.stdout is None: 31 | self.stdout = b"" 32 | if self.stderr is None: 33 | self.stderr = b"" 34 | return self.stdout, self.stderr 35 | 36 | async def wait(self): 37 | return self.returncode 38 | 39 | def kill(self): 40 | pass 41 | 42 | 43 | def setup_mock_subprocess(monkeypatch): 44 | """Set up mock subprocess to avoid interactive shell warnings""" 45 | 46 | async def mock_create_subprocess_shell( 47 | cmd, 48 | stdin=None, 49 | stdout=None, 50 | stderr=None, 51 | env=None, 52 | cwd=None, 53 | preexec_fn=None, 54 | start_new_session=None, 55 | ): 56 | # Return appropriate output based on command 57 | if "echo" in cmd: 58 | return MockProcess(stdout=b"hello world\n", stderr=b"", returncode=0) 59 | elif "pwd" in cmd: 60 | return MockProcess(stdout=cwd.encode() + b"\n", stderr=b"", returncode=0) 61 | elif "cat" in cmd: 62 | return MockProcess( 63 | stdout=None, stderr=b"", returncode=0 64 | ) # Will echo back stdin 65 | elif "ps" in cmd: 66 | return MockProcess(stdout=b"bash\n", stderr=b"", returncode=0) 67 | elif "env" in cmd: 68 | return MockProcess(stdout=b"TEST_ENV=value\n", stderr=b"", returncode=0) 69 | elif "sleep" in cmd: 70 | return MockProcess(stdout=b"", stderr=b"", returncode=0) 71 | else: 72 | return MockProcess(stdout=b"", stderr=b"", returncode=0) 73 | 74 | monkeypatch.setattr( 75 | asyncio, "create_subprocess_shell", mock_create_subprocess_shell 76 | ) 77 | 78 | 79 | @pytest.fixture 80 | def temp_test_dir(): 81 | """Create a temporary directory for testing""" 82 | with tempfile.TemporaryDirectory() as tmpdirname: 83 | # Return the real path to handle macOS /private/tmp symlink 84 | yield os.path.realpath(tmpdirname) 85 | 86 | 87 | @pytest.mark.asyncio 88 | async def test_list_tools(): 89 | """Test listing of available tools""" 90 | 91 | 92 | @pytest.mark.asyncio 93 | async def test_tool_execution_timeout(monkeypatch): 94 | """Test tool execution with timeout""" 95 | monkeypatch.setenv("ALLOW_COMMANDS", "sleep") 96 | with pytest.raises(RuntimeError, match="Command execution timed out"): 97 | await call_tool( 98 | "shell_execute", 99 | { 100 | "command": ["sleep", "2"], 101 | "directory": "/tmp", 102 | "timeout": 1, 103 | }, 104 | ) 105 | tools = await list_tools() 106 | assert len(tools) == 1 107 | tool = tools[0] 108 | assert isinstance(tool, Tool) 109 | assert tool.name == "shell_execute" 110 | assert tool.description 111 | assert tool.inputSchema["type"] == "object" 112 | assert "command" in tool.inputSchema["properties"] 113 | assert "stdin" in tool.inputSchema["properties"] 114 | assert "directory" in tool.inputSchema["properties"] 115 | assert tool.inputSchema["required"] == ["command", "directory"] 116 | 117 | 118 | @pytest.mark.asyncio 119 | async def test_call_tool_valid_command(monkeypatch, temp_test_dir): 120 | """Test execution of a valid command""" 121 | monkeypatch.setenv("ALLOW_COMMANDS", "echo") 122 | result = await call_tool( 123 | "shell_execute", 124 | {"command": ["echo", "hello world"], "directory": temp_test_dir}, 125 | ) 126 | assert len(result) == 1 127 | assert isinstance(result[0], TextContent) 128 | assert result[0].type == "text" 129 | assert result[0].text.strip() == "hello world" 130 | 131 | 132 | @pytest.mark.asyncio 133 | async def test_call_tool_with_stdin(monkeypatch, temp_test_dir): 134 | """Test command execution with stdin""" 135 | setup_mock_subprocess(monkeypatch) 136 | monkeypatch.setenv("ALLOW_COMMANDS", "cat") 137 | result = await call_tool( 138 | "shell_execute", 139 | {"command": ["cat"], "stdin": "test input", "directory": temp_test_dir}, 140 | ) 141 | assert len(result) == 1 142 | assert isinstance(result[0], TextContent) 143 | assert result[0].type == "text" 144 | assert result[0].text.strip() == "test input" 145 | 146 | 147 | @pytest.mark.asyncio 148 | async def test_call_tool_invalid_command(monkeypatch, temp_test_dir): 149 | """Test execution of an invalid command""" 150 | monkeypatch.setenv("ALLOW_COMMANDS", "echo") 151 | with pytest.raises(RuntimeError) as excinfo: 152 | await call_tool( 153 | "shell_execute", 154 | {"command": ["invalid_command"], "directory": temp_test_dir}, 155 | ) 156 | assert "Command not allowed: invalid_command" in str(excinfo.value) 157 | 158 | 159 | @pytest.mark.asyncio 160 | async def test_call_tool_unknown_tool(): 161 | """Test calling an unknown tool""" 162 | with pytest.raises(RuntimeError) as excinfo: 163 | await call_tool("unknown_tool", {}) 164 | assert "Unknown tool: unknown_tool" in str(excinfo.value) 165 | 166 | 167 | @pytest.mark.asyncio 168 | async def test_call_tool_invalid_arguments(): 169 | """Test calling a tool with invalid arguments""" 170 | with pytest.raises(RuntimeError) as excinfo: 171 | await call_tool("shell_execute", "not a dict") 172 | assert "Arguments must be a dictionary" in str(excinfo.value) 173 | 174 | 175 | @pytest.mark.asyncio 176 | async def test_call_tool_empty_command(): 177 | """Test execution with empty command""" 178 | with pytest.raises(RuntimeError) as excinfo: 179 | await call_tool("shell_execute", {"command": []}) 180 | assert "No command provided" in str(excinfo.value) 181 | 182 | 183 | # New tests for directory functionality 184 | @pytest.mark.asyncio 185 | async def test_call_tool_with_directory(temp_test_dir, monkeypatch): 186 | """Test command execution in a specific directory""" 187 | monkeypatch.setenv("ALLOW_COMMANDS", "pwd") 188 | result = await call_tool( 189 | "shell_execute", {"command": ["pwd"], "directory": temp_test_dir} 190 | ) 191 | assert len(result) == 1 192 | assert isinstance(result[0], TextContent) 193 | assert result[0].type == "text" 194 | assert result[0].text.strip() == temp_test_dir 195 | 196 | 197 | @pytest.mark.asyncio 198 | async def test_call_tool_with_file_operations(temp_test_dir, monkeypatch): 199 | """Test file operations in a specific directory""" 200 | monkeypatch.setenv("ALLOW_COMMANDS", "ls,cat") 201 | 202 | # Create a test file 203 | test_file = os.path.join(temp_test_dir, "test.txt") 204 | with open(test_file, "w") as f: 205 | f.write("test content") 206 | 207 | # Test ls command 208 | result = await call_tool( 209 | "shell_execute", {"command": ["ls"], "directory": temp_test_dir} 210 | ) 211 | assert isinstance(result[0], TextContent) 212 | assert "test.txt" in result[0].text 213 | 214 | # Test cat command 215 | result = await call_tool( 216 | "shell_execute", {"command": ["cat", "test.txt"], "directory": temp_test_dir} 217 | ) 218 | assert isinstance(result[0], TextContent) 219 | assert result[0].text.strip() == "test content" 220 | 221 | 222 | @pytest.mark.asyncio 223 | async def test_call_tool_with_nonexistent_directory(monkeypatch): 224 | """Test command execution with a non-existent directory""" 225 | monkeypatch.setenv("ALLOW_COMMANDS", "ls") 226 | with pytest.raises(RuntimeError) as excinfo: 227 | await call_tool( 228 | "shell_execute", {"command": ["ls"], "directory": "/nonexistent/directory"} 229 | ) 230 | assert "Directory does not exist: /nonexistent/directory" in str(excinfo.value) 231 | 232 | 233 | @pytest.mark.asyncio 234 | async def test_call_tool_with_file_as_directory(temp_test_dir, monkeypatch): 235 | """Test command execution with a file specified as directory""" 236 | monkeypatch.setenv("ALLOW_COMMANDS", "ls") 237 | 238 | # Create a test file 239 | test_file = os.path.join(temp_test_dir, "test.txt") 240 | with open(test_file, "w") as f: 241 | f.write("test content") 242 | 243 | with pytest.raises(RuntimeError) as excinfo: 244 | await call_tool("shell_execute", {"command": ["ls"], "directory": test_file}) 245 | assert f"Not a directory: {test_file}" in str(excinfo.value) 246 | 247 | 248 | @pytest.mark.asyncio 249 | async def test_call_tool_with_nested_directory(temp_test_dir, monkeypatch): 250 | """Test command execution in a nested directory""" 251 | monkeypatch.setenv("ALLOW_COMMANDS", "pwd,mkdir") 252 | 253 | # Create a nested directory 254 | nested_dir = os.path.join(temp_test_dir, "nested") 255 | os.mkdir(nested_dir) 256 | nested_real_path = os.path.realpath(nested_dir) 257 | 258 | result = await call_tool( 259 | "shell_execute", {"command": ["pwd"], "directory": nested_dir} 260 | ) 261 | assert isinstance(result[0], TextContent) 262 | assert result[0].text.strip() == nested_real_path 263 | 264 | 265 | @pytest.mark.asyncio 266 | async def test_call_tool_with_timeout(monkeypatch): 267 | """Test command execution with timeout""" 268 | monkeypatch.setenv("ALLOW_COMMANDS", "sleep") 269 | with pytest.raises(RuntimeError) as excinfo: 270 | await call_tool("shell_execute", {"command": ["sleep", "2"], "timeout": 1}) 271 | assert "Command execution timed out" in str(excinfo.value) 272 | 273 | 274 | @pytest.mark.asyncio 275 | async def test_call_tool_completes_within_timeout(monkeypatch): 276 | """Test command that completes within timeout period""" 277 | monkeypatch.setenv("ALLOW_COMMANDS", "sleep") 278 | result = await call_tool("shell_execute", {"command": ["sleep", "1"], "timeout": 2}) 279 | assert len(result) == 0 # sleep command produces no output 280 | 281 | 282 | @pytest.mark.asyncio 283 | async def test_invalid_command_parameter(): 284 | """Test error handling for invalid command parameter""" 285 | with pytest.raises(RuntimeError) as exc: # Changed from ValueError to RuntimeError 286 | await call_tool( 287 | "shell_execute", 288 | {"command": "not_an_array", "directory": "/tmp"}, # Should be an array 289 | ) 290 | assert "'command' must be an array" in str(exc.value) 291 | 292 | 293 | @pytest.mark.asyncio 294 | async def test_disallowed_command(monkeypatch): 295 | """Test error handling for disallowed command""" 296 | monkeypatch.setenv("ALLOW_COMMANDS", "ls") # Add allowed command 297 | with pytest.raises(RuntimeError) as exc: 298 | await call_tool( 299 | "shell_execute", 300 | { 301 | "command": ["sudo", "reboot"], # Not in allowed commands 302 | "directory": "/tmp", 303 | }, 304 | ) 305 | assert "Command not allowed: sudo" in str(exc.value) 306 | 307 | 308 | @pytest.mark.asyncio 309 | async def test_call_tool_with_stderr(monkeypatch): 310 | """Test command execution with stderr output""" 311 | 312 | async def mock_create_subprocess_shell( 313 | cmd, stdin=None, stdout=None, stderr=None, env=None, cwd=None 314 | ): 315 | # Return mock process with stderr for ls command 316 | if "ls" in cmd: 317 | return MockProcess( 318 | stdout=b"", 319 | stderr=b"ls: cannot access '/nonexistent/directory': No such file or directory\n", 320 | returncode=2, 321 | ) 322 | return MockProcess(stdout=b"", stderr=b"", returncode=0) 323 | 324 | monkeypatch.setattr( 325 | asyncio, "create_subprocess_shell", mock_create_subprocess_shell 326 | ) 327 | monkeypatch.setenv("ALLOW_COMMANDS", "ls") 328 | result = await call_tool( 329 | "shell_execute", 330 | {"command": ["ls", "/nonexistent/directory"]}, 331 | ) 332 | assert len(result) >= 1 333 | stderr_content = next( 334 | (c for c in result if isinstance(c, TextContent) and "No such file" in c.text), 335 | None, 336 | ) 337 | assert stderr_content is not None 338 | assert stderr_content.type == "text" 339 | 340 | 341 | @pytest.mark.asyncio 342 | async def test_main_server(mocker): 343 | """Test the main server function""" 344 | # Mock the stdio_server 345 | mock_read_stream = mocker.AsyncMock() 346 | mock_write_stream = mocker.AsyncMock() 347 | 348 | # Create an async context manager mock 349 | context_manager = mocker.AsyncMock() 350 | context_manager.__aenter__ = mocker.AsyncMock( 351 | return_value=(mock_read_stream, mock_write_stream) 352 | ) 353 | context_manager.__aexit__ = mocker.AsyncMock(return_value=None) 354 | 355 | # Set up stdio_server mock to return a regular function that returns the context manager 356 | def stdio_server_impl(): 357 | return context_manager 358 | 359 | mock_stdio_server = mocker.Mock(side_effect=stdio_server_impl) 360 | 361 | # Mock app.run and create_initialization_options 362 | mock_server_run = mocker.patch("mcp_shell_server.server.app.run") 363 | mock_create_init_options = mocker.patch( 364 | "mcp_shell_server.server.app.create_initialization_options" 365 | ) 366 | 367 | # Import main after setting up mocks 368 | from mcp_shell_server.server import main 369 | 370 | # Execute main function 371 | mocker.patch("mcp.server.stdio.stdio_server", mock_stdio_server) 372 | await main() 373 | 374 | # Verify interactions 375 | mock_stdio_server.assert_called_once() 376 | context_manager.__aenter__.assert_awaited_once() 377 | context_manager.__aexit__.assert_awaited_once() 378 | mock_server_run.assert_called_once_with( 379 | mock_read_stream, mock_write_stream, mock_create_init_options.return_value 380 | ) 381 | 382 | 383 | @pytest.mark.asyncio 384 | async def test_main_server_error_handling(mocker): 385 | """Test error handling in the main server function""" 386 | # Mock app.run to raise an exception 387 | mocker.patch( 388 | "mcp_shell_server.server.app.run", side_effect=RuntimeError("Test error") 389 | ) 390 | 391 | # Mock the stdio_server 392 | context_manager = mocker.AsyncMock() 393 | context_manager.__aenter__ = mocker.AsyncMock( 394 | return_value=(mocker.AsyncMock(), mocker.AsyncMock()) 395 | ) 396 | context_manager.__aexit__ = mocker.AsyncMock(return_value=None) 397 | 398 | def stdio_server_impl(): 399 | return context_manager 400 | 401 | mock_stdio_server = mocker.Mock(side_effect=stdio_server_impl) 402 | 403 | # Import main after setting up mocks 404 | from mcp_shell_server.server import main 405 | 406 | # Execute main function and expect it to raise the error 407 | mocker.patch("mcp.server.stdio.stdio_server", mock_stdio_server) 408 | with pytest.raises(RuntimeError) as exc: 409 | await main() 410 | 411 | assert str(exc.value) == "Test error" 412 | 413 | 414 | @pytest.mark.asyncio 415 | async def test_shell_startup(monkeypatch, temp_test_dir): 416 | """Test shell startup and environment""" 417 | setup_mock_subprocess(monkeypatch) 418 | monkeypatch.setenv("ALLOW_COMMANDS", "ps") 419 | result = await call_tool( 420 | "shell_execute", 421 | {"command": ["ps", "-p", "$$", "-o", "command="], "directory": temp_test_dir}, 422 | ) 423 | assert len(result) == 1 424 | assert result[0].type == "text" 425 | 426 | 427 | @pytest.mark.asyncio 428 | async def test_environment_variables(monkeypatch, temp_test_dir): 429 | """Test to check environment variables during test execution""" 430 | setup_mock_subprocess(monkeypatch) 431 | monkeypatch.setenv("ALLOW_COMMANDS", "env") 432 | result = await call_tool( 433 | "shell_execute", 434 | {"command": ["env"], "directory": temp_test_dir}, 435 | ) 436 | assert len(result) == 1 437 | -------------------------------------------------------------------------------- /tests/test_server_validation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mcp_shell_server.server import ExecuteToolHandler 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_server_input_validation(): 8 | """Test input validation in execute tool""" 9 | handler = ExecuteToolHandler() 10 | 11 | # Test command must be an array 12 | with pytest.raises(ValueError, match="'command' must be an array"): 13 | await handler.run_tool({"command": "not an array", "directory": "/"}) 14 | 15 | # Test directory is required 16 | with pytest.raises(ValueError, match="Directory is required"): 17 | await handler.run_tool({"command": ["echo", "test"], "directory": ""}) 18 | 19 | # Test command without arguments 20 | with pytest.raises(ValueError, match="No command provided"): 21 | await handler.run_tool({"directory": "/"}) 22 | -------------------------------------------------------------------------------- /tests/test_shell_executor.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from typing import IO 4 | from unittest.mock import AsyncMock 5 | 6 | import pytest 7 | 8 | 9 | def clear_env(monkeypatch): 10 | monkeypatch.delenv("ALLOW_COMMANDS", raising=False) 11 | monkeypatch.delenv("ALLOWED_COMMANDS", raising=False) 12 | 13 | 14 | @pytest.fixture 15 | def temp_test_dir(): 16 | """Create a temporary directory for testing""" 17 | with tempfile.TemporaryDirectory() as tmpdirname: 18 | # Return the real path to handle macOS /private/tmp symlink 19 | yield os.path.realpath(tmpdirname) 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_basic_command_execution( 24 | shell_executor_with_mock, 25 | mock_process_manager, 26 | temp_test_dir, 27 | monkeypatch, 28 | ): 29 | clear_env(monkeypatch) 30 | monkeypatch.setenv("ALLOW_COMMANDS", "echo") 31 | 32 | # Set up mock return values 33 | mock_process = AsyncMock() 34 | mock_process.returncode = 0 35 | mock_process.communicate = AsyncMock(return_value=(b"hello\n", b"")) 36 | mock_process.kill = AsyncMock() 37 | mock_process.wait = AsyncMock() 38 | mock_process_manager.create_process.return_value = mock_process 39 | mock_process_manager.execute_with_timeout.return_value = (b"hello\n", b"") 40 | 41 | result = await shell_executor_with_mock.execute(["echo", "hello"], temp_test_dir) 42 | assert result["stdout"].strip() == "hello" 43 | assert result["status"] == 0 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_stdin_input( 48 | shell_executor_with_mock, 49 | mock_process_manager, 50 | temp_test_dir, 51 | monkeypatch, 52 | ): 53 | clear_env(monkeypatch) 54 | monkeypatch.setenv("ALLOW_COMMANDS", "cat") 55 | 56 | # Set up mock return values 57 | mock_process = AsyncMock() 58 | mock_process.returncode = 0 59 | mock_process.communicate = AsyncMock(return_value=(b"hello world\n", b"")) 60 | mock_process.kill = AsyncMock() 61 | mock_process.wait = AsyncMock() 62 | mock_process_manager.create_process.return_value = mock_process 63 | mock_process_manager.execute_with_timeout.return_value = (b"hello world\n", b"") 64 | 65 | result = await shell_executor_with_mock.execute( 66 | ["cat "], temp_test_dir, stdin="hello world" 67 | ) 68 | assert result["stdout"].strip() == "hello world" 69 | assert result["status"] == 0 70 | assert result["error"] is None 71 | 72 | 73 | @pytest.mark.asyncio 74 | async def test_command_not_allowed( 75 | shell_executor_with_mock, 76 | mock_process_manager, 77 | temp_test_dir, 78 | monkeypatch, 79 | ): 80 | clear_env(monkeypatch) 81 | monkeypatch.setenv("ALLOW_COMMANDS", "ls") 82 | 83 | mock_process_manager.execute_with_timeout.side_effect = ValueError( 84 | "Command not allowed: rm" 85 | ) 86 | result = await shell_executor_with_mock.execute(["rm", "-rf", "/"], temp_test_dir) 87 | assert result["error"] == "Command not allowed: rm" 88 | assert result["status"] == 1 89 | 90 | 91 | @pytest.mark.asyncio 92 | async def test_empty_command( 93 | shell_executor_with_mock, mock_process_manager, temp_test_dir 94 | ): 95 | mock_process_manager.execute_with_timeout.side_effect = ValueError("Empty command") 96 | result = await shell_executor_with_mock.execute([], temp_test_dir) 97 | assert result["error"] == "Empty command" 98 | assert result["status"] == 1 99 | 100 | 101 | @pytest.mark.asyncio 102 | async def test_command_with_space_in_allow_commands( 103 | shell_executor_with_mock, 104 | mock_process_manager, 105 | temp_test_dir, 106 | monkeypatch, 107 | ): 108 | clear_env(monkeypatch) 109 | monkeypatch.setenv("ALLOW_COMMANDS", "ls, echo ,cat") 110 | 111 | # Set up mock return values 112 | mock_process = AsyncMock() 113 | mock_process.returncode = 0 114 | mock_process.communicate = AsyncMock(return_value=(b"test\n", b"")) 115 | mock_process.kill = AsyncMock() 116 | mock_process.wait = AsyncMock() 117 | mock_process_manager.create_process.return_value = mock_process 118 | mock_process_manager.execute_with_timeout.return_value = (b"test\n", b"") 119 | 120 | result = await shell_executor_with_mock.execute(["echo", "test"], temp_test_dir) 121 | assert result["stdout"].strip() == "test" 122 | assert result["status"] == 0 123 | assert result["error"] is None 124 | 125 | 126 | @pytest.mark.asyncio 127 | async def test_multiple_commands_with_operator( 128 | shell_executor_with_mock, 129 | mock_process_manager, 130 | temp_test_dir, 131 | monkeypatch, 132 | ): 133 | clear_env(monkeypatch) 134 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,ls") 135 | mock_process_manager.execute_with_timeout.side_effect = ValueError( 136 | "Unexpected shell operator: ;" 137 | ) 138 | result = await shell_executor_with_mock.execute( 139 | ["echo", "hello", ";"], temp_test_dir 140 | ) 141 | assert result["error"] == "Unexpected shell operator: ;" 142 | assert result["status"] == 1 143 | 144 | 145 | @pytest.mark.asyncio 146 | async def test_shell_operators_not_allowed( 147 | shell_executor_with_mock, 148 | mock_process_manager, 149 | temp_test_dir, 150 | monkeypatch, 151 | ): 152 | clear_env(monkeypatch) 153 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,ls,true") 154 | operators = [";", "&&", "||"] 155 | for op in operators: 156 | mock_process_manager.execute_with_timeout.side_effect = ValueError( 157 | f"Unexpected shell operator: {op}" 158 | ) 159 | result = await shell_executor_with_mock.execute( 160 | ["echo", "hello", op, "true"], temp_test_dir 161 | ) 162 | assert result["error"] == f"Unexpected shell operator: {op}" 163 | assert result["status"] == 1 164 | 165 | 166 | # New tests for directory functionality 167 | @pytest.mark.asyncio 168 | async def test_execute_in_directory( 169 | shell_executor_with_mock, 170 | mock_process_manager, 171 | temp_test_dir, 172 | monkeypatch, 173 | ): 174 | """Test command execution in a specific directory""" 175 | clear_env(monkeypatch) 176 | monkeypatch.setenv("ALLOW_COMMANDS", "pwd") 177 | mock_process_manager.execute_with_timeout.return_value = ( 178 | temp_test_dir.encode() + b"\n", 179 | b"", 180 | ) 181 | result = await shell_executor_with_mock.execute(["pwd"], directory=temp_test_dir) 182 | assert result["error"] is None 183 | assert result["status"] == 0 184 | assert result["stdout"].strip() == temp_test_dir 185 | 186 | 187 | @pytest.mark.asyncio 188 | async def test_execute_with_file_in_directory( 189 | shell_executor_with_mock, 190 | mock_process_manager, 191 | temp_test_dir, 192 | monkeypatch, 193 | ): 194 | """Test command execution with a file in the specified directory""" 195 | clear_env(monkeypatch) 196 | monkeypatch.setenv("ALLOW_COMMANDS", "ls,cat") 197 | 198 | # Create a test file in the temporary directory 199 | test_file = os.path.join(temp_test_dir, "test.txt") 200 | with open(test_file, "w") as f: 201 | f.write("test content") 202 | 203 | # Test ls command 204 | mock_process_manager.execute_with_timeout.return_value = (b"test.txt\n", b"") 205 | result = await shell_executor_with_mock.execute(["ls"], directory=temp_test_dir) 206 | assert "test.txt" in result["stdout"] 207 | 208 | # Test cat command - Set specific mock output for cat command 209 | mock_process_manager.execute_with_timeout.return_value = (b"test content\n", b"") 210 | result = await shell_executor_with_mock.execute( 211 | ["cat", "test.txt"], directory=temp_test_dir 212 | ) 213 | assert result["stdout"].strip() == "test content" 214 | assert result["error"] is None 215 | assert result["status"] == 0 216 | 217 | 218 | @pytest.mark.asyncio 219 | async def test_execute_with_nonexistent_directory( 220 | shell_executor_with_mock, mock_process_manager, monkeypatch 221 | ): 222 | """Test command execution with a non-existent directory""" 223 | clear_env(monkeypatch) 224 | monkeypatch.setenv("ALLOW_COMMANDS", "ls") 225 | mock_process_manager.execute_with_timeout.side_effect = ValueError( 226 | "Directory does not exist: /nonexistent/directory" 227 | ) 228 | result = await shell_executor_with_mock.execute( 229 | ["ls"], directory="/nonexistent/directory" 230 | ) 231 | assert result["error"] == "Directory does not exist: /nonexistent/directory" 232 | assert result["status"] == 1 233 | 234 | 235 | @pytest.mark.asyncio 236 | async def test_execute_with_file_as_directory( 237 | shell_executor_with_mock, 238 | mock_process_manager, 239 | temp_test_dir, 240 | monkeypatch, 241 | ): 242 | """Test command execution with a file specified as directory""" 243 | clear_env(monkeypatch) 244 | monkeypatch.setenv("ALLOW_COMMANDS", "ls") 245 | 246 | # Create a test file 247 | test_file = os.path.join(temp_test_dir, "test.txt") 248 | with open(test_file, "w") as f: 249 | f.write("test content") 250 | 251 | mock_process_manager.execute_with_timeout.side_effect = ValueError( 252 | f"Not a directory: {test_file}" 253 | ) 254 | result = await shell_executor_with_mock.execute(["ls"], directory=test_file) 255 | assert result["error"] == f"Not a directory: {test_file}" 256 | assert result["status"] == 1 257 | 258 | 259 | @pytest.mark.asyncio 260 | async def test_execute_with_nested_directory( 261 | shell_executor_with_mock, 262 | mock_process_manager, 263 | temp_test_dir, 264 | monkeypatch, 265 | ): 266 | """Test command execution in a nested directory""" 267 | clear_env(monkeypatch) 268 | monkeypatch.setenv("ALLOW_COMMANDS", "pwd,mkdir,ls") 269 | 270 | # Create a nested directory 271 | nested_dir = os.path.join(temp_test_dir, "nested") 272 | os.mkdir(nested_dir) 273 | nested_real_path = os.path.realpath(nested_dir) 274 | 275 | mock_process_manager.execute_with_timeout.return_value = ( 276 | nested_real_path.encode() + b"\n", 277 | b"", 278 | ) 279 | result = await shell_executor_with_mock.execute(["pwd"], directory=nested_dir) 280 | assert result["error"] is None 281 | assert result["status"] == 0 282 | assert result["stdout"].strip() == nested_real_path 283 | 284 | 285 | @pytest.mark.asyncio 286 | async def test_command_timeout( 287 | shell_executor_with_mock, 288 | mock_process_manager, 289 | temp_test_dir, 290 | monkeypatch, 291 | ): 292 | """Test command timeout functionality""" 293 | clear_env(monkeypatch) 294 | monkeypatch.setenv("ALLOW_COMMANDS", "sleep") 295 | mock_process_manager.execute_with_timeout.side_effect = TimeoutError( 296 | "Command timed out after 1 seconds" 297 | ) 298 | result = await shell_executor_with_mock.execute( 299 | ["sleep", "2"], temp_test_dir, timeout=1 300 | ) 301 | assert result["error"] == "Command timed out after 1 seconds" 302 | assert result["status"] == -1 303 | assert result["stdout"] == "" 304 | assert result["stderr"] == "Command timed out after 1 seconds" 305 | 306 | 307 | @pytest.mark.asyncio 308 | async def test_command_completes_within_timeout( 309 | shell_executor_with_mock, 310 | mock_process_manager, 311 | temp_test_dir, 312 | monkeypatch, 313 | ): 314 | """Test command that completes within timeout period""" 315 | clear_env(monkeypatch) 316 | monkeypatch.setenv("ALLOW_COMMANDS", "sleep") 317 | result = await shell_executor_with_mock.execute( 318 | ["sleep", "1"], temp_test_dir, timeout=2 319 | ) 320 | assert result["error"] is None 321 | assert result["status"] == 0 322 | assert result["stdout"] == "" 323 | 324 | 325 | @pytest.mark.asyncio 326 | async def test_allowed_commands_alias( 327 | shell_executor_with_mock, 328 | mock_process_manager, 329 | temp_test_dir, 330 | monkeypatch, 331 | ): 332 | """Test ALLOWED_COMMANDS alias functionality""" 333 | clear_env(monkeypatch) 334 | monkeypatch.setenv("ALLOW_COMMANDS", "echo") 335 | mock_process_manager.execute_with_timeout.return_value = (b"hello\n", b"") 336 | result = await shell_executor_with_mock.execute(["echo", "hello"], temp_test_dir) 337 | assert result["stdout"].strip() == "hello" 338 | assert result["status"] == 0 339 | assert result["error"] is None 340 | 341 | 342 | @pytest.mark.asyncio 343 | async def test_both_allow_commands_vars( 344 | shell_executor_with_mock, 345 | mock_process_manager, 346 | temp_test_dir, 347 | monkeypatch, 348 | ): 349 | """Test both ALLOW_COMMANDS and ALLOWED_COMMANDS working together""" 350 | clear_env(monkeypatch) 351 | monkeypatch.setenv("ALLOW_COMMANDS", "echo") 352 | monkeypatch.setenv("ALLOWED_COMMANDS", "cat") 353 | 354 | # Test command from ALLOW_COMMANDS 355 | mock_process_manager.execute_with_timeout.return_value = (b"hello\n", b"") 356 | result1 = await shell_executor_with_mock.execute(["echo", "hello"], temp_test_dir) 357 | assert result1["stdout"].strip() == "hello" 358 | assert result1["status"] == 0 359 | assert result1["error"] is None 360 | 361 | # Test command from ALLOWED_COMMANDS 362 | mock_process_manager.execute_with_timeout.return_value = (b"world\n", b"") 363 | result2 = await shell_executor_with_mock.execute( 364 | ["cat"], temp_test_dir, stdin="world" 365 | ) 366 | assert result2["stdout"].strip() == "world" 367 | assert result2["status"] == 0 368 | assert result2["error"] is None 369 | 370 | 371 | @pytest.mark.asyncio 372 | async def test_allow_commands_precedence( 373 | shell_executor_with_mock, 374 | mock_process_manager, 375 | temp_test_dir, 376 | monkeypatch, 377 | ): 378 | """Test that commands are combined from both environment variables""" 379 | clear_env(monkeypatch) 380 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,ls") 381 | monkeypatch.setenv("ALLOWED_COMMANDS", "echo,cat") 382 | 383 | assert set(shell_executor_with_mock.validator.get_allowed_commands()) == { 384 | "echo", 385 | "ls", 386 | "cat", 387 | } 388 | 389 | 390 | @pytest.mark.asyncio 391 | async def test_pipe_operator( 392 | shell_executor_with_mock, 393 | mock_process_manager, 394 | temp_test_dir, 395 | monkeypatch, 396 | ): 397 | """Test that pipe operator works correctly""" 398 | clear_env(monkeypatch) 399 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,grep") 400 | mock_process_manager.execute_pipeline.return_value = (b"world\n", b"", 0) 401 | result = await shell_executor_with_mock.execute( 402 | ["echo", "hello\nworld", "|", "grep", "world"], temp_test_dir 403 | ) 404 | assert result["error"] is None 405 | assert result["status"] == 0 406 | assert result["stdout"].strip() == "world" 407 | 408 | 409 | @pytest.mark.asyncio 410 | async def test_pipe_commands( 411 | shell_executor_with_mock, 412 | mock_process_manager, 413 | temp_test_dir, 414 | monkeypatch, 415 | ): 416 | """Test piping commands together""" 417 | clear_env(monkeypatch) 418 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,grep,cut,tr") 419 | 420 | # Test multiple pipes 421 | mock_process_manager.execute_pipeline.return_value = (b"WORLD\n", b"", 0) 422 | result = await shell_executor_with_mock.execute( 423 | ["echo", "hello world", "|", "cut", "-d", " ", "-f2", "|", "tr", "a-z", "A-Z"], 424 | temp_test_dir, 425 | ) 426 | assert result["stdout"].strip() == "WORLD" 427 | 428 | 429 | @pytest.mark.asyncio 430 | async def test_output_redirection( 431 | shell_executor_with_mock, 432 | mock_process_manager, 433 | temp_test_dir, 434 | monkeypatch, 435 | ): 436 | """Test output redirection with > operator""" 437 | clear_env(monkeypatch) 438 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,cat") 439 | output_file = os.path.join(temp_test_dir, "out.txt") 440 | 441 | # Test > redirection 442 | # Mock empty output for echo commands 443 | mock_process_manager.execute_with_timeout.return_value = (b"", b"") 444 | result = await shell_executor_with_mock.execute( 445 | ["echo", "hello", ">", output_file], directory=temp_test_dir 446 | ) 447 | assert result["error"] is None 448 | assert result["status"] == 0 449 | 450 | # Test >> redirection (append) 451 | mock_process_manager.execute_with_timeout.return_value = (b"", b"") 452 | result = await shell_executor_with_mock.execute( 453 | ["echo", "world", ">>", output_file], directory=temp_test_dir 454 | ) 455 | assert result["error"] is None 456 | assert result["status"] == 0 457 | 458 | # Mock cat command to return the expected file contents 459 | mock_process_manager.execute_with_timeout.return_value = (b"hello\nworld\n", b"") 460 | result = await shell_executor_with_mock.execute( 461 | ["cat", output_file], directory=temp_test_dir 462 | ) 463 | assert result["status"] == 0 464 | assert result["error"] is None 465 | assert result["stdout"].strip().split("\n") == ["hello", "world"] 466 | 467 | 468 | @pytest.mark.asyncio 469 | async def test_input_redirection( 470 | shell_executor_with_mock, 471 | mock_process_manager, 472 | temp_test_dir, 473 | monkeypatch, 474 | mocker, 475 | ): 476 | """Test input redirection with < operator""" 477 | clear_env(monkeypatch) 478 | monkeypatch.setenv("ALLOW_COMMANDS", "cat") 479 | input_file = os.path.join(temp_test_dir, "in.txt") 480 | 481 | # Mock the file operations 482 | mock_file = mocker.mock_open(read_data="test content") 483 | mocker.patch("builtins.open", mock_file) 484 | 485 | # Test < redirection 486 | mock_process_manager.execute_with_timeout.return_value = (b"test content\n", b"") 487 | result = await shell_executor_with_mock.execute( 488 | ["cat", "<", input_file], directory=temp_test_dir 489 | ) 490 | assert result["error"] is None 491 | assert result["status"] == 0 492 | assert result["stdout"].strip() == "test content" 493 | 494 | 495 | @pytest.mark.asyncio 496 | async def test_combined_redirections( 497 | shell_executor_with_mock, 498 | mock_process_manager, 499 | temp_test_dir, 500 | monkeypatch, 501 | ): 502 | """Test combining input and output redirection""" 503 | clear_env(monkeypatch) 504 | monkeypatch.setenv("ALLOW_COMMANDS", "cat,tr") 505 | input_file = os.path.join(temp_test_dir, "in.txt") 506 | output_file = os.path.join(temp_test_dir, "out.txt") 507 | 508 | # Create input file 509 | with open(input_file, "w") as f: 510 | f.write("hello world") 511 | 512 | # Test < and > redirection together 513 | result = await shell_executor_with_mock.execute( 514 | ["cat", "<", input_file, "|", "tr", "[:lower:]", "[:upper:]", ">", output_file], 515 | directory=temp_test_dir, 516 | ) 517 | assert result["error"] is None 518 | assert result["status"] == 0 519 | 520 | # Verify using cat command 521 | mock_process_manager.execute_with_timeout.return_value = (b"HELLO WORLD\n", b"") 522 | result = await shell_executor_with_mock.execute( 523 | ["cat", output_file], directory=temp_test_dir 524 | ) 525 | assert result["stdout"].strip() == "HELLO WORLD" 526 | 527 | 528 | @pytest.mark.asyncio 529 | async def test_redirection_error_cases( 530 | shell_executor_with_mock, 531 | mock_process_manager, 532 | temp_test_dir, 533 | monkeypatch, 534 | ): 535 | """Test error cases for redirections""" 536 | clear_env(monkeypatch) 537 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,cat") 538 | 539 | # Missing output file path 540 | result = await shell_executor_with_mock.execute( 541 | ["echo", "hello", ">"], directory=temp_test_dir 542 | ) 543 | assert result["error"] == "Missing path for output redirection" 544 | 545 | # Missing input file path 546 | result = await shell_executor_with_mock.execute( 547 | ["cat", "<"], directory=temp_test_dir 548 | ) 549 | assert result["error"] == "Missing path for input redirection" 550 | 551 | # Non-existent input file 552 | result = await shell_executor_with_mock.execute( 553 | ["cat", "<", "nonexistent.txt"], directory=temp_test_dir 554 | ) 555 | assert result["error"] == "Failed to open input file" 556 | 557 | # Operator as path 558 | result = await shell_executor_with_mock.execute( 559 | ["echo", "hello", ">", ">"], directory=temp_test_dir 560 | ) 561 | assert result["error"] == "Invalid redirection target: operator found" 562 | 563 | 564 | @pytest.mark.asyncio 565 | async def test_complex_pipeline_with_redirections( 566 | shell_executor_with_mock, 567 | mock_process_manager, 568 | temp_test_dir, 569 | monkeypatch, 570 | ): 571 | """Test complex pipeline with multiple redirections""" 572 | clear_env(monkeypatch) 573 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,grep,tr,cat") 574 | input_file = os.path.join(temp_test_dir, "pipeline_input.txt") 575 | output_file = os.path.join(temp_test_dir, "pipeline_output.txt") 576 | 577 | # Create a test input file 578 | with open(input_file, "w") as f: 579 | f.write("hello\nworld\ntest\nHELLO\n") 580 | 581 | # Mock process execution for pipeline 582 | final_output = "HELLO\nWORLD" 583 | mock_process_manager.execute_pipeline.return_value = (final_output.encode(), b"", 0) 584 | 585 | # Complex pipeline: cat < input | grep l | tr a-z A-Z > output 586 | # Set specific process manager behavior for redirection 587 | mock_process_manager.execute_with_timeout.return_value = (b"", b"") 588 | mock_process_manager.execute_pipeline.side_effect = None 589 | mock_process_manager.execute_pipeline.return_value = (b"", b"", 0) 590 | 591 | result = await shell_executor_with_mock.execute( 592 | [ 593 | "cat", 594 | "<", 595 | input_file, 596 | "|", 597 | "grep", 598 | "l", 599 | "|", 600 | "tr", 601 | "a-z", 602 | "A-Z", 603 | ">", 604 | output_file, 605 | ], 606 | directory=temp_test_dir, 607 | ) 608 | assert result["error"] is None 609 | assert result["status"] == 0 610 | assert result["stdout"] == "" 611 | 612 | # Write expected output to simulated file 613 | with open(output_file, "w") as f: 614 | f.write(final_output) 615 | 616 | # Check the output file content 617 | with open(output_file, "r") as f: 618 | actual_output = f.read().strip() 619 | assert actual_output == final_output 620 | 621 | 622 | def test_validate_redirection_syntax(shell_executor_with_mock): 623 | """Test validation of redirection syntax with various input combinations""" 624 | # Valid cases 625 | shell_executor_with_mock.io_handler.validate_redirection_syntax( 626 | ["echo", "hello", ">", "file.txt"] 627 | ) 628 | shell_executor_with_mock.io_handler.validate_redirection_syntax( 629 | ["cat", "<", "input.txt", ">", "output.txt"] 630 | ) 631 | 632 | # Test consecutive operators 633 | with pytest.raises(ValueError) as exc: 634 | shell_executor_with_mock.io_handler.validate_redirection_syntax( 635 | ["echo", "text", ">", ">", "file.txt"] 636 | ) 637 | assert str(exc.value) == "Invalid redirection syntax: consecutive operators" 638 | 639 | with pytest.raises(ValueError) as exc: 640 | shell_executor_with_mock.io_handler.validate_redirection_syntax( 641 | ["cat", "<", "<", "input.txt"] 642 | ) 643 | assert str(exc.value) == "Invalid redirection syntax: consecutive operators" 644 | 645 | 646 | def test_create_shell_command(shell_executor_with_mock): 647 | """Test shell command creation with various input combinations""" 648 | # Test basic command 649 | assert ( 650 | shell_executor_with_mock.preprocessor.create_shell_command(["echo", "hello"]) 651 | == "echo hello" 652 | ) 653 | 654 | # Test command with space-only argument 655 | assert ( 656 | shell_executor_with_mock.preprocessor.create_shell_command(["echo", " "]) 657 | == "echo ' '" 658 | ) 659 | 660 | # Test command with wildcards 661 | assert ( 662 | shell_executor_with_mock.preprocessor.create_shell_command(["ls", "*.txt"]) 663 | == "ls '*.txt'" 664 | ) 665 | 666 | # Test command with special characters 667 | assert ( 668 | shell_executor_with_mock.preprocessor.create_shell_command( 669 | ["echo", "hello;", "world"] 670 | ) 671 | == "echo 'hello;' world" 672 | ) 673 | 674 | # Test empty command 675 | assert shell_executor_with_mock.preprocessor.create_shell_command([]) == "" 676 | 677 | 678 | def test_preprocess_command(shell_executor_with_mock): 679 | """Test command preprocessing for pipeline handling""" 680 | # Test basic command 681 | assert shell_executor_with_mock.preprocessor.preprocess_command(["ls"]) == ["ls"] 682 | 683 | # Test command with separate pipe 684 | assert shell_executor_with_mock.preprocessor.preprocess_command( 685 | ["ls", "|", "grep", "test"] 686 | ) == [ 687 | "ls", 688 | "|", 689 | "grep", 690 | "test", 691 | ] 692 | 693 | # Test command with attached pipe 694 | assert shell_executor_with_mock.preprocessor.preprocess_command( 695 | ["ls|", "grep", "test"] 696 | ) == [ 697 | "ls", 698 | "|", 699 | "grep", 700 | "test", 701 | ] 702 | 703 | # Test command with special operators 704 | assert shell_executor_with_mock.preprocessor.preprocess_command( 705 | ["echo", "hello", "&&", "ls"] 706 | ) == [ 707 | "echo", 708 | "hello", 709 | "&&", 710 | "ls", 711 | ] 712 | 713 | # Test empty command 714 | assert shell_executor_with_mock.preprocessor.preprocess_command([]) == [] 715 | 716 | 717 | def test_validate_pipeline(shell_executor_with_mock, monkeypatch): 718 | """Test pipeline validation""" 719 | clear_env(monkeypatch) 720 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,grep,cat") 721 | monkeypatch.setenv("ALLOWED_COMMANDS", "echo,grep,cat") 722 | 723 | # Test valid pipeline 724 | shell_executor_with_mock.validator.validate_pipeline( 725 | ["echo", "hello", "|", "grep", "h"] 726 | ) 727 | 728 | # Test empty command before pipe 729 | with pytest.raises(ValueError) as exc: 730 | shell_executor_with_mock.validator.validate_pipeline(["|", "grep", "test"]) 731 | assert str(exc.value) == "Empty command before pipe operator" 732 | 733 | # Test disallowed commands in pipeline 734 | with pytest.raises(ValueError) as exc: 735 | shell_executor_with_mock.validator.validate_pipeline( 736 | ["rm", "|", "grep", "test"] 737 | ) 738 | assert "Command not allowed: rm" in str(exc.value) 739 | 740 | # Test shell operators in pipeline 741 | with pytest.raises(ValueError) as exc: 742 | shell_executor_with_mock.validator.validate_pipeline( 743 | ["echo", "hello", "|", "grep", "h", "&&", "ls"] 744 | ) 745 | assert "Unexpected shell operator in pipeline: &&" in str(exc.value) 746 | assert shell_executor_with_mock.preprocessor.preprocess_command([]) == [] 747 | 748 | 749 | def test_redirection_path_validation(shell_executor_with_mock): 750 | """Test validation of redirection paths""" 751 | # Test missing output redirection path 752 | with pytest.raises(ValueError, match="Missing path for output redirection"): 753 | shell_executor_with_mock.preprocessor.parse_command(["echo", "hello", ">"]) 754 | 755 | # Test missing input redirection path 756 | with pytest.raises(ValueError, match="Missing path for input redirection"): 757 | shell_executor_with_mock.preprocessor.parse_command(["cat", "<"]) 758 | 759 | # Test operator as redirection target 760 | with pytest.raises(ValueError, match="Invalid redirection target: operator found"): 761 | shell_executor_with_mock.preprocessor.parse_command(["echo", "hello", ">", ">"]) 762 | 763 | # Test multiple operators 764 | with pytest.raises(ValueError, match="Invalid redirection target: operator found"): 765 | shell_executor_with_mock.preprocessor.parse_command( 766 | ["echo", "hello", ">", ">>", "file.txt"] 767 | ) 768 | 769 | 770 | @pytest.mark.asyncio 771 | async def test_io_handle_close( 772 | shell_executor_with_mock, 773 | mock_process_manager, 774 | mock_file, 775 | temp_test_dir, 776 | monkeypatch, 777 | mocker, 778 | ): 779 | """Test IO handle closing functionality""" 780 | clear_env(monkeypatch) 781 | monkeypatch.setenv("ALLOW_COMMANDS", "echo") 782 | test_file = os.path.join(temp_test_dir, "test.txt") 783 | 784 | # Create file handler that will raise IOError on close 785 | mock_file = mocker.MagicMock(spec=IO) 786 | mock_file.close.side_effect = IOError("Failed to close file") 787 | 788 | # Patch the open function to return our mock 789 | mocker.patch("builtins.open", return_value=mock_file) 790 | 791 | # Mock logging.warning to capture the warning 792 | mock_warning = mocker.patch("logging.warning") 793 | 794 | # Execute should not raise an error 795 | await shell_executor_with_mock.execute( 796 | ["echo", "hello", ">", test_file], directory=temp_test_dir 797 | ) 798 | 799 | # Verify our mock's close method was called 800 | assert mock_file.close.called 801 | # Verify warning was logged 802 | mock_warning.assert_called_once_with("Error closing stdout: Failed to close file") 803 | 804 | 805 | def test_preprocess_command_pipeline(shell_executor_with_mock): 806 | """Test pipeline command preprocessing functionality""" 807 | # Test empty command 808 | assert shell_executor_with_mock.preprocessor.preprocess_command([]) == [] 809 | 810 | # Test single command without pipe 811 | assert shell_executor_with_mock.preprocessor.preprocess_command( 812 | ["echo", "hello"] 813 | ) == [ 814 | "echo", 815 | "hello", 816 | ] 817 | 818 | # Test simple pipe 819 | assert shell_executor_with_mock.preprocessor.preprocess_command( 820 | ["echo", "hello", "|", "grep", "h"] 821 | ) == [ 822 | "echo", 823 | "hello", 824 | "|", 825 | "grep", 826 | "h", 827 | ] 828 | 829 | # Test multiple pipes 830 | assert shell_executor_with_mock.preprocessor.preprocess_command( 831 | ["cat", "file", "|", "grep", "pattern", "|", "wc", "-l"] 832 | ) == ["cat", "file", "|", "grep", "pattern", "|", "wc", "-l"] 833 | 834 | # Test command with attached pipe operator 835 | assert shell_executor_with_mock.preprocessor.preprocess_command( 836 | ["echo|", "grep", "pattern"] 837 | ) == [ 838 | "echo", 839 | "|", 840 | "grep", 841 | "pattern", 842 | ] 843 | 844 | 845 | @pytest.mark.asyncio 846 | async def test_command_cleanup_on_error( 847 | shell_executor_with_mock, 848 | mock_process_manager, 849 | temp_test_dir, 850 | monkeypatch, 851 | ): 852 | """Test cleanup of processes when error occurs""" 853 | clear_env(monkeypatch) 854 | monkeypatch.setenv("ALLOW_COMMANDS", "sleep") 855 | 856 | # Configure mock to simulate timeout 857 | mock_process_manager.execute_with_timeout.side_effect = TimeoutError( 858 | "Command timed out" 859 | ) 860 | 861 | async def execute_with_keyboard_interrupt(): 862 | # Simulate keyboard interrupt during execution 863 | result = await shell_executor_with_mock.execute( 864 | ["sleep", "5"], temp_test_dir, timeout=1 865 | ) 866 | return result 867 | 868 | result = await execute_with_keyboard_interrupt() 869 | assert result["error"] == "Command timed out after 1 seconds" 870 | assert result["status"] == -1 871 | assert "execution_time" in result 872 | 873 | 874 | @pytest.mark.asyncio 875 | async def test_output_redirection_with_append( 876 | shell_executor_with_mock, 877 | mock_process_manager, 878 | mock_file, 879 | temp_test_dir, 880 | monkeypatch, 881 | ): 882 | """Test output redirection with append mode""" 883 | clear_env(monkeypatch) 884 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,cat") 885 | output_file = os.path.join(temp_test_dir, "test.txt") 886 | 887 | # Write initial content 888 | await shell_executor_with_mock.execute( 889 | ["echo", "hello", ">", output_file], directory=temp_test_dir 890 | ) 891 | 892 | # Append content 893 | result = await shell_executor_with_mock.execute( 894 | ["echo", "world", ">>", output_file], directory=temp_test_dir 895 | ) 896 | assert result["error"] is None 897 | assert result["status"] == 0 898 | 899 | # Verify contents 900 | mock_process_manager.execute_with_timeout.return_value = (b"hello\nworld\n", b"") 901 | result = await shell_executor_with_mock.execute( 902 | ["cat", output_file], directory=temp_test_dir 903 | ) 904 | lines = result["stdout"].strip().split("\n") 905 | assert len(lines) == 2 906 | assert lines[0] == "hello" 907 | 908 | 909 | @pytest.mark.asyncio 910 | async def test_execute_with_custom_env( 911 | shell_executor_with_mock, 912 | mock_process_manager, 913 | temp_test_dir, 914 | monkeypatch, 915 | ): 916 | """Test command execution with custom environment variables""" 917 | clear_env(monkeypatch) 918 | monkeypatch.setenv("ALLOW_COMMANDS", "env,printenv") 919 | 920 | custom_env = {"TEST_VAR1": "test_value1", "TEST_VAR2": "test_value2"} 921 | 922 | # Test env command 923 | mock_process_manager.execute_with_timeout.return_value = ( 924 | b"TEST_VAR1=test_value1\nTEST_VAR2=test_value2\n", 925 | b"", 926 | ) 927 | result = await shell_executor_with_mock.execute( 928 | ["env"], directory=temp_test_dir, envs=custom_env 929 | ) 930 | assert "TEST_VAR1=test_value1" in result["stdout"] 931 | assert "TEST_VAR2=test_value2" in result["stdout"] 932 | 933 | # Test specific variable - Update mock for printenv command 934 | mock_process_manager.execute_with_timeout.return_value = (b"test_value1\n", b"") 935 | result = await shell_executor_with_mock.execute( 936 | ["printenv", "TEST_VAR1"], directory=temp_test_dir, envs=custom_env 937 | ) 938 | assert result["stdout"].strip() == "test_value1" 939 | 940 | 941 | @pytest.mark.asyncio 942 | async def test_execute_env_override( 943 | shell_executor_with_mock, 944 | mock_process_manager, 945 | temp_test_dir, 946 | monkeypatch, 947 | ): 948 | """Test that custom environment variables override system variables""" 949 | clear_env(monkeypatch) 950 | monkeypatch.setenv("ALLOW_COMMANDS", "env") 951 | monkeypatch.setenv("TEST_VAR", "original_value") 952 | 953 | # Mock env command with new environment variable 954 | mock_process_manager.execute_with_timeout.return_value = ( 955 | b"TEST_VAR=new_value\n", 956 | b"", 957 | ) 958 | 959 | # Override system environment variable 960 | result = await shell_executor_with_mock.execute( 961 | ["env"], directory=temp_test_dir, envs={"TEST_VAR": "new_value"} 962 | ) 963 | 964 | assert "TEST_VAR=new_value" in result["stdout"] 965 | assert "TEST_VAR=original_value" not in result["stdout"] 966 | 967 | 968 | @pytest.mark.asyncio 969 | async def test_execute_with_empty_env( 970 | shell_executor_with_mock, 971 | mock_process_manager, 972 | temp_test_dir, 973 | monkeypatch, 974 | ): 975 | """Test command execution with empty environment variables""" 976 | clear_env(monkeypatch) 977 | monkeypatch.setenv("ALLOW_COMMANDS", "env") 978 | 979 | # Mock env command with system environment 980 | mock_process_manager.execute_with_timeout.return_value = ( 981 | b"PATH=/usr/bin\nHOME=/home/user\n", 982 | b"", 983 | ) 984 | 985 | result = await shell_executor_with_mock.execute( 986 | ["env"], directory=temp_test_dir, envs={} 987 | ) 988 | 989 | # Command should still work with system environment 990 | assert result["error"] is None 991 | assert result["status"] == 0 992 | assert len(result["stdout"]) > 0 993 | -------------------------------------------------------------------------------- /tests/test_shell_executor_error_cases.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import tempfile 4 | 5 | import pytest 6 | 7 | from mcp_shell_server.shell_executor import ShellExecutor 8 | 9 | 10 | def clear_env(monkeypatch): 11 | monkeypatch.delenv("ALLOW_COMMANDS", raising=False) 12 | monkeypatch.delenv("ALLOWED_COMMANDS", raising=False) 13 | 14 | 15 | @pytest.fixture 16 | def temp_test_dir(): 17 | """Create a temporary directory for testing""" 18 | with tempfile.TemporaryDirectory() as tmpdirname: 19 | # Return the real path to handle macOS /private/tmp symlink 20 | yield os.path.realpath(tmpdirname) 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_empty_command_validation(): 25 | """Test validation of empty commands""" 26 | executor = ShellExecutor() 27 | 28 | # Test empty command 29 | with pytest.raises(ValueError, match="Empty command"): 30 | executor._validate_command([]) 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_no_allowed_commands_validation(monkeypatch): 35 | """Test validation when no commands are allowed""" 36 | # Remove ALLOW_COMMANDS 37 | monkeypatch.delenv("ALLOW_COMMANDS", raising=False) 38 | monkeypatch.delenv("ALLOWED_COMMANDS", raising=False) 39 | 40 | executor = ShellExecutor() 41 | with pytest.raises( 42 | ValueError, 43 | match="No commands are allowed. Please set ALLOW_COMMANDS environment variable.", 44 | ): 45 | executor.validator.validate_command(["any_command"]) 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_shell_operator_validation(): 50 | """Test validation of shell operators""" 51 | executor = ShellExecutor() 52 | 53 | operators = [";", "&&", "||", "|"] 54 | for op in operators: 55 | # Test shell operator validation 56 | with pytest.raises(ValueError, match=f"Unexpected shell operator: {op}"): 57 | executor._validate_no_shell_operators(op) 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_process_execution_timeout(monkeypatch, temp_test_dir): 62 | """Test process execution timeout handling""" 63 | monkeypatch.setenv("ALLOW_COMMANDS", "sleep") 64 | executor = ShellExecutor() 65 | 66 | # Test process timeout 67 | command = ["sleep", "5"] 68 | with pytest.raises(asyncio.TimeoutError): 69 | await asyncio.wait_for(executor.execute(command, temp_test_dir), timeout=0.1) 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_process_failure(monkeypatch, temp_test_dir): 74 | """Test handling of process execution failure""" 75 | monkeypatch.setenv("ALLOW_COMMANDS", "false") 76 | executor = ShellExecutor() 77 | 78 | # false command always returns exit code 1 79 | result = await executor.execute(["false"], temp_test_dir) 80 | assert result["status"] == 1 81 | 82 | 83 | @pytest.mark.asyncio 84 | async def test_directory_validation(): 85 | """Test directory validation""" 86 | executor = ShellExecutor() 87 | 88 | # Test missing directory 89 | with pytest.raises(ValueError, match="Directory is required"): 90 | executor._validate_directory(None) 91 | 92 | # Test relative path 93 | with pytest.raises(ValueError, match="Directory must be an absolute path"): 94 | executor._validate_directory("relative/path") 95 | 96 | # Test non-existent directory 97 | with pytest.raises(ValueError, match="Directory does not exist"): 98 | executor._validate_directory("/nonexistent/directory") 99 | -------------------------------------------------------------------------------- /tests/test_shell_executor_new_tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | import pytest 5 | 6 | from mcp_shell_server.shell_executor import ShellExecutor 7 | 8 | 9 | @pytest.fixture 10 | def temp_test_dir(): 11 | """Create a temporary directory for testing""" 12 | with tempfile.TemporaryDirectory() as tmpdirname: 13 | yield os.path.realpath(tmpdirname) 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_redirection_validation(): 18 | """Test validation of input/output redirection""" 19 | executor = ShellExecutor() 20 | 21 | # Missing path for output redirection 22 | with pytest.raises(ValueError, match="Missing path for output redirection"): 23 | executor.io_handler.process_redirections(["echo", "test", ">"]) 24 | 25 | # Invalid redirection target (operator) 26 | with pytest.raises( 27 | ValueError, match="Invalid redirection syntax: consecutive operators" 28 | ): 29 | executor.io_handler.process_redirections(["echo", "test", ">", ">"]) 30 | 31 | # Missing path for input redirection 32 | with pytest.raises(ValueError, match="Missing path for input redirection"): 33 | executor.io_handler.process_redirections(["cat", "<"]) 34 | 35 | # Missing path for output redirection after input redirection 36 | with pytest.raises(ValueError, match="Missing path for output redirection"): 37 | executor.io_handler.process_redirections(["cat", "<", "input.txt", ">"]) 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_directory_validation(monkeypatch): 42 | """Test directory validation""" 43 | monkeypatch.setenv("ALLOW_COMMANDS", "echo") 44 | executor = ShellExecutor() 45 | 46 | # Directory validation is performed in the _validate_directory method 47 | with pytest.raises(ValueError, match="Directory is required"): 48 | executor.directory_manager.validate_directory(None) 49 | 50 | # Directory is not absolute path 51 | with pytest.raises(ValueError, match="Directory must be an absolute path"): 52 | executor.directory_manager.validate_directory("relative/path") 53 | 54 | # Directory does not exist 55 | with pytest.raises(ValueError, match="Directory does not exist"): 56 | executor.directory_manager.validate_directory("/path/does/not/exist") 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_process_timeout( 61 | shell_executor_with_mock, temp_test_dir, mock_process_manager, monkeypatch 62 | ): 63 | """Test process timeout handling""" 64 | monkeypatch.setenv("ALLOW_COMMANDS", "sleep") 65 | # Mock timeout behavior 66 | mock_process_manager.execute_with_timeout.side_effect = TimeoutError( 67 | "Command timed out after 1 seconds" 68 | ) 69 | 70 | # Process timeout test 71 | result = await shell_executor_with_mock.execute( 72 | command=["sleep", "5"], directory=temp_test_dir, timeout=1 73 | ) 74 | assert result["error"] == "Command timed out after 1 seconds" 75 | assert result["status"] == -1 76 | -------------------------------------------------------------------------------- /tests/test_shell_executor_pipe.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from unittest.mock import AsyncMock 4 | 5 | import pytest 6 | 7 | from mcp_shell_server.shell_executor import ShellExecutor 8 | 9 | 10 | @pytest.fixture 11 | def temp_test_dir(): 12 | """Create a temporary directory for testing""" 13 | with tempfile.TemporaryDirectory() as tmpdirname: 14 | # Return the real path to handle macOS /private/tmp symlink 15 | yield os.path.realpath(tmpdirname) 16 | 17 | 18 | @pytest.fixture 19 | def executor(): 20 | return ShellExecutor() 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_basic_pipe_command(executor, temp_test_dir, monkeypatch): 25 | """Test basic pipe functionality with allowed commands""" 26 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,grep") 27 | mock_process_manager = AsyncMock() 28 | mock_process_manager.execute_pipeline.return_value = (b"world\n", b"", 0) 29 | executor.process_manager = mock_process_manager 30 | result = await executor.execute( 31 | ["echo", "hello world", "|", "grep", "world"], temp_test_dir 32 | ) 33 | assert result["status"] == 0 34 | assert result["stdout"].strip() == "world" 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_invalid_pipe_command(executor, temp_test_dir, monkeypatch): 39 | """Test pipe command with non-allowed command""" 40 | monkeypatch.setenv("ALLOW_COMMANDS", "echo") 41 | monkeypatch.setenv("ALLOWED_COMMANDS", "echo") 42 | result = await executor.execute( 43 | ["echo", "hello", "|", "grep", "hello"], temp_test_dir 44 | ) 45 | assert result["status"] == 1 46 | assert "Command not allowed: grep" in result["error"] 47 | -------------------------------------------------------------------------------- /tests/test_shell_executor_pipeline.py: -------------------------------------------------------------------------------- 1 | """Test pipeline execution and cleanup scenarios.""" 2 | 3 | import os 4 | import tempfile 5 | 6 | import pytest 7 | 8 | from mcp_shell_server.shell_executor import ShellExecutor 9 | 10 | 11 | def clear_env(monkeypatch): 12 | monkeypatch.delenv("ALLOW_COMMANDS", raising=False) 13 | monkeypatch.delenv("ALLOWED_COMMANDS", raising=False) 14 | 15 | 16 | @pytest.fixture 17 | def executor(): 18 | return ShellExecutor() 19 | 20 | 21 | @pytest.fixture 22 | def temp_test_dir(): 23 | """Create a temporary directory for testing""" 24 | with tempfile.TemporaryDirectory() as tmpdirname: 25 | # Return the real path to handle macOS /private/tmp symlink 26 | yield os.path.realpath(tmpdirname) 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_pipeline_split(executor): 31 | """Test pipeline command splitting functionality""" 32 | # Test basic pipe command 33 | commands = executor.preprocessor.split_pipe_commands( 34 | ["echo", "hello", "|", "grep", "h"] 35 | ) 36 | assert len(commands) == 2 37 | assert commands[0] == ["echo", "hello"] 38 | assert commands[1] == ["grep", "h"] 39 | 40 | # Test empty pipe sections 41 | commands = executor.preprocessor.split_pipe_commands(["|", "grep", "pattern"]) 42 | assert len(commands) == 1 43 | assert commands[0] == ["grep", "pattern"] 44 | 45 | # Test multiple pipes 46 | commands = executor.preprocessor.split_pipe_commands( 47 | ["cat", "file.txt", "|", "grep", "pattern", "|", "wc", "-l"] 48 | ) 49 | assert len(commands) == 3 50 | assert commands[0] == ["cat", "file.txt"] 51 | assert commands[1] == ["grep", "pattern"] 52 | assert commands[2] == ["wc", "-l"] 53 | 54 | # Test trailing pipe 55 | commands = executor.preprocessor.split_pipe_commands(["echo", "hello", "|"]) 56 | assert len(commands) == 1 57 | assert commands[0] == ["echo", "hello"] 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_pipeline_execution_success( 62 | shell_executor_with_mock, temp_test_dir, mock_process_manager, monkeypatch 63 | ): 64 | """Test successful pipeline execution with proper return value""" 65 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,grep") 66 | # Set up mock for pipeline execution 67 | expected_output = b"mocked pipeline output\n" 68 | mock_process_manager.execute_pipeline.return_value = (expected_output, b"", 0) 69 | 70 | result = await shell_executor_with_mock.execute( 71 | ["echo", "hello world", "|", "grep", "world"], 72 | directory=temp_test_dir, 73 | timeout=5, 74 | ) 75 | 76 | assert result["error"] is None 77 | assert result["status"] == 0 78 | assert result["stdout"].rstrip() == "mocked pipeline output" 79 | assert "execution_time" in result 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_pipeline_cleanup_and_timeouts( 84 | shell_executor_with_mock, temp_test_dir, mock_process_manager, monkeypatch 85 | ): 86 | """Test cleanup of processes in pipelines and timeout handling""" 87 | monkeypatch.setenv("ALLOW_COMMANDS", "echo,grep") 88 | # Mock timeout behavior for pipeline 89 | mock_process_manager.execute_pipeline.side_effect = TimeoutError( 90 | "Command timed out after 1 seconds" 91 | ) 92 | 93 | result = await shell_executor_with_mock.execute( 94 | ["echo", "test", "|", "grep", "test"], # Use a pipeline command 95 | temp_test_dir, 96 | timeout=1, 97 | ) 98 | 99 | assert result["status"] == -1 100 | assert "timed out" in result["error"].lower() 101 | 102 | # Verify cleanup was called 103 | mock_process_manager.cleanup_processes.assert_called_once() 104 | -------------------------------------------------------------------------------- /tests/test_shell_executor_redirections.py: -------------------------------------------------------------------------------- 1 | """Tests for IO redirection in shell executor with mocked file operations.""" 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | import pytest 6 | 7 | from mcp_shell_server.io_redirection_handler import IORedirectionHandler 8 | 9 | 10 | @pytest.fixture 11 | def mock_file(): 12 | """Create a mock file object.""" 13 | file_mock = MagicMock() 14 | file_mock.closed = False 15 | file_mock.close = MagicMock() 16 | file_mock.write = MagicMock() 17 | file_mock.read = MagicMock(return_value="test content") 18 | return file_mock 19 | 20 | 21 | @pytest.fixture 22 | def handler(): 23 | """Create a new IORedirectionHandler instance for each test.""" 24 | return IORedirectionHandler() 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_file_input_redirection(handler, mock_file): 29 | """Test input redirection from a file using mocks.""" 30 | with ( 31 | patch("builtins.open", return_value=mock_file), 32 | patch("os.path.exists", return_value=True), 33 | ): 34 | 35 | redirects = { 36 | "stdin": "input.txt", 37 | "stdout": None, 38 | "stdout_append": False, 39 | } 40 | handles = await handler.setup_redirects(redirects, "/mock/dir") 41 | 42 | assert "stdin" in handles 43 | assert "stdin_data" in handles 44 | assert handles["stdin_data"] == "test content" 45 | assert isinstance(handles["stdout"], int) 46 | assert isinstance(handles["stderr"], int) 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_file_output_redirection(handler, mock_file): 51 | """Test output redirection to a file using mocks.""" 52 | with patch("builtins.open", return_value=mock_file): 53 | redirects = { 54 | "stdin": None, 55 | "stdout": "output.txt", 56 | "stdout_append": False, 57 | } 58 | handles = await handler.setup_redirects(redirects, "/mock/dir") 59 | 60 | assert "stdout" in handles 61 | assert not handles["stdout"].closed 62 | await handler.cleanup_handles(handles) 63 | mock_file.close.assert_called_once() 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_append_mode(handler, mock_file): 68 | """Test output redirection in append mode using mocks.""" 69 | with patch("builtins.open", return_value=mock_file): 70 | # Test append mode 71 | redirects = { 72 | "stdin": None, 73 | "stdout": "output.txt", 74 | "stdout_append": True, 75 | } 76 | mock_file.mode = "a" # Set the expected mode 77 | handles = await handler.setup_redirects(redirects, "/mock/dir") 78 | assert handles["stdout"].mode == "a" 79 | await handler.cleanup_handles(handles) 80 | mock_file.close.assert_called_once() 81 | 82 | # Reset mock and test write mode 83 | mock_file.reset_mock() 84 | mock_file.mode = "w" # Set the expected mode for write mode 85 | redirects["stdout_append"] = False 86 | handles = await handler.setup_redirects(redirects, "/mock/dir") 87 | assert handles["stdout"].mode == "w" 88 | await handler.cleanup_handles(handles) 89 | mock_file.close.assert_called_once() 90 | 91 | 92 | def test_validate_redirection_syntax(handler): 93 | """Test validation of redirection syntax.""" 94 | # Valid cases 95 | handler.validate_redirection_syntax(["echo", "hello", ">", "output.txt"]) 96 | handler.validate_redirection_syntax(["cat", "<", "input.txt", ">", "output.txt"]) 97 | handler.validate_redirection_syntax(["echo", "hello", ">>", "output.txt"]) 98 | 99 | # Invalid cases 100 | with pytest.raises(ValueError, match="consecutive operators"): 101 | handler.validate_redirection_syntax(["echo", ">", ">", "output.txt"]) 102 | 103 | with pytest.raises(ValueError, match="consecutive operators"): 104 | handler.validate_redirection_syntax(["cat", "<", ">", "output.txt"]) 105 | 106 | 107 | def test_process_redirections(handler): 108 | """Test processing of redirection operators.""" 109 | # Input redirection 110 | cmd, redirects = handler.process_redirections(["cat", "<", "input.txt"]) 111 | assert cmd == ["cat"] 112 | assert redirects["stdin"] == "input.txt" 113 | assert redirects["stdout"] is None 114 | 115 | # Output redirection 116 | cmd, redirects = handler.process_redirections(["echo", "test", ">", "output.txt"]) 117 | assert cmd == ["echo", "test"] 118 | assert redirects["stdout"] == "output.txt" 119 | assert not redirects["stdout_append"] 120 | 121 | # Combined redirections 122 | cmd, redirects = handler.process_redirections( 123 | ["cat", "<", "in.txt", ">", "out.txt"] 124 | ) 125 | assert cmd == ["cat"] 126 | assert redirects["stdin"] == "in.txt" 127 | assert redirects["stdout"] == "out.txt" 128 | 129 | 130 | @pytest.mark.asyncio 131 | async def test_setup_errors(handler, mock_file): 132 | """Test error cases in redirection setup using mocks.""" 133 | # Test non-existent input file 134 | with patch("os.path.exists", return_value=False): 135 | redirects = { 136 | "stdin": "nonexistent.txt", 137 | "stdout": None, 138 | "stdout_append": False, 139 | } 140 | with pytest.raises(ValueError, match="Failed to open input file"): 141 | await handler.setup_redirects(redirects, "/mock/dir") 142 | 143 | # Test error in output file creation 144 | # Mock builtins.open to raise PermissionError 145 | mock_open = MagicMock(side_effect=PermissionError("Permission denied")) 146 | with patch("builtins.open", mock_open): 147 | redirects = { 148 | "stdin": None, 149 | "stdout": "output.txt", 150 | "stdout_append": False, 151 | } 152 | with pytest.raises(ValueError, match="Failed to open output file"): 153 | await handler.setup_redirects(redirects, "/mock/dir") 154 | 155 | mock_file.close.assert_not_called() 156 | -------------------------------------------------------------------------------- /tests/test_shell_executor_redirections.py.bak: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from io import TextIOWrapper 4 | 5 | import pytest 6 | 7 | from mcp_shell_server.shell_executor import ShellExecutor 8 | 9 | 10 | @pytest.fixture 11 | def temp_test_dir(): 12 | """Create a temporary directory for testing""" 13 | with tempfile.TemporaryDirectory() as tmpdirname: 14 | yield os.path.realpath(tmpdirname) 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_redirection_setup(temp_test_dir): 19 | """Test setup of redirections with files""" 20 | executor = ShellExecutor() 21 | 22 | # Create a test input file 23 | with open(os.path.join(temp_test_dir, "input.txt"), "w") as f: 24 | f.write("test content") 25 | 26 | # Test input redirection setup 27 | redirects = { 28 | "stdin": "input.txt", 29 | "stdout": None, 30 | "stdout_append": False, 31 | } 32 | handles = await executor.io_handler.setup_redirects(redirects, temp_test_dir) 33 | assert "stdin" in handles 34 | assert "stdin_data" in handles 35 | assert handles["stdin_data"] == "test content" 36 | assert isinstance(handles["stdout"], int) 37 | assert isinstance(handles["stderr"], int) 38 | 39 | # Test output redirection setup 40 | output_file = os.path.join(temp_test_dir, "output.txt") 41 | redirects = { 42 | "stdin": None, 43 | "stdout": output_file, 44 | "stdout_append": False, 45 | } 46 | handles = await executor.io_handler.setup_redirects(redirects, temp_test_dir) 47 | assert isinstance(handles["stdout"], TextIOWrapper) 48 | assert not handles["stdout"].closed 49 | await executor.io_handler.cleanup_handles(handles) 50 | try: 51 | assert handles["stdout"].closed 52 | except ValueError: 53 | # Ignore errors from already closed file 54 | pass 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_redirection_append_mode(temp_test_dir): 59 | """Test output redirection in append mode""" 60 | executor = ShellExecutor() 61 | 62 | output_file = os.path.join(temp_test_dir, "output.txt") 63 | 64 | # Test append mode 65 | redirects = { 66 | "stdin": None, 67 | "stdout": output_file, 68 | "stdout_append": True, 69 | } 70 | handles = await executor.io_handler.setup_redirects(redirects, temp_test_dir) 71 | assert handles["stdout"].mode == "a" 72 | await executor.io_handler.cleanup_handles(handles) 73 | 74 | # Test write mode 75 | redirects["stdout_append"] = False 76 | handles = await executor.io_handler.setup_redirects(redirects, temp_test_dir) 77 | assert handles["stdout"].mode == "w" 78 | await executor.io_handler.cleanup_handles(handles) 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_redirection_setup_errors(temp_test_dir): 83 | """Test error cases in redirection setup""" 84 | executor = ShellExecutor() 85 | 86 | # Test non-existent input file 87 | redirects = { 88 | "stdin": "nonexistent.txt", 89 | "stdout": None, 90 | "stdout_append": False, 91 | } 92 | with pytest.raises(ValueError, match="Failed to open input file"): 93 | await executor.io_handler.setup_redirects(redirects, temp_test_dir) 94 | 95 | # Test error in output file creation 96 | os.chmod(temp_test_dir, 0o444) # Make directory read-only 97 | try: 98 | redirects = { 99 | "stdin": None, 100 | "stdout": "output.txt", 101 | "stdout_append": False, 102 | } 103 | with pytest.raises(ValueError, match="Failed to open output file"): 104 | await executor.io_handler.setup_redirects(redirects, temp_test_dir) 105 | finally: 106 | os.chmod(temp_test_dir, 0o755) # Reset permissions 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_invalid_redirection_paths(): 111 | """Test invalid redirection path scenarios""" 112 | executor = ShellExecutor() 113 | 114 | # Test missing path for output redirection 115 | with pytest.raises(ValueError, match="Missing path for output redirection"): 116 | executor.io_handler.process_redirections(["echo", "test", ">"]) 117 | 118 | # Test invalid redirection target (operator found) 119 | with pytest.raises(ValueError, match="Invalid redirection target: operator found"): 120 | executor.io_handler.process_redirections(["echo", "test", ">", ">"]) 121 | 122 | # Test missing path for input redirection 123 | with pytest.raises(ValueError, match="Missing path for input redirection"): 124 | executor.io_handler.process_redirections(["cat", "<"]) 125 | 126 | # Test missing path for output redirection 127 | with pytest.raises(ValueError, match="Missing path for output redirection"): 128 | executor.io_handler.process_redirections(["echo", "test", ">"]) 129 | 130 | # Test invalid redirection target: operator found for output 131 | with pytest.raises(ValueError, match="Invalid redirection target: operator found"): 132 | executor.io_handler.process_redirections(["echo", "test", ">", ">"]) 133 | --------------------------------------------------------------------------------