mcp-shell-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ """MCP Shell Server Package."""
2
+
3
+ import asyncio
4
+
5
+ from . import server
6
+
7
+
8
+ def main():
9
+ """Main entry point for the package."""
10
+ asyncio.run(server.main())
11
+
12
+
13
+ # Optionally expose other important items at package level
14
+ __all__ = ["main", "server"]
15
+ __version__ = "0.1.0"
@@ -0,0 +1,123 @@
1
+ import logging
2
+ import traceback
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ from mcp.server import Server
7
+ from mcp.types import TextContent, Tool
8
+
9
+ from .shell_executor import ShellExecutor
10
+
11
+ # Configure logging
12
+ logging.basicConfig(level=logging.INFO)
13
+ logger = logging.getLogger("mcp-shell-server")
14
+
15
+ app = Server("mcp-shell-server")
16
+
17
+
18
+ class ExecuteToolHandler:
19
+ """Handler for shell command execution"""
20
+
21
+ name = "shell_execute"
22
+ description = "Execute a shell command"
23
+
24
+ def __init__(self):
25
+ self.executor = ShellExecutor()
26
+
27
+ def get_allowed_commands(self) -> list[str]:
28
+ """Get the allowed commands"""
29
+ return self.executor.get_allowed_commands()
30
+
31
+ def get_tool_description(self) -> Tool:
32
+ """Get the tool description for the execute command"""
33
+ return Tool(
34
+ name=self.name,
35
+ description=f"{self.description}\nAllowed commands: {', '.join(self.get_allowed_commands())}",
36
+ inputSchema={
37
+ "type": "object",
38
+ "properties": {
39
+ "command": {
40
+ "type": "array",
41
+ "items": {"type": "string"},
42
+ "description": "Command and its arguments as array",
43
+ },
44
+ "stdin": {
45
+ "type": "string",
46
+ "description": "Input to be passed to the command via stdin",
47
+ },
48
+ "directory": {
49
+ "type": "string",
50
+ "description": "Working directory where the command will be executed",
51
+ },
52
+ },
53
+ "required": ["command"],
54
+ },
55
+ )
56
+
57
+ async def run_tool(self, arguments: dict) -> Sequence[TextContent]:
58
+ """Execute the shell command with the given arguments"""
59
+ command = arguments.get("command", [])
60
+ stdin = arguments.get("stdin")
61
+ directory = arguments.get("directory")
62
+
63
+ if not command:
64
+ raise ValueError("No command provided")
65
+
66
+ result = await self.executor.execute(command, stdin, directory)
67
+
68
+ # Raise error if command execution failed
69
+ if result.get("error"):
70
+ raise RuntimeError(result["error"])
71
+
72
+ # Convert executor result to TextContent sequence
73
+ content: list[TextContent] = []
74
+
75
+ if result.get("stdout"):
76
+ content.append(TextContent(type="text", text=result["stdout"]))
77
+ if result.get("stderr"):
78
+ content.append(TextContent(type="text", text=result["stderr"]))
79
+
80
+ return content
81
+
82
+
83
+ # Initialize tool handlers
84
+ tool_handler = ExecuteToolHandler()
85
+
86
+
87
+ @app.list_tools()
88
+ async def list_tools() -> list[Tool]:
89
+ """List available tools."""
90
+ return [tool_handler.get_tool_description()]
91
+
92
+
93
+ @app.call_tool()
94
+ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
95
+ """Handle tool calls"""
96
+ try:
97
+ if name != tool_handler.name:
98
+ raise ValueError(f"Unknown tool: {name}")
99
+
100
+ if not isinstance(arguments, dict):
101
+ raise ValueError("Arguments must be a dictionary")
102
+
103
+ return await tool_handler.run_tool(arguments)
104
+
105
+ except Exception as e:
106
+ logger.error(traceback.format_exc())
107
+ logger.error(f"Error during call_tool: {str(e)}")
108
+ raise RuntimeError(f"Error executing command: {str(e)}") from e
109
+
110
+
111
+ async def main() -> None:
112
+ """Main entry point for the MCP shell server"""
113
+ logger.info("Starting MCP shell server")
114
+ try:
115
+ from mcp.server.stdio import stdio_server
116
+
117
+ async with stdio_server() as (read_stream, write_stream):
118
+ await app.run(
119
+ read_stream, write_stream, app.create_initialization_options()
120
+ )
121
+ except Exception as e:
122
+ logger.error(f"Server error: {str(e)}")
123
+ raise
@@ -0,0 +1,175 @@
1
+ import asyncio
2
+ import os
3
+ import time
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ class ShellExecutor:
8
+ """
9
+ Executes shell commands in a secure manner by validating against a whitelist.
10
+ """
11
+
12
+ def __init__(self):
13
+ """
14
+ Initialize the executor. The allowed commands are read from ALLOW_COMMANDS
15
+ environment variable during command validation, not at initialization.
16
+ """
17
+ pass
18
+
19
+ def _get_allowed_commands(self) -> set:
20
+ """
21
+ Get the set of allowed commands from environment variable.
22
+
23
+ Returns:
24
+ set: Set of allowed command names
25
+ """
26
+ allow_commands = os.environ.get("ALLOW_COMMANDS", "")
27
+ return {cmd.strip() for cmd in allow_commands.split(",") if cmd.strip()}
28
+
29
+ def _clean_command(self, command: List[str]) -> List[str]:
30
+ """
31
+ Clean command by trimming whitespace from each part.
32
+
33
+ Args:
34
+ command (List[str]): Original command and its arguments
35
+
36
+ Returns:
37
+ List[str]: Cleaned command with trimmed whitespace
38
+ """
39
+ return [arg.strip() for arg in command if arg.strip()]
40
+
41
+ def _validate_command(self, command: List[str]) -> None:
42
+ """
43
+ Validate if the command is allowed to be executed.
44
+
45
+ Args:
46
+ command (List[str]): Command and its arguments
47
+
48
+ Raises:
49
+ ValueError: If the command is empty, not allowed, or contains invalid shell operators
50
+ """
51
+ if not command:
52
+ raise ValueError("Empty command")
53
+
54
+ allowed_commands = self._get_allowed_commands()
55
+ if not allowed_commands:
56
+ raise ValueError(
57
+ "No commands are allowed. Please set ALLOW_COMMANDS environment variable."
58
+ )
59
+
60
+ # Clean and check the first command
61
+ cleaned_cmd = command[0].strip()
62
+ if cleaned_cmd not in allowed_commands:
63
+ raise ValueError(f"Command not allowed: {cleaned_cmd}")
64
+
65
+ # Check for shell operators and validate subsequent commands
66
+ for i, arg in enumerate(command[1:], start=1):
67
+ cleaned_arg = arg.strip()
68
+ if cleaned_arg in [";", "&&", "||", "|"]:
69
+ if i + 1 >= len(command):
70
+ raise ValueError(f"Unexpected shell operator: {cleaned_arg}")
71
+ next_cmd = command[i + 1].strip()
72
+ if next_cmd not in allowed_commands:
73
+ raise ValueError(
74
+ f"Command not allowed after {cleaned_arg}: {next_cmd}"
75
+ )
76
+
77
+ def _validate_directory(self, directory: Optional[str]) -> None:
78
+ """
79
+ Validate if the directory exists and is accessible.
80
+
81
+ Args:
82
+ directory (Optional[str]): Directory path to validate
83
+
84
+ Raises:
85
+ ValueError: If the directory doesn't exist or is not accessible
86
+ """
87
+ if directory is None:
88
+ return
89
+
90
+ if not os.path.exists(directory):
91
+ raise ValueError(f"Directory does not exist: {directory}")
92
+ if not os.path.isdir(directory):
93
+ raise ValueError(f"Not a directory: {directory}")
94
+ if not os.access(directory, os.R_OK | os.X_OK):
95
+ raise ValueError(f"Directory is not accessible: {directory}")
96
+
97
+ def get_allowed_commands(self) -> list[str]:
98
+ """Get the allowed commands"""
99
+ return list(self._get_allowed_commands())
100
+
101
+ async def execute(
102
+ self,
103
+ command: List[str],
104
+ stdin: Optional[str] = None,
105
+ directory: Optional[str] = None,
106
+ ) -> Dict[str, Any]:
107
+ """
108
+ Execute a shell command with optional stdin input and working directory.
109
+
110
+ Args:
111
+ command (List[str]): Command and its arguments
112
+ stdin (Optional[str]): Input to be passed to the command via stdin
113
+ directory (Optional[str]): Working directory for command execution
114
+
115
+ Returns:
116
+ Dict[str, Any]: Execution result containing stdout, stderr, status code, and execution time.
117
+ If error occurs, result contains additional 'error' field.
118
+ """
119
+ start_time = time.time()
120
+
121
+ try:
122
+ # Clean command before validation and execution
123
+ cleaned_command = self._clean_command(command)
124
+ if not cleaned_command:
125
+ raise ValueError("Empty command")
126
+
127
+ self._validate_command(cleaned_command)
128
+ self._validate_directory(directory)
129
+ except ValueError as e:
130
+ return {
131
+ "error": str(e),
132
+ "status": 1,
133
+ "stdout": "",
134
+ "stderr": str(e),
135
+ "execution_time": time.time() - start_time,
136
+ }
137
+
138
+ try:
139
+ process = await asyncio.create_subprocess_exec(
140
+ cleaned_command[0],
141
+ *cleaned_command[1:],
142
+ stdin=asyncio.subprocess.PIPE if stdin else None,
143
+ stdout=asyncio.subprocess.PIPE,
144
+ stderr=asyncio.subprocess.PIPE,
145
+ env={"PATH": os.environ.get("PATH", "")},
146
+ cwd=directory, # Set working directory if specified
147
+ )
148
+
149
+ stdin_bytes = stdin.encode() if stdin else None
150
+ stdout, stderr = await process.communicate(input=stdin_bytes)
151
+
152
+ return {
153
+ "error": None, # Set error field to None for success case
154
+ "stdout": stdout.decode() if stdout else "",
155
+ "stderr": stderr.decode() if stderr else "",
156
+ "status": process.returncode,
157
+ "execution_time": time.time() - start_time,
158
+ "directory": directory, # Include working directory in response
159
+ }
160
+ except FileNotFoundError:
161
+ return {
162
+ "error": f"Command not found: {cleaned_command[0]}",
163
+ "status": 1,
164
+ "stdout": "",
165
+ "stderr": f"Command not found: {cleaned_command[0]}",
166
+ "execution_time": time.time() - start_time,
167
+ }
168
+ except Exception as e:
169
+ return {
170
+ "error": str(e),
171
+ "status": 1,
172
+ "stdout": "",
173
+ "stderr": str(e),
174
+ "execution_time": time.time() - start_time,
175
+ }
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.3
2
+ Name: mcp-shell-server
3
+ Version: 0.1.0
4
+ Summary: MCP Shell Server - Execute shell commands via MCP protocol
5
+ Author: tumf
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: asyncio>=3.4.3
9
+ Requires-Dist: mcp>=1.1.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: black>=23.3.0; extra == 'dev'
12
+ Requires-Dist: isort>=5.12.0; extra == 'dev'
13
+ Requires-Dist: mypy>=1.2.0; extra == 'dev'
14
+ Requires-Dist: pre-commit>=3.2.2; extra == 'dev'
15
+ Requires-Dist: ruff>=0.0.262; extra == 'dev'
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'test'
18
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'test'
19
+ Requires-Dist: pytest-env>=1.1.0; extra == 'test'
20
+ Requires-Dist: pytest>=7.4.0; extra == 'test'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # MCP Shell Server
24
+
25
+ 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.
26
+
27
+ ## Features
28
+
29
+ * **Secure Command Execution**: Only whitelisted commands can be executed
30
+ * **Standard Input Support**: Pass input to commands via stdin
31
+ * **Comprehensive Output**: Returns stdout, stderr, exit status, and execution time
32
+ * **Shell Operator Safety**: Validates commands after shell operators (; , &&, ||, |)
33
+
34
+ ## MCP client setting in your Claude.app
35
+
36
+ ```shell
37
+ code ~/Library/Application\ Support/Claude/claude_desktop_config.json
38
+ ```
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "shell": {
44
+ "command": "uv",
45
+ "args": [
46
+ "--directory",
47
+ ".",
48
+ "run",
49
+ "mcp-shell-server"
50
+ ],
51
+ "env": {
52
+ "ALLOW_COMMANDS": "ls,cat,pwd,grep,wc,touch,find"
53
+ }
54
+ },
55
+ }
56
+ }
57
+ ```
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ pip install mcp-shell-server
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ### Starting the Server
68
+
69
+ ```bash
70
+ ALLOW_COMMANDS="ls,cat,echo" uvx mcp-shell-server
71
+ ```
72
+
73
+ The `ALLOW_COMMANDS` environment variable specifies which commands are allowed to be executed. Commands can be separated by commas with optional spaces around them.
74
+
75
+ Valid formats for ALLOW_COMMANDS:
76
+
77
+ ```bash
78
+ ALLOW_COMMANDS="ls,cat,echo" # Basic format
79
+ ALLOW_COMMANDS="ls ,echo, cat" # With spaces
80
+ ALLOW_COMMANDS="ls, cat , echo" # Multiple spaces
81
+ ```
82
+
83
+ ### Request Format
84
+
85
+ ```python
86
+ # Basic command execution
87
+ {
88
+ "command": ["ls", "-l", "/tmp"]
89
+ }
90
+
91
+ # Command with stdin input
92
+ {
93
+ "command": ["cat"],
94
+ "stdin": "Hello, World!"
95
+ }
96
+ ```
97
+
98
+ ### Response Format
99
+
100
+ Successful response:
101
+
102
+ ```json
103
+ {
104
+ "stdout": "command output",
105
+ "stderr": "",
106
+ "status": 0,
107
+ "execution_time": 0.123
108
+ }
109
+ ```
110
+
111
+ Error response:
112
+
113
+ ```json
114
+ {
115
+ "error": "Command not allowed: rm",
116
+ "status": 1,
117
+ "stdout": "",
118
+ "stderr": "Command not allowed: rm",
119
+ "execution_time": 0
120
+ }
121
+ ```
122
+
123
+ ## Security
124
+
125
+ The server implements several security measures:
126
+
127
+ 1. **Command Whitelisting**: Only explicitly allowed commands can be executed
128
+ 2. **Shell Operator Validation**: Commands after shell operators (;, &&, ||, |) are also validated against the whitelist
129
+ 3. **No Shell Injection**: Commands are executed directly without shell interpretation
130
+
131
+ ## Development
132
+
133
+ ### Setting up Development Environment
134
+
135
+ 1. Clone the repository
136
+
137
+ ```bash
138
+ git clone https://github.com/yourusername/mcp-shell-server.git
139
+ cd mcp-shell-server
140
+ ```
141
+
142
+ 2. Install dependencies including test requirements
143
+
144
+ ```bash
145
+ pip install -e ".[test]"
146
+ ```
147
+
148
+ ### Running Tests
149
+
150
+ ```bash
151
+ pytest
152
+ ```
153
+
154
+ ## API Reference
155
+
156
+ ### Request Arguments
157
+
158
+ | Field | Type | Required | Description |
159
+ |----------|------------|----------|-----------------------------------------------|
160
+ | command | string[] | Yes | Command and its arguments as array elements |
161
+ | stdin | string | No | Input to be passed to the command |
162
+
163
+ ### Response Fields
164
+
165
+ | Field | Type | Description |
166
+ |----------------|---------|---------------------------------------------|
167
+ | stdout | string | Standard output from the command |
168
+ | stderr | string | Standard error output from the command |
169
+ | status | integer | Exit status code |
170
+ | execution_time | float | Time taken to execute (in seconds) |
171
+ | error | string | Error message (only present if failed) |
172
+
173
+ ## Requirements
174
+
175
+ * Python 3.11 or higher
176
+ * mcp>=1.1.0
177
+
178
+ ## License
179
+
180
+ MIT License - See LICENSE file for details
@@ -0,0 +1,7 @@
1
+ mcp_shell_server/__init__.py,sha256=PXUa6MYNfCK7XRaIeod8TRp4_NXWRhnC2MyZq4iwbjA,271
2
+ mcp_shell_server/server.py,sha256=EPQ3p232eeak4vAfaKJKn5dXa7CWZK4oawRKnng5JUU,3890
3
+ mcp_shell_server/shell_executor.py,sha256=5_wKIQQzobaYXdCUYe17qPDH4GMmOgop4wm3IDmDcG0,6329
4
+ mcp_shell_server-0.1.0.dist-info/METADATA,sha256=A0L0xv1ex-xoaUtebiQ-DWA0bHFwoO_tp-0_yhkj56M,4400
5
+ mcp_shell_server-0.1.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
6
+ mcp_shell_server-0.1.0.dist-info/entry_points.txt,sha256=cliA1OUE6oGFNpFqxK39iVUAEYITAYn1JnufKxl5Kn8,59
7
+ mcp_shell_server-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.26.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-shell-server = mcp_shell_server:main