cli-mcp-server 0.1.2__py3-none-any.whl → 0.2.1__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.
@@ -9,4 +9,4 @@ def main():
9
9
 
10
10
 
11
11
  # Optionally expose other important items at package level
12
- __all__ = ['main', 'server']
12
+ __all__ = ["main", "server"]
cli_mcp_server/server.py CHANGED
@@ -13,10 +13,26 @@ from mcp.server.models import InitializationOptions
13
13
  server = Server("cli-mcp-server")
14
14
 
15
15
 
16
- class CommandSecurityError(Exception):
17
- """
18
- Custom exception for command security violations
19
- """
16
+ class CommandError(Exception):
17
+ """Base exception for command-related errors"""
18
+
19
+ pass
20
+
21
+
22
+ class CommandSecurityError(CommandError):
23
+ """Security violation errors"""
24
+
25
+ pass
26
+
27
+
28
+ class CommandExecutionError(CommandError):
29
+ """Command execution errors"""
30
+
31
+ pass
32
+
33
+
34
+ class CommandTimeoutError(CommandError):
35
+ """Command timeout errors"""
20
36
 
21
37
  pass
22
38
 
@@ -29,9 +45,10 @@ class SecurityConfig:
29
45
 
30
46
  allowed_commands: set[str]
31
47
  allowed_flags: set[str]
32
- allowed_patterns: List[str]
33
48
  max_command_length: int
34
49
  command_timeout: int
50
+ allow_all_commands: bool = False
51
+ allow_all_flags: bool = False
35
52
 
36
53
 
37
54
  class CommandExecutor:
@@ -41,6 +58,27 @@ class CommandExecutor:
41
58
  self.allowed_dir = os.path.abspath(os.path.realpath(allowed_dir))
42
59
  self.security_config = security_config
43
60
 
61
+ def _normalize_path(self, path: str) -> str:
62
+ """
63
+ Normalizes a path and ensures it's within allowed directory.
64
+ """
65
+ try:
66
+ if os.path.isabs(path):
67
+ # If absolute path, check directly
68
+ real_path = os.path.abspath(os.path.realpath(path))
69
+ else:
70
+ # If relative path, combine with allowed_dir first
71
+ real_path = os.path.abspath(os.path.realpath(os.path.join(self.allowed_dir, path)))
72
+
73
+ if not self._is_path_safe(real_path):
74
+ raise CommandSecurityError(f"Path '{path}' is outside of allowed directory: {self.allowed_dir}")
75
+
76
+ return real_path
77
+ except CommandSecurityError:
78
+ raise
79
+ except Exception as e:
80
+ raise CommandSecurityError(f"Invalid path '{path}': {str(e)}")
81
+
44
82
  def validate_command(self, command_string: str) -> tuple[str, List[str]]:
45
83
  """
46
84
  Validates and parses a command string for security and formatting.
@@ -64,10 +102,7 @@ class CommandExecutor:
64
102
  shell_operators = ["&&", "||", "|", ">", ">>", "<", "<<", ";"]
65
103
  for operator in shell_operators:
66
104
  if operator in command_string:
67
- raise CommandSecurityError(
68
- f"Shell operator '{operator}' is not supported. "
69
- "Only single commands are allowed."
70
- )
105
+ raise CommandSecurityError(f"Shell operator '{operator}' is not supported")
71
106
 
72
107
  try:
73
108
  parts = shlex.split(command_string)
@@ -76,33 +111,29 @@ class CommandExecutor:
76
111
 
77
112
  command, args = parts[0], parts[1:]
78
113
 
79
- # Validate command
80
- if command not in self.security_config.allowed_commands:
114
+ # Validate command if not in allow-all mode
115
+ if not self.security_config.allow_all_commands and command not in self.security_config.allowed_commands:
81
116
  raise CommandSecurityError(f"Command '{command}' is not allowed")
82
117
 
83
- # Validate arguments
118
+ # Process and validate arguments
119
+ validated_args = []
84
120
  for arg in args:
85
121
  if arg.startswith("-"):
86
- if arg not in self.security_config.allowed_flags:
122
+ if not self.security_config.allow_all_flags and arg not in self.security_config.allowed_flags:
87
123
  raise CommandSecurityError(f"Flag '{arg}' is not allowed")
124
+ validated_args.append(arg)
88
125
  continue
89
126
 
90
- # Validate path if argument looks like a path
91
- if "/" in arg or "\\" in arg or os.path.isabs(arg):
92
- full_path = os.path.abspath(os.path.join(self.allowed_dir, arg))
93
- if not self._is_path_safe(full_path):
94
- raise CommandSecurityError(f"Path '{arg}' is not allowed")
95
-
96
- # Check patterns
97
- if not any(
98
- re.match(pattern, arg)
99
- for pattern in self.security_config.allowed_patterns
100
- ):
101
- raise CommandSecurityError(
102
- f"Argument '{arg}' doesn't match allowed patterns"
103
- )
104
-
105
- return command, args
127
+ # For any path-like argument, validate it
128
+ if "/" in arg or "\\" in arg or os.path.isabs(arg) or arg == ".":
129
+ normalized_path = self._normalize_path(arg)
130
+ validated_args.append(normalized_path)
131
+ else:
132
+ # For non-path arguments, add them as-is
133
+ validated_args.append(arg)
134
+
135
+ return command, validated_args
136
+
106
137
  except ValueError as e:
107
138
  raise CommandSecurityError(f"Invalid command format: {str(e)}")
108
139
 
@@ -123,8 +154,12 @@ class CommandExecutor:
123
154
  Private method intended for internal use only.
124
155
  """
