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.
- cmd_line_mcp/__init__.py +3 -0
- cmd_line_mcp/config.py +380 -0
- cmd_line_mcp/security.py +423 -0
- cmd_line_mcp/server.py +943 -0
- cmd_line_mcp/session.py +144 -0
- mseep_cmd_line_mcp-0.5.0.data/data/default_config.json +65 -0
- mseep_cmd_line_mcp-0.5.0.dist-info/METADATA +347 -0
- mseep_cmd_line_mcp-0.5.0.dist-info/RECORD +12 -0
- mseep_cmd_line_mcp-0.5.0.dist-info/WHEEL +5 -0
- mseep_cmd_line_mcp-0.5.0.dist-info/entry_points.txt +2 -0
- mseep_cmd_line_mcp-0.5.0.dist-info/licenses/LICENSE +21 -0
- mseep_cmd_line_mcp-0.5.0.dist-info/top_level.txt +1 -0
cmd_line_mcp/__init__.py
ADDED
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
|