cli-mcp-server 0.2.3__tar.gz → 0.2.5__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-mcp-server
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Command line interface for MCP clients with secure execution and customizable security policies
5
5
  Project-URL: Homepage, https://github.com/MladenSU/cli-mcp-server
6
6
  Project-URL: Documentation, https://github.com/MladenSU/cli-mcp-server#readme
@@ -9,7 +9,7 @@ Project-URL: Bug Tracker, https://github.com/MladenSU/cli-mcp-server/issues
9
9
  Author-email: Mladen <fangs-lever6n@icloud.com>
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.10
12
- Requires-Dist: mcp>=1.6.0
12
+ Requires-Dist: mcp>=1.10.1
13
13
  Description-Content-Type: text/markdown
14
14
 
15
15
  # CLI MCP Server
@@ -23,6 +23,7 @@ comprehensive security features.
23
23
  ![Python Version](https://img.shields.io/badge/python-3.10%2B-blue)
24
24
  ![MCP Protocol](https://img.shields.io/badge/MCP-Compatible-green)
25
25
  [![smithery badge](https://smithery.ai/badge/cli-mcp-server)](https://smithery.ai/protocol/cli-mcp-server)
26
+ [![Python Tests](https://github.com/MladenSU/cli-mcp-server/actions/workflows/python-tests.yml/badge.svg)](https://github.com/MladenSU/cli-mcp-server/actions/workflows/python-tests.yml)
26
27
 
27
28
  <a href="https://glama.ai/mcp/servers/q89277vzl1"><img width="380" height="200" src="https://glama.ai/mcp/servers/q89277vzl1/badge" /></a>
28
29
 
@@ -76,6 +77,7 @@ Configure the server using environment variables:
76
77
  | `ALLOWED_FLAGS` | Comma-separated list of allowed flags or 'all' | `-l,-a,--help` |
77
78
  | `MAX_COMMAND_LENGTH`| Maximum command string length | `1024` |
78
79
  | `COMMAND_TIMEOUT` | Command execution timeout (seconds) | `30` |
80
+ | `ALLOW_SHELL_OPERATORS` | Allow shell operators (&&, \|\|, \|, >, etc.) | `false` |
79
81
 
80
82
  Note: Setting `ALLOWED_COMMANDS` or `ALLOWED_FLAGS` to 'all' will allow any command or flag respectively.
81
83
 
@@ -104,7 +106,7 @@ Executes whitelisted CLI commands within allowed directories.
104
106
  ```
105
107
 
106
108
  **Security Notes:**
107
- - Shell operators (&&, |, >, >>) are not supported
109
+ - Shell operators (&&, |, >, >>) are not supported by default, but can be enabled with `ALLOW_SHELL_OPERATORS=true`
108
110
  - Commands must be whitelisted unless ALLOWED_COMMANDS='all'
109
111
  - Flags must be whitelisted unless ALLOWED_FLAGS='all'
110
112
  - All paths are validated to be within ALLOWED_DIR
@@ -139,7 +141,8 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
139
141
  "ALLOWED_COMMANDS": "ls,cat,pwd,echo",
140
142
  "ALLOWED_FLAGS": "-l,-a,--help,--version",
141
143
  "MAX_COMMAND_LENGTH": "1024",
142
- "COMMAND_TIMEOUT": "30"
144
+ "COMMAND_TIMEOUT": "30",
145
+ "ALLOW_SHELL_OPERATORS": "false"
143
146
  }
144
147
  }
145
148
  }
@@ -161,7 +164,8 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
161
164
  "ALLOWED_COMMANDS": "ls,cat,pwd,echo",
162
165
  "ALLOWED_FLAGS": "-l,-a,--help,--version",
163
166
  "MAX_COMMAND_LENGTH": "1024",
164
- "COMMAND_TIMEOUT": "30"
167
+ "COMMAND_TIMEOUT": "30",
168
+ "ALLOW_SHELL_OPERATORS": "false"
165
169
  }
166
170
  }
167
171
  }
@@ -174,7 +178,7 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
174
178
  - ✅ Command whitelist enforcement with 'all' option
175
179
  - ✅ Flag validation with 'all' option
176
180
  - ✅ Path traversal prevention and normalization
177
- - ✅ Shell operator blocking
181
+ - ✅ Shell operator blocking (with opt-in support via `ALLOW_SHELL_OPERATORS=true`)
178
182
  - ✅ Command length limits
179
183
  - ✅ Execution timeouts
180
184
  - ✅ Working directory restrictions
@@ -9,6 +9,7 @@ comprehensive security features.
9
9
  ![Python Version](https://img.shields.io/badge/python-3.10%2B-blue)
10
10
  ![MCP Protocol](https://img.shields.io/badge/MCP-Compatible-green)
11
11
  [![smithery badge](https://smithery.ai/badge/cli-mcp-server)](https://smithery.ai/protocol/cli-mcp-server)
12
+ [![Python Tests](https://github.com/MladenSU/cli-mcp-server/actions/workflows/python-tests.yml/badge.svg)](https://github.com/MladenSU/cli-mcp-server/actions/workflows/python-tests.yml)
12
13
 
13
14
  <a href="https://glama.ai/mcp/servers/q89277vzl1"><img width="380" height="200" src="https://glama.ai/mcp/servers/q89277vzl1/badge" /></a>
14
15
 
@@ -62,6 +63,7 @@ Configure the server using environment variables:
62
63
  | `ALLOWED_FLAGS` | Comma-separated list of allowed flags or 'all' | `-l,-a,--help` |
63
64
  | `MAX_COMMAND_LENGTH`| Maximum command string length | `1024` |
64
65
  | `COMMAND_TIMEOUT` | Command execution timeout (seconds) | `30` |
66
+ | `ALLOW_SHELL_OPERATORS` | Allow shell operators (&&, \|\|, \|, >, etc.) | `false` |
65
67
 
66
68
  Note: Setting `ALLOWED_COMMANDS` or `ALLOWED_FLAGS` to 'all' will allow any command or flag respectively.
67
69
 
@@ -90,7 +92,7 @@ Executes whitelisted CLI commands within allowed directories.
90
92
  ```
91
93
 
92
94
  **Security Notes:**
93
- - Shell operators (&&, |, >, >>) are not supported
95
+ - Shell operators (&&, |, >, >>) are not supported by default, but can be enabled with `ALLOW_SHELL_OPERATORS=true`
94
96
  - Commands must be whitelisted unless ALLOWED_COMMANDS='all'
95
97
  - Flags must be whitelisted unless ALLOWED_FLAGS='all'
96
98
  - All paths are validated to be within ALLOWED_DIR
@@ -125,7 +127,8 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
125
127
  "ALLOWED_COMMANDS": "ls,cat,pwd,echo",
126
128
  "ALLOWED_FLAGS": "-l,-a,--help,--version",
127
129
  "MAX_COMMAND_LENGTH": "1024",
128
- "COMMAND_TIMEOUT": "30"
130
+ "COMMAND_TIMEOUT": "30",
131
+ "ALLOW_SHELL_OPERATORS": "false"
129
132
  }
130
133
  }
131
134
  }
@@ -147,7 +150,8 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
147
150
  "ALLOWED_COMMANDS": "ls,cat,pwd,echo",
148
151
  "ALLOWED_FLAGS": "-l,-a,--help,--version",
149
152
  "MAX_COMMAND_LENGTH": "1024",
150
- "COMMAND_TIMEOUT": "30"
153
+ "COMMAND_TIMEOUT": "30",
154
+ "ALLOW_SHELL_OPERATORS": "false"
151
155
  }
152
156
  }
153
157
  }