125
156
  try:
126
- abs_path = os.path.abspath(os.path.realpath(path))
127
- return abs_path.startswith(self.allowed_dir)
157
+ # Resolve any symlinks and get absolute path
158
+ real_path = os.path.abspath(os.path.realpath(path))
159
+ allowed_dir_real = os.path.abspath(os.path.realpath(self.allowed_dir))
160
+
161
+ # Check if the path starts with allowed_dir
162
+ return real_path.startswith(allowed_dir_real)
128
163
  except Exception:
129
164
  return False
130
165
 
@@ -155,11 +190,11 @@ class CommandExecutor:
155
190
  - Captures both stdout and stderr
156
191
  """
157
192
  if len(command_string) > self.security_config.max_command_length:
158
- raise CommandSecurityError("Command string too long")
193
+ raise CommandSecurityError(f"Command exceeds maximum length of {self.security_config.max_command_length}")
159
194
 
160
195
  try:
161
-
162
196
  command, args = self.validate_command(command_string)
197
+
163
198
  return subprocess.run(
164
199
  [command] + args,
165
200
  shell=False,
@@ -168,10 +203,12 @@ class CommandExecutor:
168
203
  timeout=self.security_config.command_timeout,
169
204
  cwd=self.allowed_dir,
170
205
  )
206
+ except subprocess.TimeoutExpired:
207
+ raise CommandTimeoutError(f"Command timed out after {self.security_config.command_timeout} seconds")
208
+ except CommandError:
209
+ raise
171
210
  except Exception as e:
172
- if isinstance(e, CommandSecurityError):
173
- raise
174
- raise CommandSecurityError(f"Command execution failed: {str(e)}")
211
+ raise CommandExecutionError(f"Command execution failed: {str(e)}")
175
212
 
176
213
 
177
214
  # Load security configuration from environment
@@ -187,43 +224,48 @@ def load_security_config() -> SecurityConfig:
187
224
  SecurityConfig: Configuration object containing:
188
225
  - allowed_commands: Set of permitted command names
189
226
  - allowed_flags: Set of permitted command flags/options
190
- - allowed_patterns: List of regex patterns for valid inputs
191
227
  - max_command_length: Maximum length of command string
192
228
  - command_timeout: Maximum execution time in seconds
229
+ - allow_all_commands: Whether all commands are allowed
230
+ - allow_all_flags: Whether all flags are allowed
193
231
 
194
232
  Environment Variables:
195
- ALLOWED_COMMANDS: Comma-separated list of allowed commands (default: "ls,cat,pwd")
196
- ALLOWED_FLAGS: Comma-separated list of allowed flags (default: "-l,-a,--help")
197
- ALLOWED_PATTERNS: Comma-separated list of patterns (default: "*.txt,*.log,*.md")
233
+ ALLOWED_COMMANDS: Comma-separated list of allowed commands or 'all' (default: "ls,cat,pwd")
234
+ ALLOWED_FLAGS: Comma-separated list of allowed flags or 'all' (default: "-l,-a,--help")
198
235
  MAX_COMMAND_LENGTH: Maximum command string length (default: 1024)
199
236
  COMMAND_TIMEOUT: Command timeout in seconds (default: 30)
200
237
  """
