mseep-cmd-line-mcp 0.5.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.
@@ -0,0 +1,3 @@
1
+ """Command-line MCP server for safe command execution."""
2
+
3
+ __version__ = "0.5.0"
cmd_line_mcp/config.py ADDED
@@ -0,0 +1,380 @@
1
+ """Configuration utilities for the command-line MCP server."""
2
+
3
+ import logging
4
+ import os
5
+ import json
6
+ from importlib.resources import files
7
+ from typing import Dict, Optional, Any, List
8
+ from pathlib import Path
9
+
10
+ # Configure logger
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Config:
15
+ """Configuration for the command-line MCP server."""
16
+
17
+ def __init__(
18
+ self,
19
+ config_path: Optional[str] = None,
20
+ env_file_path: Optional[str] = None,
21
+ ):
22
+ """Initialize the configuration.
23
+
24
+ Args:
25
+ config_path: Optional path to a configuration file
26
+ env_file_path: Optional path to a .env file
27
+ """
28
+ self._config_path = config_path
29
+ self._env_file_path = env_file_path
30
+ self._config_cache = {}
31
+ self._env_vars = {}
32
+ self.config = {}
33
+
34
+ # Load configuration in order of precedence:
35
+ # 1. Built-in default_config.json
36
+ # 2. Config file from environment variable
37
+ # 3. Config file from constructor parameter
38
+ # 4. .env file
39
+ # 5. Environment variables
40
+
41
+ # Load the built-in default configuration
42
+ self._load_default_config()
43
+
44
+ # Try to load configuration from environment variable
45
+ env_config_path = os.environ.get("CMD_LINE_MCP_CONFIG")
46
+ if env_config_path and os.path.exists(env_config_path):
47
+ self._load_config_from_json(env_config_path)
48
+
49
+ # Load configuration from specified path, overriding environment config
50
+ if config_path and os.path.exists(config_path):
51
+ self._load_config_from_json(config_path)
52
+
53
+ # Load .env file if provided
54
+ if env_file_path and os.path.exists(env_file_path):
55
+ self._load_env_file(env_file_path)
56
+ else:
57
+ # Look for .env in current directory and parent directories (up to 3 levels)
58
+ current_dir = Path.cwd()
59
+ potential_paths = [current_dir]
60
+ for _ in range(3): # Check up to 3 parent directories
61
+ parent = current_dir.parent
62
+ if parent == current_dir: # Reached root
63
+ break
64
+ potential_paths.append(parent)
65
+ current_dir = parent
66
+
67
+ for path in potential_paths:
68
+ env_file = path / ".env"
69
+ if env_file.exists():
70
+ self._load_env_file(str(env_file))
71
+ break
72
+
73
+ # Override with environment variables - this now takes all CMD_LINE_MCP_* vars
74
+ self._load_from_environment_variables()
75
+
76
+ def _load_default_config(self) -> None:
77
+ """Load the default configuration from the built-in default_config.json file."""
78
+ try:
79
+ # Look in the root directory (3 levels up from this file)
80
+ root_config_path = Path(__file__).parent.parent.parent / "default_config.json"
81
+ if root_config_path.exists():
82
+ with open(root_config_path, "r") as f:
83
+ self.config = json.load(f)
84
+ logger.info(f"Loaded default configuration from {root_config_path}")
85
+ return
86
+
87
+ # If not found in the root directory, check current working directory
88
+ cwd_config_path = Path.cwd() / "default_config.json"
89
+ if cwd_config_path.exists():
90
+ with open(cwd_config_path, "r") as f:
91
+ self.config = json.load(f)
92
+ msg = "Loaded default configuration from current directory"
93
+ logger.info(f"{msg}: {cwd_config_path}")
94
+ return
95
+
96
+ logger.error("Could not find default_config.json in any location - using empty configuration")
97
+ # If we get here, we couldn't find the default config anywhere
98
+ # Initialize with empty structure to prevent errors
99
+ self.config = {
100
+ "server": {},
101
+ "security": {},
102
+ "commands": {"read": [], "write": [], "system": [], "blocked": [], "dangerous_patterns": []},
103
+ "output": {}
104
+ }
105
+ except Exception as e:
106
+ logger.error(f"Error loading default configuration: {str(e)}")
107
+ # Initialize with empty structure to prevent errors
108
+ self.config = {
109
+ "server": {},
110
+ "security": {},
111
+ "commands": {"read": [], "write": [], "system": [], "blocked": [], "dangerous_patterns": []},
112
+ "output": {}
113
+ }
114
+
115
+ def _load_config_from_json(self, config_path: str) -> None:
116
+ """Load configuration from a JSON file.
117
+
118
+ Args:
119
+ config_path: Path to the configuration file
120
+ """
121
+ try:
122
+ with open(config_path, "r") as f:
123
+ loaded_config = json.load(f)
124
+
125
+ # Merge loaded config with default config
126
+ self._update_config_recursively(self.config, loaded_config)
127
+ except Exception as e:
128
+ logger.error(f"Error loading configuration from {config_path}: {str(e)}")
129
+
130
+ def _update_config_recursively(self, target: Dict, source: Dict) -> None:
131
+ """Recursively update configuration dictionary.
132
+
133
+ Args:
134
+ target: Target dictionary to update
135
+ source: Source dictionary with new values
136
+ """
137
+ for key, value in source.items():
138
+ if (
139
+ key in target
140
+ and isinstance(target[key], dict)
141
+ and isinstance(value, dict)
142
+ ):
143
+ self._update_config_recursively(target[key], value)
144
+ else:
145
+ target[key] = value
146
+
147
+ def _load_env_file(self, env_file_path: str) -> None:
148
+ """Load configuration from a .env file.
149
+
150
+ Args:
151
+ env_file_path: Path to the .env file
152
+ """
153
+ try:
154
+ with open(env_file_path, "r") as f:
155
+ for line in f:
156
+ line = line.strip()
157
+ if not line or line.startswith("#"):
158
+ continue
159
+
160
+ if "=" in line:
161
+ key, value = line.split("=", 1)
162
+ key = key.strip()
163
+ value = value.strip()
164
+
165
+ # Remove quotes if present
166
+ if (value.startswith('"') and value.endswith('"')) or (
167
+ value.startswith("'") and value.endswith("'")
168
+ ):
169
+ value = value[1:-1]
170
+
171
+ # Only process CMD_LINE_MCP_ variables
172
+ if key.startswith("CMD_LINE_MCP_"):
173
+ self._env_vars[key] = value
174
+ except Exception as e:
175
+ logger.error(f"Error loading .env file from {env_file_path}: {str(e)}")
176
+
177
+ def _load_from_environment_variables(self) -> None:
178
+ """Load configuration from environment variables."""
179
+ # First priority: actual environment variables
180
+ for key, value in os.environ.items():
181
+ if key.startswith("CMD_LINE_MCP_"):
182
+ self._env_vars[key] = value
183
+
184
+ # Process all environment variables
185
+ for key, value in self._env_vars.items():
186
+ if not key.startswith("CMD_LINE_MCP_"):
187
+ continue
188
+
189
+ # Remove prefix and get the nested keys
190
+ config_key = key[13:].lower() # Remove "CMD_LINE_MCP_" prefix
191
+
192
+ # Handle special cases for commands and arrays
193
+ if config_key.startswith("commands_"):
194
+ # Handle command categories (read, write, system, blocked)
195
+ category = config_key[9:] # Remove "commands_" prefix
196
+ if category in ["read", "write", "system", "blocked"]:
197
+ # Split comma-separated values
198
+ commands = [cmd.strip() for cmd in value.split(",") if cmd.strip()]
199
+ # Merge with existing commands rather than replacing them
200
+ # Make sure no duplicates by converting to set and back to list
201
+ existing_commands = self.config["commands"][category]
202
+ merged_commands = list(set(existing_commands + commands))
203
+ self.config["commands"][category] = merged_commands
204
+ elif config_key == "dangerous_patterns":
205
+ # Split comma-separated patterns
206
+ patterns = [
207
+ pattern.strip() for pattern in value.split(",") if pattern.strip()
208
+ ]
209
+ # Merge with existing patterns rather than replacing them
210
+ existing_patterns = self.config["commands"]["dangerous_patterns"]
211
+ merged_patterns = list(set(existing_patterns + patterns))
212
+ self.config["commands"]["dangerous_patterns"] = merged_patterns
213
+ elif config_key.startswith("security_"):
214
+ # Handle security settings
215
+ setting = config_key[9:] # Remove "security_" prefix
216
+ if setting in self.config["security"]:
217
+ # Convert value type based on the default
218
+ default_value = self.config["security"][setting]
219
+ if isinstance(default_value, bool):
220
+ self.config["security"][setting] = value.lower() in [
221
+ "true",
222
+ "1",
223
+ "yes",
224
+ ]
225
+ elif isinstance(default_value, int):
226
+ try:
227
+ self.config["security"][setting] = int(value)
228
+ except ValueError:
229
+ logger.warning(f"Invalid integer value for {key}: {value}")
230
+ else:
231
+ self.config["security"][setting] = value
232
+ elif config_key.startswith("server_"):
233
+ # Handle server settings
234
+ setting = config_key[7:] # Remove "server_" prefix
235
+ if setting in self.config["server"]:
236
+ # Convert value type based on the default
237
+ default_value = self.config["server"][setting]
238
+ if isinstance(default_value, bool):
239
+ self.config["server"][setting] = value.lower() in [
240
+ "true",
241
+ "1",
242
+ "yes",
243
+ ]
244
+ elif isinstance(default_value, int):
245
+ try:
246
+ self.config["server"][setting] = int(value)
247
+ except ValueError:
248
+ logger.warning(f"Invalid integer value for {key}: {value}")
249
+ else:
250
+ self.config["server"][setting] = value
251
+ elif config_key.startswith("output_"):
252
+ # Handle output settings
253
+ setting = config_key[7:] # Remove "output_" prefix
254
+ if setting in self.config["output"]:
255
+ # Convert value type based on the default
256
+ default_value = self.config["output"][setting]
257
+ if isinstance(default_value, bool):
258
+ self.config["output"][setting] = value.lower() in [
259
+ "true",
260
+ "1",
261
+ "yes",
262
+ ]
263
+ elif isinstance(default_value, int):
264
+ try:
265
+ self.config["output"][setting] = int(value)
266
+ except ValueError:
267
+ logger.warning(f"Invalid integer value for {key}: {value}")
268
+ else:
269
+ self.config["output"][setting] = value
270
+
271
+ def get(self, section: str, key: str, default: Any = None) -> Any:
272
+ """Get a configuration value.
273
+
274
+ Args:
275
+ section: Configuration section
276
+ key: Configuration key
277
+ default: Default value if not found
278
+
279
+ Returns:
280
+ The configuration value
281
+ """
282
+ # Check cache first
283
+ cache_key = f"{section}.{key}"
284
+ if cache_key in self._config_cache:
285
+ return self._config_cache[cache_key]
286
+
287
+ # Get value from config
288
+ if section in self.config and key in self.config[section]:
289
+ value = self.config[section][key]
290
+ # Cache the result
291
+ self._config_cache[cache_key] = value
292
+ return value
293
+ return default
294
+
295
+ def get_section(self, section: str) -> Dict[str, Any]:
296
+ """Get a configuration section.
297
+
298
+ Args:
299
+ section: Configuration section
300
+
301
+ Returns:
302
+ The configuration section
303
+ """
304
+ return self.config.get(section, {})
305
+
306
+ def get_all(self) -> Dict[str, Any]:
307
+ """Get the entire configuration.
308
+
309
+ Returns:
310
+ The complete configuration dictionary
311
+ """
312
+ return self.config
313
+
314
+ def update(self, updates: Dict[str, Any], save: bool = False) -> None:
315
+ """Update the configuration.
316
+
317
+ Args:
318
+ updates: Dictionary with configuration updates
319
+ save: Whether to save the configuration to the file
320
+ """
321
+ self._update_config_recursively(self.config, updates)
322
+
323
+ # Clear cache
324
+ self._config_cache.clear()
325
+
326
+ # Save to file if requested
327
+ if save and self._config_path:
328
+ try:
329
+ with open(self._config_path, "w") as f:
330
+ json.dump(self.config, f, indent=2)
331
+ except Exception as e:
332
+ logger.error(
333
+ f"Error saving configuration to {self._config_path}: {str(e)}"
334
+ )
335
+
336
+ def get_effective_command_lists(self) -> Dict[str, List[str]]:
337
+ """Get the effective command lists taking into account all configuration.
338
+
339
+ Returns:
340
+ Dictionary with read, write, system, and blocked command lists
341
+ """
342
+ return {
343
+ "read": self.config["commands"]["read"],
344
+ "write": self.config["commands"]["write"],
345
+ "system": self.config["commands"]["system"],
346
+ "blocked": self.config["commands"]["blocked"],
347
+ "dangerous_patterns": self.config["commands"]["dangerous_patterns"],
348
+ }
349
+
350
+ def has_separator_support(self) -> Dict[str, bool]:
351
+ """Get support status for command separators.
352
+
353
+ Returns:
354
+ Dictionary with support status for each separator
355
+ """
356
+ # Use patterns to check if separators are in dangerous_patterns
357
+ separators = {
358
+ "pipe": True,
359
+ "semicolon": True,
360
+ "ampersand": True,
361
+ } # | # ; # &
362
+
363
+ dangerous_patterns = self.config["commands"]["dangerous_patterns"]
364
+ allow_separators = self.config["security"].get("allow_command_separators", True)
365
+
366
+ if not allow_separators:
367
+ return {key: False for key in separators}
368
+
369
+ # Check if there's a pattern that would block these separators
370
+ for pattern in dangerous_patterns:
371
+ # Be very careful with the pipe character checking
372
+ # Only block if the pipe character is the ENTIRE pattern
373
+ if pattern == ";" or pattern == ";":
374
+ separators["semicolon"] = False
375
+ if pattern == "&" or pattern == "&":
376
+ separators["ampersand"] = False
377
+ if pattern == "|" or pattern == r"\|":
378
+ separators["pipe"] = False
379
+
380
+ return separators