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.
- cli_mcp_server/__init__.py +1 -1
- cli_mcp_server/server.py +77 -64
- {cli_mcp_server-0.1.2.dist-info → cli_mcp_server-0.2.0.dist-info}/METADATA +12 -5
- cli_mcp_server-0.2.0.dist-info/RECORD +7 -0
- cli_mcp_server-0.1.2.dist-info/RECORD +0 -7
- {cli_mcp_server-0.1.2.dist-info → cli_mcp_server-0.2.0.dist-info}/WHEEL +0 -0
- {cli_mcp_server-0.1.2.dist-info → cli_mcp_server-0.2.0.dist-info}/entry_points.txt +0 -0
- {cli_mcp_server-0.1.2.dist-info → cli_mcp_server-0.2.0.dist-info}/licenses/LICENSE +0 -0
cli_mcp_server/__init__.py
CHANGED
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
|
17
|
-
"""
|
18
|
-
|
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
|
-
#
|
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
|
-
#
|
91
|
-
if "/" in arg or "\\" in arg or os.path.isabs(arg):
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
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
|
-
|
127
|
-
|
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
|
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
|
-
|
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.
|
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.
|
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
|

|
23
23
|

|
24
24
|

|
25
|
+
[](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}}/
|
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,,
|
File without changes
|
File without changes
|
File without changes
|