238
+ allowed_commands = os.getenv("ALLOWED_COMMANDS", "ls,cat,pwd")
239
+ allowed_flags = os.getenv("ALLOWED_FLAGS", "-l,-a,--help")
240
+
241
+ allow_all_commands = allowed_commands.lower() == 'all'
242
+ allow_all_flags = allowed_flags.lower() == 'all'
243
+
201
244
  return SecurityConfig(
202
- allowed_commands=set(os.getenv("ALLOWED_COMMANDS", "ls,cat,pwd").split(",")),
203
- allowed_flags=set(os.getenv("ALLOWED_FLAGS", "-l,-a,--help").split(",")),
204
- allowed_patterns=[
205
- r"^[\w\-. ]+$", # Basic filename pattern
206
- *os.getenv("ALLOWED_PATTERNS", "*.txt,*.log,*.md").split(","),
207
- ],
245
+ allowed_commands=set() if allow_all_commands else set(allowed_commands.split(",")),
246
+ allowed_flags=set() if allow_all_flags else set(allowed_flags.split(",")),
208
247
  max_command_length=int(os.getenv("MAX_COMMAND_LENGTH", "1024")),
209
248
  command_timeout=int(os.getenv("COMMAND_TIMEOUT", "30")),
249
+ allow_all_commands=allow_all_commands,
250
+ allow_all_flags=allow_all_flags,
210
251
  )
211
252
 
212
253
 
213
- executor = CommandExecutor(
214
- allowed_dir=os.getenv("ALLOWED_DIR", ""), security_config=load_security_config()
215
- )
254
+ executor = CommandExecutor(allowed_dir=os.getenv("ALLOWED_DIR", ""), security_config=load_security_config())
216
255
 
217
256
 
218
257
  @server.list_tools()
219
258
  async def handle_list_tools() -> list[types.Tool]:
259
+ commands_desc = "all commands" if executor.security_config.allow_all_commands else ", ".join(executor.security_config.allowed_commands)
260
+ flags_desc = "all flags" if executor.security_config.allow_all_flags else ", ".join(executor.security_config.allowed_flags)
261
+
220
262
  return [
221
263
  types.Tool(
222
264
  name="run_command",
223
265
  description=(
224
266
  f"Allows command (CLI) execution in the directory: {executor.allowed_dir}\n\n"
225
- f"Available commands: {', '.join(executor.security_config.allowed_commands)}\n"
226
- f"Available flags: {', '.join(executor.security_config.allowed_flags)}\n\n"
267
+ f"Available commands: {commands_desc}\n"
268
+ f"Available flags: {flags_desc}\n\n"
227
269
  "Note: Shell operators (&&, |, >, >>) are not supported."
228
270
  ),
229
271
  inputSchema={
@@ -231,7 +273,7 @@ async def handle_list_tools() -> list[types.Tool]:
231
273
  "properties": {
232
274
  "command": {
233
275
  "type": "string",
234
- "description": "Single command to execute (example: 'ls -l' or 'cat file.txt')"
276
+ "description": "Single command to execute (example: 'ls -l' or 'cat file.txt')",
235
277
  }
236
278
  },
237
279
  "required": ["command"],
@@ -239,26 +281,20 @@ async def handle_list_tools() -> list[types.Tool]:
239
281
  ),
240
282
  types.Tool(
241
283
  name="show_security_rules",
242
- description=(
243
- "Show what commands and operations are allowed in this environment.\n"
244
- ),
284
+ description=("Show what commands and operations are allowed in this environment.\n"),
245
285
  inputSchema={
246
286
  "type": "object",
247
287
  "properties": {},
248
288
  },
249
- )
289
+ ),
250
290
  ]
251
291
 
252
292
 
253
293
  @server.call_tool()
