cli-mcp-server 0.2.2__py3-none-any.whl → 0.2.3__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/server.py +77 -30
- {cli_mcp_server-0.2.2.dist-info → cli_mcp_server-0.2.3.dist-info}/METADATA +2 -2
- cli_mcp_server-0.2.3.dist-info/RECORD +7 -0
- cli_mcp_server-0.2.2.dist-info/RECORD +0 -7
- {cli_mcp_server-0.2.2.dist-info → cli_mcp_server-0.2.3.dist-info}/WHEEL +0 -0
- {cli_mcp_server-0.2.2.dist-info → cli_mcp_server-0.2.3.dist-info}/entry_points.txt +0 -0
- {cli_mcp_server-0.2.2.dist-info → cli_mcp_server-0.2.3.dist-info}/licenses/LICENSE +0 -0
cli_mcp_server/server.py
CHANGED
@@ -62,16 +62,20 @@ class CommandExecutor:
|
|
62
62
|
"""
|
63
63
|
Normalizes a path and ensures it's within allowed directory.
|
64
64
|
"""
|
65
|
-
try:
|
65
|
+
try:
|
66
66
|
if os.path.isabs(path):
|
67
67
|
# If absolute path, check directly
|
68
68
|
real_path = os.path.abspath(os.path.realpath(path))
|
69
69
|
else:
|
70
70
|
# If relative path, combine with allowed_dir first
|
71
|
-
real_path = os.path.abspath(
|
71
|
+
real_path = os.path.abspath(
|
72
|
+
os.path.realpath(os.path.join(self.allowed_dir, path))
|
73
|
+
)
|
72
74
|
|
73
75
|
if not self._is_path_safe(real_path):
|
74
|
-
raise CommandSecurityError(
|
76
|
+
raise CommandSecurityError(
|
77
|
+
f"Path '{path}' is outside of allowed directory: {self.allowed_dir}"
|
78
|
+
)
|
75
79
|
|
76
80
|
return real_path
|
77
81
|
except CommandSecurityError:
|
@@ -102,7 +106,9 @@ class CommandExecutor:
|
|
102
106
|
shell_operators = ["&&", "||", "|", ">", ">>", "<", "<<", ";"]
|
103
107
|
for operator in shell_operators:
|
104
108
|
if operator in command_string:
|
105
|
-
raise CommandSecurityError(
|
109
|
+
raise CommandSecurityError(
|
110
|
+
f"Shell operator '{operator}' is not supported"
|
111
|
+
)
|
106
112
|
|
107
113
|
try:
|
108
114
|
parts = shlex.split(command_string)
|
@@ -112,25 +118,31 @@ class CommandExecutor:
|
|
112
118
|
command, args = parts[0], parts[1:]
|
113
119
|
|
114
120
|
# Validate command if not in allow-all mode
|
115
|
-
if
|
121
|
+
if (
|
122
|
+
not self.security_config.allow_all_commands
|
123
|
+
and command not in self.security_config.allowed_commands
|
124
|
+
):
|
116
125
|
raise CommandSecurityError(f"Command '{command}' is not allowed")
|
117
126
|
|
118
127
|
# Process and validate arguments
|
119
128
|
validated_args = []
|
120
129
|
for arg in args:
|
121
130
|
if arg.startswith("-"):
|
122
|
-
if
|
131
|
+
if (
|
132
|
+
not self.security_config.allow_all_flags
|
133
|
+
and arg not in self.security_config.allowed_flags
|
134
|
+
):
|
123
135
|
raise CommandSecurityError(f"Flag '{arg}' is not allowed")
|
124
136
|
validated_args.append(arg)
|
125
137
|
continue
|
126
138
|
|
127
139
|
# For any path-like argument, validate it
|
128
|
-
if "/" in arg or "\\" in arg or os.path.isabs(arg) or arg == ".":
|
140
|
+
if "/" in arg or "\\" in arg or os.path.isabs(arg) or arg == ".":
|
129
141
|
if self._is_url_path(arg):
|
130
142
|
# If it's a URL, we don't need to normalize it
|
131
143
|
validated_args.append(arg)
|
132
144
|
continue
|
133
|
-
|
145
|
+
|
134
146
|
normalized_path = self._normalize_path(arg)
|
135
147
|
validated_args.append(normalized_path)
|
136
148
|
else:
|
@@ -152,9 +164,8 @@ class CommandExecutor:
|
|
152
164
|
Returns:
|
153
165
|
bool: True if the path is a URL, False otherwise.
|
154
166
|
"""
|
155
|
-
url_pattern = re.compile(r"^
|
167
|
+
url_pattern = re.compile(r"^https?://")
|
156
168
|
return bool(url_pattern.match(path))
|
157
|
-
|
158
169
|
|
159
170
|
def _is_path_safe(self, path: str) -> bool:
|
160
171
|
"""
|
@@ -209,7 +220,9 @@ class CommandExecutor:
|
|
209
220
|
- Captures both stdout and stderr
|
210
221
|
"""
|
211
222
|
if len(command_string) > self.security_config.max_command_length:
|
212
|
-
raise CommandSecurityError(
|
223
|
+
raise CommandSecurityError(
|
224
|
+
f"Command exceeds maximum length of {self.security_config.max_command_length}"
|
225
|
+
)
|
213
226
|
|
214
227
|
try:
|
215
228
|
command, args = self.validate_command(command_string)
|
@@ -223,7 +236,9 @@ class CommandExecutor:
|
|
223
236
|
cwd=self.allowed_dir,
|
224
237
|
)
|
225
238
|
except subprocess.TimeoutExpired:
|
226
|
-
raise CommandTimeoutError(
|
239
|
+
raise CommandTimeoutError(
|
240
|
+
f"Command timed out after {self.security_config.command_timeout} seconds"
|
241
|
+
)
|
227
242
|
except CommandError:
|
228
243
|
raise
|
229
244
|
except Exception as e:
|
@@ -256,12 +271,14 @@ def load_security_config() -> SecurityConfig:
|
|
256
271
|
"""
|
257
272
|
allowed_commands = os.getenv("ALLOWED_COMMANDS", "ls,cat,pwd")
|
258
273
|
allowed_flags = os.getenv("ALLOWED_FLAGS", "-l,-a,--help")
|
259
|
-
|
260
|
-
allow_all_commands = allowed_commands.lower() ==
|
261
|
-
allow_all_flags = allowed_flags.lower() ==
|
262
|
-
|
274
|
+
|
275
|
+
allow_all_commands = allowed_commands.lower() == "all"
|
276
|
+
allow_all_flags = allowed_flags.lower() == "all"
|
277
|
+
|
263
278
|
return SecurityConfig(
|
264
|
-
allowed_commands=
|
279
|
+
allowed_commands=(
|
280
|
+
set() if allow_all_commands else set(allowed_commands.split(","))
|
281
|
+
),
|
265
282
|
allowed_flags=set() if allow_all_flags else set(allowed_flags.split(",")),
|
266
283
|
max_command_length=int(os.getenv("MAX_COMMAND_LENGTH", "1024")),
|
267
284
|
command_timeout=int(os.getenv("COMMAND_TIMEOUT", "30")),
|
@@ -270,14 +287,24 @@ def load_security_config() -> SecurityConfig:
|
|
270
287
|
)
|
271
288
|
|
272
289
|
|
273
|
-
executor = CommandExecutor(
|
290
|
+
executor = CommandExecutor(
|
291
|
+
allowed_dir=os.getenv("ALLOWED_DIR", ""), security_config=load_security_config()
|
292
|
+
)
|
274
293
|
|
275
294
|
|
276
295
|
@server.list_tools()
|
277
296
|
async def handle_list_tools() -> list[types.Tool]:
|
278
|
-
commands_desc =
|
279
|
-
|
280
|
-
|
297
|
+
commands_desc = (
|
298
|
+
"all commands"
|
299
|
+
if executor.security_config.allow_all_commands
|
300
|
+
else ", ".join(executor.security_config.allowed_commands)
|
301
|
+
)
|
302
|
+
flags_desc = (
|
303
|
+
"all flags"
|
304
|
+
if executor.security_config.allow_all_flags
|
305
|
+
else ", ".join(executor.security_config.allowed_flags)
|
306
|
+
)
|
307
|
+
|
281
308
|
return [
|
282
309
|
types.Tool(
|
283
310
|
name="run_command",
|
@@ -300,7 +327,9 @@ async def handle_list_tools() -> list[types.Tool]:
|
|
300
327
|
),
|
301
328
|
types.Tool(
|
302
329
|
name="show_security_rules",
|
303
|
-
description=(
|
330
|
+
description=(
|
331
|
+
"Show what commands and operations are allowed in this environment.\n"
|
332
|
+
),
|
304
333
|
inputSchema={
|
305
334
|
"type": "object",
|
306
335
|
"properties": {},
|
@@ -310,10 +339,14 @@ async def handle_list_tools() -> list[types.Tool]:
|
|
310
339
|
|
311
340
|
|
312
341
|
@server.call_tool()
|
313
|
-
async def handle_call_tool(
|
342
|
+
async def handle_call_tool(
|
343
|
+
name: str, arguments: Optional[Dict[str, Any]]
|
344
|
+
) -> List[types.TextContent]:
|
314
345
|
if name == "run_command":
|
315
346
|
if not arguments or "command" not in arguments:
|
316
|
-
return [
|
347
|
+
return [
|
348
|
+
types.TextContent(type="text", text="No command provided", error=True)
|
349
|
+
]
|
317
350
|
|
318
351
|
try:
|
319
352
|
result = executor.execute(arguments["command"])
|
@@ -322,7 +355,9 @@ async def handle_call_tool(name: str, arguments: Optional[Dict[str, Any]]) -> Li
|
|
322
355
|
if result.stdout:
|
323
356
|
response.append(types.TextContent(type="text", text=result.stdout))
|
324
357
|
if result.stderr:
|
325
|
-
response.append(
|
358
|
+
response.append(
|
359
|
+
types.TextContent(type="text", text=result.stderr, error=True)
|
360
|
+
)
|
326
361
|
|
327
362
|
response.append(
|
328
363
|
types.TextContent(
|
@@ -334,7 +369,11 @@ async def handle_call_tool(name: str, arguments: Optional[Dict[str, Any]]) -> Li
|
|
334
369
|
return response
|
335
370
|
|
336
371
|
except CommandSecurityError as e:
|
337
|
-
return [
|
372
|
+
return [
|
373
|
+
types.TextContent(
|
374
|
+
type="text", text=f"Security violation: {str(e)}", error=True
|
375
|
+
)
|
376
|
+
]
|
338
377
|
except subprocess.TimeoutExpired:
|
339
378
|
return [
|
340
379
|
types.TextContent(
|
@@ -347,9 +386,17 @@ async def handle_call_tool(name: str, arguments: Optional[Dict[str, Any]]) -> Li
|
|
347
386
|
return [types.TextContent(type="text", text=f"Error: {str(e)}", error=True)]
|
348
387
|
|
349
388
|
elif name == "show_security_rules":
|
350
|
-
commands_desc =
|
351
|
-
|
352
|
-
|
389
|
+
commands_desc = (
|
390
|
+
"All commands allowed"
|
391
|
+
if executor.security_config.allow_all_commands
|
392
|
+
else ", ".join(sorted(executor.security_config.allowed_commands))
|
393
|
+
)
|
394
|
+
flags_desc = (
|
395
|
+
"All flags allowed"
|
396
|
+
if executor.security_config.allow_all_flags
|
397
|
+
else ", ".join(sorted(executor.security_config.allowed_flags))
|
398
|
+
)
|
399
|
+
|
353
400
|
security_info = (
|
354
401
|
"Security Configuration:\n"
|
355
402
|
f"==================\n"
|
@@ -383,4 +430,4 @@ async def main():
|
|
383
430
|
experimental_capabilities={},
|
384
431
|
),
|
385
432
|
),
|
386
|
-
)
|
433
|
+
)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: cli-mcp-server
|
3
|
-
Version: 0.2.
|
3
|
+
Version: 0.2.3
|
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.
|
12
|
+
Requires-Dist: mcp>=1.6.0
|
13
13
|
Description-Content-Type: text/markdown
|
14
14
|
|
15
15
|
# CLI MCP Server
|
@@ -0,0 +1,7 @@
|
|
1
|
+
cli_mcp_server/__init__.py,sha256=bGLiX7XuhVsS-PJdoRIWXiilZ3NTAQ7fb9_8kkNzLlM,216
|
2
|
+
cli_mcp_server/server.py,sha256=CHP2x_FDx1Rz79ZnmznE6J7-9EHdafK-UibrVTvQx7k,15124
|
3
|
+
cli_mcp_server-0.2.3.dist-info/METADATA,sha256=EiU10rdnN39XiTq2m0UjTrZDR0-1AEei9NFNf-6NBfk,7371
|
4
|
+
cli_mcp_server-0.2.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
5
|
+
cli_mcp_server-0.2.3.dist-info/entry_points.txt,sha256=07bDmJJSXg-6VCFEFTOlsGoxI-0faJafT1yEEjUdN-U,55
|
6
|
+
cli_mcp_server-0.2.3.dist-info/licenses/LICENSE,sha256=85rOR_IMpb2VzXBA4VCRZh_KWlaO1Rly8HDYDwGgMWk,1062
|
7
|
+
cli_mcp_server-0.2.3.dist-info/RECORD,,
|
@@ -1,7 +0,0 @@
|
|
1
|
-
cli_mcp_server/__init__.py,sha256=bGLiX7XuhVsS-PJdoRIWXiilZ3NTAQ7fb9_8kkNzLlM,216
|
2
|
-
cli_mcp_server/server.py,sha256=MzehdxGx_EtqVO0-OeZeDpayRK-PDRqD8uaQEVApHks,14564
|
3
|
-
cli_mcp_server-0.2.2.dist-info/METADATA,sha256=Rawjyx3K6EXw-Yc-VzMYT_jq4nlQTHjARzBQWU6ggT8,7371
|
4
|
-
cli_mcp_server-0.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
5
|
-
cli_mcp_server-0.2.2.dist-info/entry_points.txt,sha256=07bDmJJSXg-6VCFEFTOlsGoxI-0faJafT1yEEjUdN-U,55
|
6
|
-
cli_mcp_server-0.2.2.dist-info/licenses/LICENSE,sha256=85rOR_IMpb2VzXBA4VCRZh_KWlaO1Rly8HDYDwGgMWk,1062
|
7
|
-
cli_mcp_server-0.2.2.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|