cli-mcp-server 0.1.2__py3-none-any.whl → 0.2.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.
@@ -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,7 +45,6 @@ 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
35
50
 
@@ -41,6 +56,27 @@ class CommandExecutor:
41
56
  self.allowed_dir = os.path.abspath(os.path.realpath(allowed_dir))
42
57
  self.security_config = security_config
43
58
 
59
+ def _normalize_path(self, path: str) -> str:
60
+ """
61
+ Normalizes a path and ensures it's within allowed directory.
62
+ """
63
+ try:
64
+ if os.path.isabs(path):
65
+ # If absolute path, check directly
66
+ real_path = os.path.abspath(os.path.realpath(path))
67
+ else:
68
+ # If relative path, combine with allowed_dir first
69
+ real_path = os.path.abspath(os.path.realpath(os.path.join(self.allowed_dir, path)))
70
+
71
+ if not self._is_path_safe(real_path):
72
+ raise CommandSecurityError(f"Path '{path}' is outside of allowed directory: {self.allowed_dir}")
73
+
74
+ return real_path
75
+ except CommandSecurityError:
76
+ raise
77
+ except Exception as e:
78
+ raise CommandSecurityError(f"Invalid path '{path}': {str(e)}")
79
+
44
80
  def validate_command(self, command_string: str) -> tuple[str, List[str]]:
45
81
  """
46
82
  Validates and parses a command string for security and formatting.
@@ -64,10 +100,7 @@ class CommandExecutor:
64
100
  shell_operators = ["&&", "||", "|", ">", ">>", "<", "<<", ";"]
65
101
  for operator in shell_operators:
66
102
  if operator in command_string:
67
- raise CommandSecurityError(
68
- f"Shell operator '{operator}' is not supported. "
69
- "Only single commands are allowed."
70
- )
103
+ raise CommandSecurityError(f"Shell operator '{operator}' is not supported")
71
104
 
72
105
  try:
73
106
  parts = shlex.split(command_string)
@@ -80,29 +113,25 @@ class CommandExecutor:
80
113
  if command not in self.security_config.allowed_commands:
81
114
  raise CommandSecurityError(f"Command '{command}' is not allowed")
82
115
 
83
- # Validate arguments
116
+ # Process and validate arguments
117
+ validated_args = []
84
118
  for arg in args:
85
119
  if arg.startswith("-"):
86
120
  if arg not in self.security_config.allowed_flags:
87
121
  raise CommandSecurityError(f"Flag '{arg}' is not allowed")
122
+ validated_args.append(arg)
88
123
  continue
89
124
 
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
125
+ # For any path-like argument, validate it
126
+ if "/" in arg or "\\" in arg or os.path.isabs(arg) or arg == ".":
127
+ normalized_path = self._normalize_path(arg)
128
+ validated_args.append(normalized_path)
129
+ else:
130
+ # For non-path arguments, add them as-is
131
+ validated_args.append(arg)
132
+
133
+ return command, validated_args
134
+
106
135
  except ValueError as e:
107
136
  raise CommandSecurityError(f"Invalid command format: {str(e)}")
108
137
 
@@ -123,8 +152,12 @@ class CommandExecutor:
123
152
  Private method intended for internal use only.
124
153
  """
125
154
  try:
126
- abs_path = os.path.abspath(os.path.realpath(path))
127
- return abs_path.startswith(self.allowed_dir)
155
+ # Resolve any symlinks and get absolute path
156
+ real_path = os.path.abspath(os.path.realpath(path))
157
+ allowed_dir_real = os.path.abspath(os.path.realpath(self.allowed_dir))
158
+
159
+ # Check if the path starts with allowed_dir
160
+ return real_path.startswith(allowed_dir_real)
128
161
  except Exception:
129
162
  return False
130
163
 
@@ -155,11 +188,11 @@ class CommandExecutor:
155
188
  - Captures both stdout and stderr