@@ -160,7 +164,7 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
160
164
  - ✅ Command whitelist enforcement with 'all' option
161
165
  - ✅ Flag validation with 'all' option
162
166
  - ✅ Path traversal prevention and normalization
163
- - ✅ Shell operator blocking
167
+ - ✅ Shell operator blocking (with opt-in support via `ALLOW_SHELL_OPERATORS=true`)
164
168
  - ✅ Command length limits
165
169
  - ✅ Execution timeouts
166
170
  - ✅ Working directory restrictions
@@ -1,10 +1,10 @@
1
1
  [project]
2
2
  name = "cli-mcp-server"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "Command line interface for MCP clients with secure execution and customizable security policies"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
- dependencies = ["mcp>=1.6.0"]
7
+ dependencies = ["mcp>=1.10.1"]
8
8
  authors = [
9
9
  { name = "Mladen", email = "fangs-lever6n@icloud.com" },
10
10
  ]
@@ -49,6 +49,7 @@ class SecurityConfig:
49
49
  command_timeout: int
50
50
  allow_all_commands: bool = False
51
51
  allow_all_flags: bool = False
52
+ allow_shell_operators: bool = False
52
53
 
53
54
 
54
55
  class CommandExecutor:
@@ -87,29 +88,103 @@ class CommandExecutor:
87
88
  """
88
89
  Validates and parses a command string for security and formatting.
89
90
 
90
- Checks the command string for unsupported shell operators and splits it into
91
- command and arguments. Only single commands without shell operators are allowed.
91
+ Checks if the command string contains shell operators. If it does, splits the command
92
+ by operators and validates each part individually. If all parts are valid, returns
93
+ the original command string to be executed with shell=True.
94
+
95
+ For commands without shell operators, splits into command and arguments and validates
96
+ each part according to security rules.
92
97
 
93
98
  Args:
94
99
  command_string (str): The command string to validate and parse.
95
100
 
96
101
  Returns:
97
102
  tuple[str, List[str]]: A tuple containing:
98
- - The command name (str)
99
- - List of command arguments (List[str])
103
+ - For regular commands: The command name (str) and list of arguments (List[str])
104
+ - For commands with shell operators: The full command string and empty args list
100
105
 
101
106
  Raises:
102
- CommandSecurityError: If the command contains unsupported shell operators.
107
+ CommandSecurityError: If any part of the command fails security validation.
103
108
  """