254
- async def handle_call_tool(
255
- name: str, arguments: Optional[Dict[str, Any]]
256
- ) -> List[types.TextContent]:
294
+ async def handle_call_tool(name: str, arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
257
295
  if name == "run_command":
258
296
  if not arguments or "command" not in arguments:
259
- return [
260
- types.TextContent(type="text", text="No command provided", error=True)
261
- ]
297
+ return [types.TextContent(type="text", text="No command provided", error=True)]
262
298
 
263
299
  try:
264
300
  result = executor.execute(arguments["command"])
@@ -267,9 +303,7 @@ async def handle_call_tool(
267
303
  if result.stdout:
268
304
  response.append(types.TextContent(type="text", text=result.stdout))
269
305
  if result.stderr:
270
- response.append(
271
- types.TextContent(type="text", text=result.stderr, error=True)
272
- )
306
+ response.append(types.TextContent(type="text", text=result.stderr, error=True))
273
307
 
274
308
  response.append(
275
309
  types.TextContent(
@@ -281,11 +315,7 @@ async def handle_call_tool(
281
315
  return response
282
316
 
283
317
  except CommandSecurityError as e:
284
- return [
285
- types.TextContent(
286
- type="text", text=f"Security violation: {str(e)}", error=True
287
- )
288
- ]
318
+ return [types.TextContent(type="text", text=f"Security violation: {str(e)}", error=True)]
289
319
  except subprocess.TimeoutExpired:
290
320
  return [
291
321
  types.TextContent(
@@ -298,19 +328,19 @@ async def handle_call_tool(
298
328
  return [types.TextContent(type="text", text=f"Error: {str(e)}", error=True)]
299
329
 
300
330
  elif name == "show_security_rules":
331
+ commands_desc = "All commands allowed" if executor.security_config.allow_all_commands else ", ".join(sorted(executor.security_config.allowed_commands))
332
+ flags_desc = "All flags allowed" if executor.security_config.allow_all_flags else ", ".join(sorted(executor.security_config.allowed_flags))
333
+
301
334
  security_info = (
302
335
  "Security Configuration:\n"
303
336
  f"==================\n"
304
337
  f"Working Directory: {executor.allowed_dir}\n"
305
338
  f"\nAllowed Commands:\n"
306
339
  f"----------------\n"
307
- f"{', '.join(sorted(executor.security_config.allowed_commands))}\n"
340
+ f"{commands_desc}\n"
308
341
  f"\nAllowed Flags:\n"
309
342
  f"-------------\n"
310
- f"{', '.join(sorted(executor.security_config.allowed_flags))}\n"
311
- f"\nAllowed Patterns:\n"
312
- f"----------------\n"
313
- f"{', '.join(executor.security_config.allowed_patterns)}\n"
343
+ f"{flags_desc}\n"
314
344
  f"\nSecurity Limits:\n"
315
345
  f"---------------\n"
316
346
  f"Max Command Length: {executor.security_config.max_command_length} characters\n"
@@ -328,10 +358,10 @@ async def main():
328
358
  write_stream,
329
359
  InitializationOptions(
330
360
  server_name="cli-mcp-server",
331
- server_version="0.1.1",
361
+ server_version="0.2.1",
332
362
  capabilities=server.get_capabilities(
333
363
  notification_options=NotificationOptions(),
334
364
  experimental_capabilities={},
335
365
  ),
336
366
  ),
337
- )
367
+ )
@@ -1,14 +1,15 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: cli-mcp-server
3
- Version: 0.1.2
3
+ Version: 0.2.1
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
7
7
  Project-URL: Repository, https://github.com/MladenSU/cli-mcp-server.git
8
8
  Project-URL: Bug Tracker, https://github.com/MladenSU/cli-mcp-server/issues
9
9
  Author-email: Mladen <fangs-lever6n@icloud.com>
10
+ License-File: LICENSE
10
11
  Requires-Python: >=3.10
11
- Requires-Dist: mcp>=1.0.0
12
+ Requires-Dist: mcp>=1.1.0
12
13
  Description-Content-Type: text/markdown
13
14
 
14
15
  # CLI MCP Server
@@ -22,6 +23,9 @@ features.
22
23
  ![License](https://img.shields.io/badge/license-MIT-blue.svg)
23
24
  ![Python Version](https://img.shields.io/badge/python-3.10%2B-blue)
24
25
  ![MCP Protocol](https://img.shields.io/badge/MCP-Compatible-green)
26
+ [![smithery badge](https://smithery.ai/badge/cli-mcp-server)](https://smithery.ai/protocol/cli-mcp-server)
27
+
28
+ <a href="https://glama.ai/mcp/servers/q89277vzl1"><img width="380" height="200" src="https://glama.ai/mcp/servers/q89277vzl1/badge" /></a>
25
29
 
26
30
  ---
27
31
 
@@ -71,10 +75,17 @@ Configure the server using environment variables:
71
75
  | `ALLOWED_DIR` | Base directory for command execution | Required |
72
76
  | `ALLOWED_COMMANDS` | Comma-separated list of allowed commands | `ls,cat,pwd` |
73
77
  | `ALLOWED_FLAGS` | Comma-separated list of allowed flags | `-l,-a,--help` |
74
- | `ALLOWED_PATTERNS` | Comma-separated file patterns | `*.txt,*.log,*.md` |
75
78
  | `MAX_COMMAND_LENGTH` | Maximum command string length | `1024` |
76
79
  | `COMMAND_TIMEOUT` | Command execution timeout (seconds) | `30` |
77
80
 
81
+ ## Installation
82
+
83
+ To install CLI MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/protocol/cli-mcp-server):
84
+
85
+ ```bash
86
+ npx @smithery/cli install cli-mcp-server --client claude
87
+ ```
88
+
78
89
  ## Available Tools
79
90
 
80
91
  ### run_command
@@ -117,7 +128,6 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
117
128
  "ALLOWED_DIR": "</your/desired/dir>",
118
129
  "ALLOWED_COMMANDS": "ls,cat,pwd,echo",
119
130
  "ALLOWED_FLAGS": "-l,-a,--help,--version",
120
- "ALLOWED_PATTERNS": "*.txt,*.log,*.md",
121
131
  "MAX_COMMAND_LENGTH": "1024",
122
132
  "COMMAND_TIMEOUT": "30"
123
133
  }
@@ -140,7 +150,6 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
140
150
  "ALLOWED_DIR": "</your/desired/dir>",
141
151
  "ALLOWED_COMMANDS": "ls,cat,pwd,echo",
142
152
  "ALLOWED_FLAGS": "-l,-a,--help,--version",
143
- "ALLOWED_PATTERNS": "*.txt,*.log,*.md",
144
153
  "MAX_COMMAND_LENGTH": "1024",
145
154
  "COMMAND_TIMEOUT": "30"
146
155
  }
@@ -148,6 +157,7 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
148
157
  }
149
158
  }
150
159
  ```
160
+ > In case it's not working or showing in the UI, clear your cache via `uv clean`.
151
161
 
152
162
  ## Security Features
153
163
 
@@ -208,7 +218,7 @@ You can launch the MCP Inspector via [`npm`](https://docs.npmjs.com/downloading-
208
218
  this command:
209
219
 
210
220
  ```bash
211
- npx @modelcontextprotocol/inspector uv --directory {{your source code local directory}}/unichat-mcp-server run unichat-mcp-server
221
+ npx @modelcontextprotocol/inspector uv --directory {{your source code local directory}}/cli-mcp-server run cli-mcp-server
212
222
  ```
213
223
 
214
224
  Upon launching, the Inspector will display a URL that you can access in your browser to begin debugging.
@@ -219,4 +229,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
219
229
 
220
230
  ---
221
231
 
222
- For more information or support, please open an issue on the project repository.
232
+ For more information or support, please open an issue on the project repository.
@@ -0,0 +1,7 @@
1
+ cli_mcp_server/__init__.py,sha256=bGLiX7XuhVsS-PJdoRIWXiilZ3NTAQ7fb9_8kkNzLlM,216
2
+ cli_mcp_server/server.py,sha256=N2BtE8YLf8P643GPwawnnmf1kg7blY6Z0x2KP9zYhS8,13918
3
+ cli_mcp_server-0.2.1.dist-info/METADATA,sha256=rWoOhhU4jK0Yt8BAguG1_k9v3y5q9SiJbw6PWfKl75M,6566
4
+ cli_mcp_server-0.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
5
+ cli_mcp_server-0.2.1.dist-info/entry_points.txt,sha256=07bDmJJSXg-6VCFEFTOlsGoxI-0faJafT1yEEjUdN-U,55
6
+ cli_mcp_server-0.2.1.dist-info/licenses/LICENSE,sha256=85rOR_IMpb2VzXBA4VCRZh_KWlaO1Rly8HDYDwGgMWk,1062
7
+ cli_mcp_server-0.2.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.26.3
2
+ Generator: hatchling 1.27.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,7 +0,0 @@
1
- cli_mcp_server/__init__.py,sha256=F95EXGXtBnIVQqu-bXcasy5OfORA-gzLnvXyqWFtjcY,216
2
- cli_mcp_server/server.py,sha256=vXfPFCIbPjZ8J6cyRinCMZgFYBev5muwmPkq4ZFAh3s,12336
3
- cli_mcp_server-0.1.2.dist-info/METADATA,sha256=daSaUn_7-fGogH5fHN9VOPWETW1x2hO7pauuZ59x5eI,6199
4
- cli_mcp_server-0.1.2.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
5
- cli_mcp_server-0.1.2.dist-info/entry_points.txt,sha256=07bDmJJSXg-6VCFEFTOlsGoxI-0faJafT1yEEjUdN-U,55
6
- cli_mcp_server-0.1.2.dist-info/licenses/LICENSE,sha256=85rOR_IMpb2VzXBA4VCRZh_KWlaO1Rly8HDYDwGgMWk,1062
7
- cli_mcp_server-0.1.2.dist-info/RECORD,,