156
189
  """
157
190
  if len(command_string) > self.security_config.max_command_length:
158
- raise CommandSecurityError("Command string too long")
191
+ raise CommandSecurityError(f"Command exceeds maximum length of {self.security_config.max_command_length}")
159
192
 
160
193
  try:
161
-
162
194
  command, args = self.validate_command(command_string)
195
+
163
196
  return subprocess.run(
164
197
  [command] + args,
165
198
  shell=False,
@@ -168,10 +201,12 @@ class CommandExecutor:
168
201
  timeout=self.security_config.command_timeout,
169
202
  cwd=self.allowed_dir,
170
203
  )
204
+ except subprocess.TimeoutExpired:
205
+ raise CommandTimeoutError(f"Command timed out after {self.security_config.command_timeout} seconds")
206
+ except CommandError:
207
+ raise
171
208
  except Exception as e:
172
- if isinstance(e, CommandSecurityError):
173
- raise
174
- raise CommandSecurityError(f"Command execution failed: {str(e)}")
209
+ raise CommandExecutionError(f"Command execution failed: {str(e)}")
175
210
 
176
211
 
177
212
  # Load security configuration from environment
@@ -187,7 +222,6 @@ def load_security_config() -> SecurityConfig:
187
222
  SecurityConfig: Configuration object containing:
188
223
  - allowed_commands: Set of permitted command names
189
224
  - allowed_flags: Set of permitted command flags/options
190
- - allowed_patterns: List of regex patterns for valid inputs
191
225
  - max_command_length: Maximum length of command string
192
226
  - command_timeout: Maximum execution time in seconds
193
227
 
@@ -201,18 +235,12 @@ def load_security_config() -> SecurityConfig:
201
235
  return SecurityConfig(
202
236
  allowed_commands=set(os.getenv("ALLOWED_COMMANDS", "ls,cat,pwd").split(",")),
203
237
  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
- ],
208
238
  max_command_length=int(os.getenv("MAX_COMMAND_LENGTH", "1024")),
209
239
  command_timeout=int(os.getenv("COMMAND_TIMEOUT", "30")),
210
240
  )
211
241
 
212
242
 
213
- executor = CommandExecutor(
214
- allowed_dir=os.getenv("ALLOWED_DIR", ""), security_config=load_security_config()
215
- )
243
+ executor = CommandExecutor(allowed_dir=os.getenv("ALLOWED_DIR", ""), security_config=load_security_config())
216
244
 
217
245
 
218
246
  @server.list_tools()
@@ -231,7 +259,7 @@ async def handle_list_tools() -> list[types.Tool]:
231
259
  "properties": {
232
260
  "command": {
233
261
  "type": "string",
234
- "description": "Single command to execute (example: 'ls -l' or 'cat file.txt')"
262
+ "description": "Single command to execute (example: 'ls -l' or 'cat file.txt')",
235
263
  }
236
264
  },
237
265
  "required": ["command"],
@@ -239,26 +267,20 @@ async def handle_list_tools() -> list[types.Tool]:
239
267
  ),
240
268
  types.Tool(
241
269
  name="show_security_rules",
242
- description=(
243
- "Show what commands and operations are allowed in this environment.\n"
244
- ),
270
+ description=("Show what commands and operations are allowed in this environment.\n"),
245
271
  inputSchema={
246
272
  "type": "object",
247
273
  "properties": {},
248
274
  },
249
- )
275
+ ),
250
276
  ]
251
277
 
252
278
 
253
279
  @server.call_tool()
254
- async def handle_call_tool(
255
- name: str, arguments: Optional[Dict[str, Any]]
256
- ) -> List[types.TextContent]:
280
+ async def handle_call_tool(name: str, arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
257
281
  if name == "run_command":
258
282
  if not arguments or "command" not in arguments:
259
- return [
260
- types.TextContent(type="text", text="No command provided", error=True)
261
- ]
283
+ return [types.TextContent(type="text", text="No command provided", error=True)]
262
284
 
263
285
  try:
264
286
  result = executor.execute(arguments["command"])
@@ -267,9 +289,7 @@ async def handle_call_tool(
267
289
  if result.stdout:
268
290
  response.append(types.TextContent(type="text", text=result.stdout))
269
291
  if result.stderr:
270
- response.append(
271
- types.TextContent(type="text", text=result.stderr, error=True)
272
- )
292
+ response.append(types.TextContent(type="text", text=result.stderr, error=True))
273
293
 
274
294
  response.append(
275
295
  types.TextContent(
@@ -281,11 +301,7 @@ async def handle_call_tool(
281
301
  return response
282
302
 
283
303
  except CommandSecurityError as e:
284
- return [
285
- types.TextContent(
286
- type="text", text=f"Security violation: {str(e)}", error=True
287
- )
288
- ]
304
+ return [types.TextContent(type="text", text=f"Security violation: {str(e)}", error=True)]
289
305
  except subprocess.TimeoutExpired:
290
306
  return [
291
307
  types.TextContent(
@@ -308,9 +324,6 @@ async def handle_call_tool(
308
324
  f"\nAllowed Flags:\n"
309
325
  f"-------------\n"
310
326
  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"
314
327
  f"\nSecurity Limits:\n"
315
328
  f"---------------\n"
316
329
  f"Max Command Length: {executor.security_config.max_command_length} characters\n"
@@ -328,7 +341,7 @@ async def main():
328
341
  write_stream,
329
342
  InitializationOptions(
330
343
  server_name="cli-mcp-server",
331
- server_version="0.1.1",
344
+ server_version="0.2.0",
332
345
  capabilities=server.get_capabilities(
333
346
  notification_options=NotificationOptions(),
334
347
  experimental_capabilities={},
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: cli-mcp-server
3
- Version: 0.1.2
3
+ Version: 0.2.0
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
@@ -22,6 +22,7 @@ features.
22
22
  ![License](https://img.shields.io/badge/license-MIT-blue.svg)
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
+ [![smithery badge](https://smithery.ai/badge/cli-mcp-server)](https://smithery.ai/protocol/cli-mcp-server)
25
26
 
26
27
  ---
27
28
 
@@ -71,10 +72,17 @@ Configure the server using environment variables:
71
72
  | `ALLOWED_DIR` | Base directory for command execution | Required |
72
73
  | `ALLOWED_COMMANDS` | Comma-separated list of allowed commands | `ls,cat,pwd` |
73
74
  | `ALLOWED_FLAGS` | Comma-separated list of allowed flags | `-l,-a,--help` |
74
- | `ALLOWED_PATTERNS` | Comma-separated file patterns | `*.txt,*.log,*.md` |
75
75
  | `MAX_COMMAND_LENGTH` | Maximum command string length | `1024` |
76
76
  | `COMMAND_TIMEOUT` | Command execution timeout (seconds) | `30` |
77
77
 
78
+ ## Installation
79
+
80
+ To install CLI MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/protocol/cli-mcp-server):
81
+
82
+ ```bash
83
+ npx @smithery/cli install cli-mcp-server --client claude
84
+ ```
85
+
78
86
  ## Available Tools
79
87
 
80
88
  ### run_command
@@ -117,7 +125,6 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
117
125
  "ALLOWED_DIR": "</your/desired/dir>",
118
126
  "ALLOWED_COMMANDS": "ls,cat,pwd,echo",
119
127
  "ALLOWED_FLAGS": "-l,-a,--help,--version",
120
- "ALLOWED_PATTERNS": "*.txt,*.log,*.md",
121
128
  "MAX_COMMAND_LENGTH": "1024",
122
129
  "COMMAND_TIMEOUT": "30"
123
130
  }
@@ -140,7 +147,6 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
140
147
  "ALLOWED_DIR": "</your/desired/dir>",
141
148
  "ALLOWED_COMMANDS": "ls,cat,pwd,echo",
142
149
  "ALLOWED_FLAGS": "-l,-a,--help,--version",
143
- "ALLOWED_PATTERNS": "*.txt,*.log,*.md",
144
150
  "MAX_COMMAND_LENGTH": "1024",
145
151
  "COMMAND_TIMEOUT": "30"
146
152
  }
@@ -148,6 +154,7 @@ Add to your `~/Library/Application\ Support/Claude/claude_desktop_config.json`:
148
154
  }
149
155
  }
150
156
  ```
157
+ > In case it's not working or showing in the UI, clear your cache via `uv clean`.
151
158
 
152
159
  ## Security Features
153
160
 
@@ -208,7 +215,7 @@ You can launch the MCP Inspector via [`npm`](https://docs.npmjs.com/downloading-
208
215
  this command:
209
216
 
210
217
  ```bash
211
- npx @modelcontextprotocol/inspector uv --directory {{your source code local directory}}/unichat-mcp-server run unichat-mcp-server
218
+ npx @modelcontextprotocol/inspector uv --directory {{your source code local directory}}/cli-mcp-server run cli-mcp-server
212
219
  ```
213
220
 
214
221
  Upon launching, the Inspector will display a URL that you can access in your browser to begin debugging.
@@ -0,0 +1,7 @@
1
+ cli_mcp_server/__init__.py,sha256=bGLiX7XuhVsS-PJdoRIWXiilZ3NTAQ7fb9_8kkNzLlM,216
2
+ cli_mcp_server/server.py,sha256=zM-Q-5KtdjBY64RfkpJeUjOLd__4mW4JJMN0aQU9X5E,12908
3
+ cli_mcp_server-0.2.0.dist-info/METADATA,sha256=Fx8K92ryxA-Vj2eU1IfyZzD5VdcFzDQ_OaVqKQANZFQ,6403
4
+ cli_mcp_server-0.2.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
5
+ cli_mcp_server-0.2.0.dist-info/entry_points.txt,sha256=07bDmJJSXg-6VCFEFTOlsGoxI-0faJafT1yEEjUdN-U,55
6
+ cli_mcp_server-0.2.0.dist-info/licenses/LICENSE,sha256=85rOR_IMpb2VzXBA4VCRZh_KWlaO1Rly8HDYDwGgMWk,1062
7
+ cli_mcp_server-0.2.0.dist-info/RECORD,,
@@ -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,,