104
109
 
105
- # Check for shell operators that we don't support
110
+ # Define shell operators
106
111
  shell_operators = ["&&", "||", "|", ">", ">>", "<", "<<", ";"]
107
- for operator in shell_operators:
108
- if operator in command_string:
109
- raise CommandSecurityError(
110
- f"Shell operator '{operator}' is not supported"
111
- )
112
112
 
113
+ # Check if command contains shell operators
114
+ contains_shell_operator = any(
115
+ operator in command_string for operator in shell_operators
116
+ )
117
+
118
+ if contains_shell_operator:
119
+ # Check if shell operators are allowed
120
+ if not self.security_config.allow_shell_operators:
121
+ # If shell operators are not allowed, raise an error
122
+ for operator in shell_operators:
123
+ if operator in command_string:
124
+ raise CommandSecurityError(
125
+ f"Shell operator '{operator}' is not supported. Set ALLOW_SHELL_OPERATORS=true to enable."
126
+ )
127
+
128
+ # Split the command by shell operators and validate each part
129
+ return self._validate_command_with_operators(
130
+ command_string, shell_operators
131
+ )
132
+
133
+ # Process single command without shell operators
134
+ return self._validate_single_command(command_string)
135
+
136
+ def _is_url_path(self, path: str) -> bool:
137
+ """
138
+ Checks if a given path is a URL of type http or https.
139
+
140
+ Args:
141
+ path (str): The path to check.
142
+
143
+ Returns:
144
+ bool: True if the path is a URL, False otherwise.
145
+ """
146
+ url_pattern = re.compile(r"^https?://")
147
+ return bool(url_pattern.match(path))
148
+
149
+ def _is_path_safe(self, path: str) -> bool:
150
+ """
151
+ Checks if a given path is safe to access within allowed directory boundaries.
152
+
153
+ Validates that the absolute resolved path is within the allowed directory
154
+ to prevent directory traversal attacks.
155
+
156
+ Args:
157
+ path (str): The path to validate.
158
+
159
+ Returns:
160
+ bool: True if path is within allowed directory, False otherwise.
161
+ Returns False if path resolution fails for any reason.
162
+
163
+ Private method intended for internal use only.
164
+ """
165
+ try:
166
+ # Resolve any symlinks and get absolute path
167
+ real_path = os.path.abspath(os.path.realpath(path))
168
+ allowed_dir_real = os.path.abspath(os.path.realpath(self.allowed_dir))
169
+
170
+ # Check if the path starts with allowed_dir
171
+ return real_path.startswith(allowed_dir_real)
172
+ except Exception:
173
+ return False
174
+
175
+ def _validate_single_command(self, command_string: str) -> tuple[str, List[str]]:
176
+ """
177
+ Validates a single command without shell operators.
178
+
179
+ Args:
180
+ command_string (str): The command string to validate.
181
+
182
+ Returns:
183
+ tuple[str, List[str]]: A tuple containing the command and validated arguments.
184
+
185
+ Raises:
186
+ CommandSecurityError: If the command fails validation.
187
+ """
113
188
  try:
114
189
  parts = shlex.split(command_string)
115
190
  if not parts:
@@ -127,6 +202,8 @@ class CommandExecutor:
127
202
  # Process and validate arguments
128
203
  validated_args = []
129
204
  for arg in args:
205
+ is_explicit_path = (arg.startswith(("./", "../", "/")) and not arg.startswith("//")) or arg == "."
206
+
130
207
  if arg.startswith("-"):
131
208
  if (
132
209
  not self.security_config.allow_all_flags
@@ -135,9 +212,8 @@ class CommandExecutor:
135
212
  raise CommandSecurityError(f"Flag '{arg}' is not allowed")
136
213
  validated_args.append(arg)
137
214
  continue
138
-
139
215
  # For any path-like argument, validate it
140
- if "/" in arg or "\\" in arg or os.path.isabs(arg) or arg == ".":
216
+ if is_explicit_path or ("/" in arg and os.path.exists(os.path.join(self.allowed_dir, arg))):
141
217
  if self._is_url_path(arg):
142
218
  # If it's a URL, we don't need to normalize it
143
219
  validated_args.append(arg)
@@ -154,44 +230,69 @@ class CommandExecutor:
154
230
  except ValueError as e:
155
231
  raise CommandSecurityError(f"Invalid command format: {str(e)}")
156
232
 
157
- def _is_url_path(self, path: str) -> bool:
158
- """
159
- Checks if a given path is a URL of type http or https.
160
-
161
- Args:
162
- path (str): The path to check.
163
-
164
- Returns:
165
- bool: True if the path is a URL, False otherwise.
166
- """
167
- url_pattern = re.compile(r"^https?://")
168
- return bool(url_pattern.match(path))
169
-
170
- def _is_path_safe(self, path: str) -> bool:
233
+ def _validate_command_with_operators(
234
+ self, command_string: str, shell_operators: List[str]
235
+ ) -> tuple[str, List[str]]:
171
236
  """
172
- Checks if a given path is safe to access within allowed directory boundaries.
237
+ Validates a command string that contains shell operators.
173
238
 
174
- Validates that the absolute resolved path is within the allowed directory
175
- to prevent directory traversal attacks.
239
+ Splits the command string by shell operators and validates each part individually.
240
+ If all parts are valid, returns the original command to be executed with shell=True.
176
241
 
177
242
  Args:
178
- path (str): The path to validate.
243
+ command_string (str): The command string containing shell operators.
244
+ shell_operators (List[str]): List of shell operators to split by.
179
245
 
180
246
  Returns:
181
- bool: True if path is within allowed directory, False otherwise.
182
- Returns False if path resolution fails for any reason.
247
+ tuple[str, List[str]]: A tuple containing the command and empty args list
248
+ (since the command will be executed with shell=True)
183
249
 
184
- Private method intended for internal use only.
250
+ Raises:
251
+ CommandSecurityError: If any part of the command fails validation.
185
252
  """
186
- try:
187
- # Resolve any symlinks and get absolute path
188
- real_path = os.path.abspath(os.path.realpath(path))
189
- allowed_dir_real = os.path.abspath(os.path.realpath(self.allowed_dir))
253
+ # Create a regex pattern to split by any of the shell operators
254
+ # We need to escape special regex characters in the operators
255
+ escaped_operators = [re.escape(op) for op in shell_operators]
256
+ pattern = "|".join(escaped_operators)
257
+
258
+ # Split the command string by shell operators, keeping the operators
259
+ parts = re.split(f"({pattern})", command_string)
260
+
261
+ # Filter out empty parts and whitespace-only parts
262
+ parts = [part.strip() for part in parts if part.strip()]
263
+
264
+ # Group commands and operators
265
+ commands = []
266
+ i = 0
267
+ while i < len(parts):
268
+ if i + 1 < len(parts) and parts[i + 1] in shell_operators:
269
+ # If next part is an operator, current part is a command
270
+ if parts[i]: # Skip empty commands
271
+ commands.append(parts[i])
272
+ i += 2 # Skip the operator
273
+ else:
274
+ # If no operator follows, this is the last command
275
+ if (
276
+ parts[i] and parts[i] not in shell_operators
277
+ ): # Skip if it's an operator
278
+ commands.append(parts[i])
279
+ i += 1
280
+
281
+ # Validate each command individually
282
+ for cmd in commands:
283
+ try:
284
+ # Use the extracted validation method for each command
285
+ self._validate_single_command(cmd)
286
+ except CommandSecurityError as e:
287
+ raise CommandSecurityError(f"Invalid command part '{cmd}': {str(e)}")
288
+ except ValueError as e:
289
+ raise CommandSecurityError(
290
+ f"Invalid command format in '{cmd}': {str(e)}"
291
+ )
190
292
 
191
- # Check if the path starts with allowed_dir
192
- return real_path.startswith(allowed_dir_real)
193
- except Exception:
194
- return False
293
+ # If we get here, all commands passed validation
294
+ # Return the original command string to be executed with shell=True
295
+ return command_string, []
195
296
 
196
297
  def execute(self, command_string: str) -> subprocess.CompletedProcess:
197
298
  """
@@ -210,12 +311,11 @@ class CommandExecutor:
210
311
  Raises:
211
312
  CommandSecurityError: If the command:
212
313
  - Exceeds maximum length
213
- - Contains invalid shell operators
214
314
  - Fails security validation
215
315
  - Fails during execution
216
316
 
217
317
  Notes:
218
- - Executes with shell=False for security
318
+ - Uses shell=True for commands with shell operators, shell=False otherwise
219
319
  - Uses timeout and working directory constraints
220
320
  - Captures both stdout and stderr
221
321
  """
@@ -227,14 +327,38 @@ class CommandExecutor:
227
327
  try:
228
328
  command, args = self.validate_command(command_string)
229
329
 
230
- return subprocess.run(
231
- [command] + args,
232
- shell=False,
233
- text=True,
234
- capture_output=True,
235
- timeout=self.security_config.command_timeout,
236
- cwd=self.allowed_dir,
237
- )
330
+ # Check if this is a command with shell operators
331
+ shell_operators = ["&&", "||", "|", ">", ">>", "<", "<<", ";"]
332
+ use_shell = any(operator in command_string for operator in shell_operators)
333
+
334
+ # Double-check that shell operators are allowed if they are present
335
+ if use_shell and not self.security_config.allow_shell_operators:
336
+ for operator in shell_operators:
337
+ if operator in command_string:
338
+ raise CommandSecurityError(
339
+ f"Shell operator '{operator}' is not supported. Set ALLOW_SHELL_OPERATORS=true to enable."
340
+ )
341
+
342
+ if use_shell:
343
+ # For commands with shell operators, execute with shell=True
344
+ return subprocess.run(
345
+ command, # command is the full command string in this case
346
+ shell=True,
347
+ text=True,
348
+ capture_output=True,
349
+ timeout=self.security_config.command_timeout,
350
+ cwd=self.allowed_dir,
351
+ )
352
+ else:
353
+ # For regular commands, execute with shell=False
354
+ return subprocess.run(
355
+ [command] + args,
356
+ shell=False,
357
+ text=True,
358
+ capture_output=True,
359
+ timeout=self.security_config.command_timeout,
360
+ cwd=self.allowed_dir,
361
+ )
238
362
  except subprocess.TimeoutExpired:
239
363
  raise CommandTimeoutError(
240
364
  f"Command timed out after {self.security_config.command_timeout} seconds"
@@ -262,18 +386,23 @@ def load_security_config() -> SecurityConfig:
262
386
  - command_timeout: Maximum execution time in seconds
263
387
  - allow_all_commands: Whether all commands are allowed
264
388
  - allow_all_flags: Whether all flags are allowed
389
+ - allow_shell_operators: Whether shell operators (&&, ||, |, etc.) are allowed
265
390
 
266
391
  Environment Variables:
267
392
  ALLOWED_COMMANDS: Comma-separated list of allowed commands or 'all' (default: "ls,cat,pwd")
268
393
  ALLOWED_FLAGS: Comma-separated list of allowed flags or 'all' (default: "-l,-a,--help")
269
394
  MAX_COMMAND_LENGTH: Maximum command string length (default: 1024)
270
395
  COMMAND_TIMEOUT: Command timeout in seconds (default: 30)
396
+ ALLOW_SHELL_OPERATORS: Whether to allow shell operators like &&, ||, |, >, etc. (default: false)
397
+ Set to "true" or "1" to enable, any other value to disable.
271
398
  """
272
399
  allowed_commands = os.getenv("ALLOWED_COMMANDS", "ls,cat,pwd")
273
400
  allowed_flags = os.getenv("ALLOWED_FLAGS", "-l,-a,--help")
401
+ allow_shell_operators_env = os.getenv("ALLOW_SHELL_OPERATORS", "false")
274
402
 
275
403
  allow_all_commands = allowed_commands.lower() == "all"
276
404
  allow_all_flags = allowed_flags.lower() == "all"
405
+ allow_shell_operators = allow_shell_operators_env.lower() in ("true", "1")
277
406
 
278
407
  return SecurityConfig(
279
408
  allowed_commands=(
@@ -284,6 +413,7 @@ def load_security_config() -> SecurityConfig:
284
413
  command_timeout=int(os.getenv("COMMAND_TIMEOUT", "30")),
285
414
  allow_all_commands=allow_all_commands,
286
415
  allow_all_flags=allow_all_flags,
416
+ allow_shell_operators=allow_shell_operators,
287
417
  )
288
418
 
289
419
 
@@ -312,7 +442,7 @@ async def handle_list_tools() -> list[types.Tool]:
312
442
  f"Allows command (CLI) execution in the directory: {executor.allowed_dir}\n\n"
313
443
  f"Available commands: {commands_desc}\n"
314
444
  f"Available flags: {flags_desc}\n\n"
315
- "Note: Shell operators (&&, |, >, >>) are not supported."
445
+ f"Shell operators (&&, ||, |, >, >>, <, <<, ;) are {'supported' if executor.security_config.allow_shell_operators else 'not supported'}. Set ALLOW_SHELL_OPERATORS=true to enable."
316
446
  ),
317
447
  inputSchema={
318
448
  "type": "object",