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/server.py
ADDED
@@ -0,0 +1,943 @@
|
|
1
|
+
"""
|
2
|
+
Command-line MCP server that safely executes Unix/macOS terminal commands.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import argparse
|
6
|
+
import asyncio
|
7
|
+
import logging
|
8
|
+
import os
|
9
|
+
from typing import Any, Dict, List, Optional
|
10
|
+
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
12
|
+
|
13
|
+
from cmd_line_mcp.config import Config
|
14
|
+
from cmd_line_mcp.security import (
|
15
|
+
validate_command,
|
16
|
+
extract_directory_from_command,
|
17
|
+
is_directory_whitelisted,
|
18
|
+
normalize_path,
|
19
|
+
)
|
20
|
+
from cmd_line_mcp.session import SessionManager
|
21
|
+
|
22
|
+
# Configure logging
|
23
|
+
logging.basicConfig(
|
24
|
+
level=logging.INFO,
|
25
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
26
|
+
)
|
27
|
+
logger = logging.getLogger(__name__)
|
28
|
+
|
29
|
+
|
30
|
+
class CommandLineMCP:
|
31
|
+
"""Command-line MCP server for Unix/macOS terminal commands."""
|
32
|
+
|
33
|
+
def __init__(
|
34
|
+
self,
|
35
|
+
config_path: Optional[str] = None,
|
36
|
+
env_file_path: Optional[str] = None,
|
37
|
+
):
|
38
|
+
"""Initialize the MCP server.
|
39
|
+
|
40
|
+
Args:
|
41
|
+
config_path: Optional path to a configuration file
|
42
|
+
env_file_path: Optional path to a .env file
|
43
|
+
"""
|
44
|
+
self.config = Config(config_path, env_file_path)
|
45
|
+
self.session_manager = SessionManager()
|
46
|
+
|
47
|
+
# Create a fixed persistent session for Claude Desktop mode - FIXED ID
|
48
|
+
self.claude_desktop_session_id = "claude_desktop_fixed_session"
|
49
|
+
|
50
|
+
# NOTE: We don't pre-approve any directories - let the user explicitly approve them
|
51
|
+
# or configure them in the whitelist
|
52
|
+
|
53
|
+
logger.info(
|
54
|
+
f"Created persistent Claude Desktop session: {self.claude_desktop_session_id}"
|
55
|
+
)
|
56
|
+
|
57
|
+
# Set up logging
|
58
|
+
log_level = self.config.get("server", "log_level", "INFO")
|
59
|
+
logger.setLevel(getattr(logging, log_level))
|
60
|
+
|
61
|
+
# Load command lists from config
|
62
|
+
command_lists = self.config.get_effective_command_lists()
|
63
|
+
self.read_commands = command_lists["read"]
|
64
|
+
self.write_commands = command_lists["write"]
|
65
|
+
self.system_commands = command_lists["system"]
|
66
|
+
self.blocked_commands = command_lists["blocked"]
|
67
|
+
self.dangerous_patterns = command_lists["dangerous_patterns"]
|
68
|
+
|
69
|
+
# Get separator support status
|
70
|
+
self.separator_support = self.config.has_separator_support()
|
71
|
+
|
72
|
+
# Get whitelisted directories from config
|
73
|
+
self.whitelisted_directories = self.config.get_section("security").get(
|
74
|
+
"whitelisted_directories", ["/home", "/tmp"]
|
75
|
+
)
|
76
|
+
|
77
|
+
# Initialize MCP app
|
78
|
+
server_config = self.config.get_section("server")
|
79
|
+
self.app = FastMCP(
|
80
|
+
server_config.get("name", "cmd-line-mcp"),
|
81
|
+
version=server_config.get("version", "0.4.0"),
|
82
|
+
description=server_config.get(
|
83
|
+
"description",
|
84
|
+
"MCP server for safely executing command-line tools",
|
85
|
+
),
|
86
|
+
)
|
87
|
+
|
88
|
+
# Store capabilities data to use in get_command_help tool
|
89
|
+
self.command_capabilities = {
|
90
|
+
"supported_commands": {
|
91
|
+
"read": self.read_commands,
|
92
|
+
"write": self.write_commands,
|
93
|
+
"system": self.system_commands,
|
94
|
+
},
|
95
|
+
"blocked_commands": self.blocked_commands,
|
96
|
+
"command_chaining": {
|
97
|
+
"pipe": (
|
98
|
+
"Supported" if self.separator_support["pipe"] else "Not supported"
|
99
|
+
),
|
100
|
+
"semicolon": (
|
101
|
+
"Supported"
|
102
|
+
if self.separator_support["semicolon"]
|
103
|
+
else "Not supported"
|
104
|
+
),
|
105
|
+
"ampersand": (
|
106
|
+
"Supported"
|
107
|
+
if self.separator_support["ampersand"]
|
108
|
+
else "Not supported"
|
109
|
+
),
|
110
|
+
},
|
111
|
+
"command_restrictions": "Special characters like $(), ${}, backticks, and I/O redirection are blocked",
|
112
|
+
}
|
113
|
+
|
114
|
+
self.usage_examples = [
|
115
|
+
{
|
116
|
+
"command": "ls ~/Downloads",
|
117
|
+
"description": "List files in downloads directory",
|
118
|
+
},
|
119
|
+
{
|
120
|
+
"command": "cat ~/.bashrc",
|
121
|
+
"description": "View bash configuration",
|
122
|
+
},
|
123
|
+
{
|
124
|
+
"command": "du -h ~/Downloads/* | grep G",
|
125
|
+
"description": "Find large files in downloads folder",
|
126
|
+
},
|
127
|
+
{
|
128
|
+
"command": 'find ~/Downloads -type f -name "*.pdf"',
|
129
|
+
"description": "Find all PDF files in downloads",
|
130
|
+
},
|
131
|
+
{
|
132
|
+
"command": "head -n 20 ~/Documents/notes.txt",
|
133
|
+
"description": "View the first 20 lines of a file",
|
134
|
+
},
|
135
|
+
{
|
136
|
+
"command": "ls -la | awk '{print $1, $9}'",
|
137
|
+
"description": "List files showing permissions and names using awk",
|
138
|
+
},
|
139
|
+
{
|
140
|
+
"command": "cat file.txt | awk '{if($1>10) print $0}'",
|
141
|
+
"description": "Filter lines where first column is greater than 10",
|
142
|
+
},
|
143
|
+
]
|
144
|
+
|
145
|
+
# Register tools
|
146
|
+
execute_command_tool = self.app.tool()
|
147
|
+
|
148
|
+
@execute_command_tool # Keep decorator reference to satisfy linters
|
149
|
+
async def execute_command(
|
150
|
+
command: str, session_id: Optional[str] = None
|
151
|
+
) -> Dict[str, Any]:
|
152
|
+
"""
|
153
|
+
Execute a Unix/macOS terminal command.
|
154
|
+
|
155
|
+
Args:
|
156
|
+
command: The command to execute
|
157
|
+
session_id: Optional session ID for permission management
|
158
|
+
|
159
|
+
Returns:
|
160
|
+
A dictionary with command output and status
|
161
|
+
"""
|
162
|
+
# For Claude Desktop compatibility, use the fixed session ID
|
163
|
+
require_session_id = self.config.get(
|
164
|
+
"security", "require_session_id", False
|
165
|
+
)
|
166
|
+
if not session_id or not require_session_id:
|
167
|
+
session_id = self.claude_desktop_session_id
|
168
|
+
logger.info(f"Using persistent Claude Desktop session: {session_id}")
|
169
|
+
|
170
|
+
return await self._execute_command(command, session_id=session_id)
|
171
|
+
|
172
|
+
# Store reference to silence linter warnings
|
173
|
+
self._execute_command_func = execute_command
|
174
|
+
|
175
|
+
execute_read_command_tool = self.app.tool()
|
176
|
+
|
177
|
+
@execute_read_command_tool # Keep decorator reference to satisfy linters
|
178
|
+
async def execute_read_command(
|
179
|
+
command: str, session_id: Optional[str] = None
|
180
|
+
) -> Dict[str, Any]:
|
181
|
+
"""
|
182
|
+
Execute a read-only Unix/macOS terminal command (ls, cat, grep, etc.).
|
183
|
+
|
184
|
+
Args:
|
185
|
+
command: The read-only command to execute
|
186
|
+
session_id: Optional session ID for permission management
|
187
|
+
|
188
|
+
Returns:
|
189
|
+
A dictionary with command output and status
|
190
|
+
"""
|
191
|
+
# For Claude Desktop compatibility, use the fixed session ID when no session ID provided
|
192
|
+
if not session_id:
|
193
|
+
session_id = self.claude_desktop_session_id
|
194
|
+
logger.info(
|
195
|
+
f"Using persistent Claude Desktop session for read command: {session_id}"
|
196
|
+
)
|
197
|
+
|
198
|
+
# Validate command and check directory permissions in one go
|
199
|
+
# Get the latest command lists
|
200
|
+
command_lists = self.config.get_effective_command_lists()
|
201
|
+
allow_separators = self.config.get(
|
202
|
+
"security", "allow_command_separators", True
|
203
|
+
)
|
204
|
+
|
205
|
+
validation = validate_command(
|
206
|
+
command,
|
207
|
+
command_lists["read"],
|
208
|
+
command_lists["write"],
|
209
|
+
command_lists["system"],
|
210
|
+
command_lists["blocked"],
|
211
|
+
command_lists["dangerous_patterns"],
|
212
|
+
allow_command_separators=allow_separators,
|
213
|
+
)
|
214
|
+
|
215
|
+
if not validation["is_valid"]:
|
216
|
+
return {
|
217
|
+
"success": False,
|
218
|
+
"output": "",
|
219
|
+
"error": validation["error"],
|
220
|
+
}
|
221
|
+
|
222
|
+
if validation["command_type"] != "read":
|
223
|
+
return {
|
224
|
+
"success": False,
|
225
|
+
"output": "",
|
226
|
+
"error": "This tool only supports read commands. Use execute_command for other command types.",
|
227
|
+
}
|
228
|
+
|
229
|
+
# Extract directory and check permissions (apply same directory checks as in _execute_command)
|
230
|
+
working_dir = extract_directory_from_command(command)
|
231
|
+
logger.info(f"Read command - extracted working directory: {working_dir}")
|
232
|
+
|
233
|
+
# Check if directory is whitelisted or has session approval
|
234
|
+
directory_allowed = False
|
235
|
+
|
236
|
+
if working_dir:
|
237
|
+
# Check global whitelist first
|
238
|
+
if is_directory_whitelisted(working_dir, self.whitelisted_directories):
|
239
|
+
directory_allowed = True
|
240
|
+
logger.info(
|
241
|
+
f"Read command - directory '{working_dir}' is globally whitelisted"
|
242
|
+
)
|
243
|
+
# Check session approvals if we have a session ID
|
244
|
+
elif session_id and self.session_manager.has_directory_approval(
|
245
|
+
session_id, working_dir
|
246
|
+
):
|
247
|
+
directory_allowed = True
|
248
|
+
logger.info(
|
249
|
+
f"Read command - directory '{working_dir}' is approved for session {session_id}"
|
250
|
+
)
|
251
|
+
else:
|
252
|
+
logger.warning(
|
253
|
+
f"Read command - directory '{working_dir}' is not whitelisted or approved"
|
254
|
+
)
|
255
|
+
# For Claude Desktop compatibility mode (require_session_id = False)
|
256
|
+
require_session_id = self.config.get(
|
257
|
+
"security", "require_session_id", False
|
258
|
+
)
|
259
|
+
auto_approve_in_desktop = self.config.get_section("security").get(
|
260
|
+
"auto_approve_directories_in_desktop_mode", False
|
261
|
+
)
|
262
|
+
|
263
|
+
if not require_session_id:
|
264
|
+
# Check if the directory is approved in the persistent desktop session
|
265
|
+
if self.session_manager.has_directory_approval(
|
266
|
+
self.claude_desktop_session_id, working_dir
|
267
|
+
):
|
268
|
+
directory_allowed = True
|
269
|
+
logger.info(
|
270
|
+
f"Read command - directory '{working_dir}' is approved in persistent desktop session"
|
271
|
+
)
|
272
|
+
elif auto_approve_in_desktop:
|
273
|
+
# Auto-approve directories in desktop mode if configured
|
274
|
+
directory_allowed = True
|
275
|
+
# Also add to persistent session for future requests
|
276
|
+
self.session_manager.approve_directory(
|
277
|
+
self.claude_desktop_session_id, working_dir
|
278
|
+
)
|
279
|
+
logger.warning(
|
280
|
+
f"Read command - auto-approving directory access in desktop mode: {working_dir}"
|
281
|
+
)
|
282
|
+
else:
|
283
|
+
# Only allow whitelisted directories if auto-approve is off
|
284
|
+
directory_allowed = False
|
285
|
+
logger.warning(
|
286
|
+
f"Read command - directory '{working_dir}' is not whitelisted - restricting access"
|
287
|
+
)
|
288
|
+
else:
|
289
|
+
# If we couldn't extract a directory, default to requiring permission
|
290
|
+
logger.warning(
|
291
|
+
"Read command - could not extract working directory from command"
|
292
|
+
)
|
293
|
+
working_dir = os.getcwd() # Default to current directory
|
294
|
+
|
295
|
+
# Check whitelist for current directory
|
296
|
+
if is_directory_whitelisted(working_dir, self.whitelisted_directories):
|
297
|
+
directory_allowed = True
|
298
|
+
elif session_id and self.session_manager.has_directory_approval(
|
299
|
+
session_id, working_dir
|
300
|
+
):
|
301
|
+
directory_allowed = True
|
302
|
+
else:
|
303
|
+
# For Claude Desktop compatibility mode
|
304
|
+
require_session_id = self.config.get(
|
305
|
+
"security", "require_session_id", False
|
306
|
+
)
|
307
|
+
auto_approve_in_desktop = self.config.get_section("security").get(
|
308
|
+
"auto_approve_directories_in_desktop_mode", False
|
309
|
+
)
|
310
|
+
|
311
|
+
if not require_session_id:
|
312
|
+
# Check if the directory is approved in the persistent desktop session
|
313
|
+
if self.session_manager.has_directory_approval(
|
314
|
+
self.claude_desktop_session_id, working_dir
|
315
|
+
):
|
316
|
+
directory_allowed = True
|
317
|
+
logger.info(
|
318
|
+
f"Read command - directory '{working_dir}' is approved in persistent desktop session"
|
319
|
+
)
|
320
|
+
elif auto_approve_in_desktop:
|
321
|
+
# Auto-approve directories in desktop mode if configured
|
322
|
+
directory_allowed = True
|
323
|
+
# Also add to persistent session for future requests
|
324
|
+
self.session_manager.approve_directory(
|
325
|
+
self.claude_desktop_session_id, working_dir
|
326
|
+
)
|
327
|
+
logger.warning(
|
328
|
+
f"Read command - auto-approving directory access in desktop mode: {working_dir}"
|
329
|
+
)
|
330
|
+
else:
|
331
|
+
# Only allow whitelisted directories if auto-approve is off
|
332
|
+
directory_allowed = False
|
333
|
+
|
334
|
+
# If directory is not allowed
|
335
|
+
if not directory_allowed:
|
336
|
+
# Check if we're in Claude Desktop mode (no session ID or require_session_id=false)
|
337
|
+
require_session_id = self.config.get(
|
338
|
+
"security", "require_session_id", False
|
339
|
+
)
|
340
|
+
if not session_id or not require_session_id:
|
341
|
+
# Always use the fixed persistent session ID for Claude Desktop
|
342
|
+
desktop_session_id = self.claude_desktop_session_id
|
343
|
+
|
344
|
+
# Include approval request information for Claude Desktop
|
345
|
+
return {
|
346
|
+
"success": False,
|
347
|
+
"output": "",
|
348
|
+
"error": f"Read command - access to directory '{working_dir}' is not allowed. Only whitelisted directories can be accessed.\n"
|
349
|
+
+ f"Whitelisted directories include: {', '.join(self.whitelisted_directories)}\n"
|
350
|
+
+ "Note: To request access to this directory, use the approve_directory tool with:\n"
|
351
|
+
+ f' approve_directory(directory="{working_dir}", session_id="{desktop_session_id}", remember=True)',
|
352
|
+
"directory": working_dir,
|
353
|
+
"session_id": desktop_session_id,
|
354
|
+
"requires_directory_approval": True, # Signal that approval is needed
|
355
|
+
}
|
356
|
+
else:
|
357
|
+
# For normal mode, request approval
|
358
|
+
return {
|
359
|
+
"success": False,
|
360
|
+
"output": "",
|
361
|
+
"error": f"Read command - directory '{working_dir}' requires approval. Use approve_directory tool with session_id '{session_id}'.",
|
362
|
+
"requires_directory_approval": True,
|
363
|
+
"directory": working_dir,
|
364
|
+
"session_id": session_id,
|
365
|
+
}
|
366
|
+
|
367
|
+
# Now that we've validated both the command and directory permissions, execute the command
|
368
|
+
return await self._execute_command(
|
369
|
+
command, command_type="read", session_id=session_id
|
370
|
+
)
|
371
|
+
|
372
|
+
# Store reference to silence linter warnings
|
373
|
+
self._execute_read_command_func = execute_read_command
|
374
|
+
|
375
|
+
list_available_commands_tool = self.app.tool()
|
376
|
+
|
377
|
+
@list_available_commands_tool # Keep decorator reference to satisfy linters
|
378
|
+
async def list_available_commands() -> Dict[str, List[str]]:
|
379
|
+
"""
|
380
|
+
List all available commands by category.
|
381
|
+
|
382
|
+
Returns:
|
383
|
+
A dictionary with commands grouped by category
|
384
|
+
"""
|
385
|
+
# Get the latest command lists
|
386
|
+
command_lists = self.config.get_effective_command_lists()
|
387
|
+
|
388
|
+
return {
|
389
|
+
"read_commands": command_lists["read"],
|
390
|
+
"write_commands": command_lists["write"],
|
391
|
+
"system_commands": command_lists["system"],
|
392
|
+
"blocked_commands": command_lists["blocked"],
|
393
|
+
}
|
394
|
+
|
395
|
+
# Store reference to silence linter warnings
|
396
|
+
self._list_available_commands_func = list_available_commands
|
397
|
+
|
398
|
+
get_command_help_tool = self.app.tool()
|
399
|
+
|
400
|
+
@get_command_help_tool # Keep decorator reference to satisfy linters
|
401
|
+
async def get_command_help() -> Dict[str, Any]:
|
402
|
+
"""
|
403
|
+
Get detailed help about command capabilities and usage.
|
404
|
+
|
405
|
+
This tool provides comprehensive information about:
|
406
|
+
- Supported commands in each category (read, write, system)
|
407
|
+
- Blocked commands for security reasons
|
408
|
+
- Command chaining capabilities (pipes, semicolons, ampersands)
|
409
|
+
- Usage restrictions and examples
|
410
|
+
|
411
|
+
Returns:
|
412
|
+
A dictionary with detailed information about command capabilities and usage
|
413
|
+
"""
|
414
|
+
# Get the latest command lists and separator support
|
415
|
+
command_lists = self.config.get_effective_command_lists()
|
416
|
+
separator_support = self.config.has_separator_support()
|
417
|
+
|
418
|
+
# Log the separator support for debugging
|
419
|
+
logger.info(f"Separator support status: {separator_support}")
|
420
|
+
logger.info(
|
421
|
+
f"allow_command_separators setting: {self.config.get('security', 'allow_command_separators')}"
|
422
|
+
)
|
423
|
+
|
424
|
+
# Extra check for pipe character in dangerous patterns
|
425
|
+
pipe_in_patterns = any(
|
426
|
+
"|" in p or r"\|" in p for p in command_lists["dangerous_patterns"]
|
427
|
+
)
|
428
|
+
logger.info(
|
429
|
+
f"Pipe character found in dangerous patterns: {pipe_in_patterns}"
|
430
|
+
)
|
431
|
+
|
432
|
+
# Update capabilities
|
433
|
+
updated_capabilities = {
|
434
|
+
"supported_commands": {
|
435
|
+
"read": command_lists["read"],
|
436
|
+
"write": command_lists["write"],
|
437
|
+
"system": command_lists["system"],
|
438
|
+
},
|
439
|
+
"blocked_commands": command_lists["blocked"],
|
440
|
+
"command_chaining": {
|
441
|
+
"pipe": (
|
442
|
+
"Supported" if separator_support["pipe"] else "Not supported"
|
443
|
+
),
|
444
|
+
"semicolon": (
|
445
|
+
"Supported"
|
446
|
+
if separator_support["semicolon"]
|
447
|
+
else "Not supported"
|
448
|
+
),
|
449
|
+
"ampersand": (
|
450
|
+
"Supported"
|
451
|
+
if separator_support["ampersand"]
|
452
|
+
else "Not supported"
|
453
|
+
),
|
454
|
+
},
|
455
|
+
"command_restrictions": "Special characters like $(), ${}, backticks, and I/O redirection are blocked",
|
456
|
+
}
|
457
|
+
|
458
|
+
# Provide helpful information for Claude to understand command usage
|
459
|
+
return {
|
460
|
+
"capabilities": updated_capabilities,
|
461
|
+
"examples": self.usage_examples,
|
462
|
+
"recommended_approach": {
|
463
|
+
"finding_large_files": "Use 'du -h <directory>/* | sort -hr | head -n 10' to find the 10 largest files",
|
464
|
+
"file_searching": "Use 'find <directory> -type f -name \"pattern\"' for file searches",
|
465
|
+
"text_searching": "Use 'grep \"pattern\" <file>' to search in files",
|
466
|
+
"file_viewing": "Use 'cat', 'head', or 'tail' for viewing files",
|
467
|
+
"sorting": "Use 'sort' with options like -n (numeric), -r (reverse), -h (human readable sizes)",
|
468
|
+
"text_processing": "Use 'awk' for advanced text processing. For example: 'ls -la | awk \"{print $1, $9}\"' to show permissions and filenames",
|
469
|
+
"column_filtering": "Use 'awk' to filter by column values: 'cat data.txt | awk \"{if($3 > 100) print}\"' to show lines where column 3 exceeds 100",
|
470
|
+
},
|
471
|
+
"permissions": {
|
472
|
+
"read_commands": "Can be executed without confirmation",
|
473
|
+
"write_commands": "Require approval for first use in a session",
|
474
|
+
"system_commands": "Require approval for first use in a session",
|
475
|
+
},
|
476
|
+
}
|
477
|
+
|
478
|
+
# Store reference to silence linter warnings
|
479
|
+
self._get_command_help_func = get_command_help
|
480
|
+
|
481
|
+
approve_command_type_tool = self.app.tool()
|
482
|
+
|
483
|
+
@approve_command_type_tool # Keep decorator reference to satisfy linters
|
484
|
+
async def approve_command_type(
|
485
|
+
command_type: str, session_id: str, remember: bool = False
|
486
|
+
) -> Dict[str, Any]:
|
487
|
+
"""
|
488
|
+
Approve a command type for the current session.
|
489
|
+
|
490
|
+
Args:
|
491
|
+
command_type: The command type to approve (read, write, system)
|
492
|
+
session_id: The session ID
|
493
|
+
remember: Whether to remember this approval for the session
|
494
|
+
|
495
|
+
Returns:
|
496
|
+
A dictionary with approval status
|
497
|
+
"""
|
498
|
+
if command_type not in ["read", "write", "system"]:
|
499
|
+
return {
|
500
|
+
"success": False,
|
501
|
+
"message": f"Invalid command type: {command_type}",
|
502
|
+
}
|
503
|
+
|
504
|
+
if remember:
|
505
|
+
self.session_manager.approve_command_type(session_id, command_type)
|
506
|
+
return {
|
507
|
+
"success": True,
|
508
|
+
"message": f"Command type '{command_type}' approved for this session",
|
509
|
+
}
|
510
|
+
else:
|
511
|
+
return {
|
512
|
+
"success": True,
|
513
|
+
"message": f"Command type '{command_type}' approved for one-time use",
|
514
|
+
}
|
515
|
+
|
516
|
+
# Store reference to silence linter warnings
|
517
|
+
self._approve_command_type_func = approve_command_type
|
518
|
+
|
519
|
+
# Add tool for directory approval
|
520
|
+
approve_directory_tool = self.app.tool()
|
521
|
+
|
522
|
+
@approve_directory_tool # Keep decorator reference to satisfy linters
|
523
|
+
async def approve_directory(
|
524
|
+
directory: str, session_id: str, remember: bool = True
|
525
|
+
) -> Dict[str, Any]:
|
526
|
+
"""
|
527
|
+
Approve access to a directory for the current session.
|
528
|
+
|
529
|
+
Args:
|
530
|
+
directory: The directory to approve access to
|
531
|
+
session_id: The session ID
|
532
|
+
remember: Whether to remember this approval for the session
|
533
|
+
|
534
|
+
Returns:
|
535
|
+
A dictionary with approval status
|
536
|
+
"""
|
537
|
+
# Normalize the directory path
|
538
|
+
normalized_dir = normalize_path(directory)
|
539
|
+
|
540
|
+
# Check if directory is already whitelisted globally
|
541
|
+
if is_directory_whitelisted(normalized_dir, self.whitelisted_directories):
|
542
|
+
return {
|
543
|
+
"success": True,
|
544
|
+
"message": f"Directory '{normalized_dir}' is already globally whitelisted",
|
545
|
+
"directory": normalized_dir,
|
546
|
+
}
|
547
|
+
|
548
|
+
# Check if directory is already approved for this session
|
549
|
+
if self.session_manager.has_directory_approval(session_id, normalized_dir):
|
550
|
+
return {
|
551
|
+
"success": True,
|
552
|
+
"message": f"Directory '{normalized_dir}' is already approved for this session",
|
553
|
+
"directory": normalized_dir,
|
554
|
+
}
|
555
|
+
|
556
|
+
# Approve the directory for this session
|
557
|
+
if remember:
|
558
|
+
self.session_manager.approve_directory(session_id, normalized_dir)
|
559
|
+
return {
|
560
|
+
"success": True,
|
561
|
+
"message": f"Directory '{normalized_dir}' approved for this session",
|
562
|
+
"directory": normalized_dir,
|
563
|
+
}
|
564
|
+
else:
|
565
|
+
return {
|
566
|
+
"success": True,
|
567
|
+
"message": f"Directory '{normalized_dir}' approved for one-time use",
|
568
|
+
"directory": normalized_dir,
|
569
|
+
}
|
570
|
+
|
571
|
+
# Store reference to silence linter warnings
|
572
|
+
self._approve_directory_func = approve_directory
|
573
|
+
|
574
|
+
# Tool to list whitelisted and approved directories
|
575
|
+
list_directories_tool = self.app.tool()
|
576
|
+
|
577
|
+
@list_directories_tool # Keep decorator reference to satisfy linters
|
578
|
+
async def list_directories(session_id: Optional[str] = None) -> Dict[str, Any]:
|
579
|
+
"""
|
580
|
+
List all whitelisted and approved directories.
|
581
|
+
|
582
|
+
Args:
|
583
|
+
session_id: Optional session ID to get session-specific approvals
|
584
|
+
|
585
|
+
Returns:
|
586
|
+
A dictionary with globally whitelisted and session-approved directories
|
587
|
+
"""
|
588
|
+
result = {
|
589
|
+
"whitelisted_directories": self.whitelisted_directories,
|
590
|
+
"session_approved_directories": [],
|
591
|
+
}
|
592
|
+
|
593
|
+
if session_id:
|
594
|
+
result["session_approved_directories"] = list(
|
595
|
+
self.session_manager.get_approved_directories(session_id)
|
596
|
+
)
|
597
|
+
|
598
|
+
return result
|
599
|
+
|
600
|
+
# Store reference to silence linter warnings
|
601
|
+
self._list_directories_func = list_directories
|
602
|
+
|
603
|
+
# Register new tools for configuration
|
604
|
+
get_configuration_tool = self.app.tool()
|
605
|
+
|
606
|
+
@get_configuration_tool # Keep decorator reference to satisfy linters
|
607
|
+
async def get_configuration() -> Dict[str, Any]:
|
608
|
+
"""
|
609
|
+
Get the current configuration settings.
|
610
|
+
|
611
|
+
Returns:
|
612
|
+
The current configuration settings
|
613
|
+
"""
|
614
|
+
# Get a safe copy of the configuration
|
615
|
+
config_copy = self.config.get_all()
|
616
|
+
|
617
|
+
# Format it for display
|
618
|
+
return {
|
619
|
+
"server": config_copy["server"],
|
620
|
+
"security": config_copy["security"],
|
621
|
+
"commands": {
|
622
|
+
"read_count": len(config_copy["commands"]["read"]),
|
623
|
+
"write_count": len(config_copy["commands"]["write"]),
|
624
|
+
"system_count": len(config_copy["commands"]["system"]),
|
625
|
+
"blocked_count": len(config_copy["commands"]["blocked"]),
|
626
|
+
"dangerous_patterns_count": len(
|
627
|
+
config_copy["commands"]["dangerous_patterns"]
|
628
|
+
),
|
629
|
+
"full_command_lists": config_copy["commands"],
|
630
|
+
},
|
631
|
+
"output": config_copy["output"],
|
632
|
+
"separator_support": self.config.has_separator_support(),
|
633
|
+
"directory_whitelisting": {
|
634
|
+
"enabled": True,
|
635
|
+
"whitelisted_directories": self.whitelisted_directories,
|
636
|
+
"note": "Directories not in this list will require session approval",
|
637
|
+
},
|
638
|
+
}
|
639
|
+
|
640
|
+
# Store reference to silence linter warnings
|
641
|
+
self._get_configuration_func = get_configuration
|
642
|
+
|
643
|
+
# update_configuration tool removed
|
644
|
+
|
645
|
+
async def _execute_command(
|
646
|
+
self,
|
647
|
+
command: str,
|
648
|
+
command_type: Optional[str] = None,
|
649
|
+
session_id: Optional[str] = None,
|
650
|
+
) -> Dict[str, Any]:
|
651
|
+
"""Execute a Unix/macOS terminal command.
|
652
|
+
|
653
|
+
Args:
|
654
|
+
command: The command to execute
|
655
|
+
command_type: Optional type of command (read, write, system)
|
656
|
+
session_id: Optional session ID for permission management
|
657
|
+
|
658
|
+
Returns:
|
659
|
+
A dictionary with command output and status
|
660
|
+
"""
|
661
|
+
# Get the latest command lists and separator settings
|
662
|
+
command_lists = self.config.get_effective_command_lists()
|
663
|
+
allow_separators = self.config.get("security", "allow_command_separators", True)
|
664
|
+
|
665
|
+
# Validate the command
|
666
|
+
validation = validate_command(
|
667
|
+
command,
|
668
|
+
command_lists["read"],
|
669
|
+
command_lists["write"],
|
670
|
+
command_lists["system"],
|
671
|
+
command_lists["blocked"],
|
672
|
+
command_lists["dangerous_patterns"],
|
673
|
+
allow_command_separators=allow_separators,
|
674
|
+
)
|
675
|
+
|
676
|
+
if not validation["is_valid"]:
|
677
|
+
return {
|
678
|
+
"success": False,
|
679
|
+
"output": "",
|
680
|
+
"error": validation["error"],
|
681
|
+
}
|
682
|
+
|
683
|
+
# If command_type is specified, ensure it matches the validated type
|
684
|
+
if command_type and validation["command_type"] != command_type:
|
685
|
+
return {
|
686
|
+
"success": False,
|
687
|
+
"output": "",
|
688
|
+
"error": f"Command type mismatch. Expected {command_type}, got {validation['command_type']}",
|
689
|
+
}
|
690
|
+
|
691
|
+
actual_command_type = validation["command_type"]
|
692
|
+
|
693
|
+
# Extract the working directory from the command
|
694
|
+
working_dir = extract_directory_from_command(command)
|
695
|
+
logger.info(f"Extracted working directory from command: {working_dir}")
|
696
|
+
|
697
|
+
# Check if directory is whitelisted or has session approval
|
698
|
+
directory_allowed = False
|
699
|
+
|
700
|
+
if working_dir:
|
701
|
+
# Check global whitelist first
|
702
|
+
if is_directory_whitelisted(working_dir, self.whitelisted_directories):
|
703
|
+
directory_allowed = True
|
704
|
+
logger.info(f"Directory '{working_dir}' is globally whitelisted")
|
705
|
+
# Check session approvals if we have a session ID
|
706
|
+
elif session_id and self.session_manager.has_directory_approval(
|
707
|
+
session_id, working_dir
|
708
|
+
):
|
709
|
+
directory_allowed = True
|
710
|
+
logger.info(
|
711
|
+
f"Directory '{working_dir}' is approved for session {session_id}"
|
712
|
+
)
|
713
|
+
else:
|
714
|
+
logger.warning(
|
715
|
+
f"Directory '{working_dir}' is not whitelisted or approved"
|
716
|
+
)
|
717
|
+
# For Claude Desktop compatibility mode (require_session_id = False)
|
718
|
+
require_session_id = self.config.get(
|
719
|
+
"security", "require_session_id", False
|
720
|
+
)
|
721
|
+
auto_approve_in_desktop = self.config.get_section("security").get(
|
722
|
+
"auto_approve_directories_in_desktop_mode", False
|
723
|
+
)
|
724
|
+
|
725
|
+
if not require_session_id:
|
726
|
+
# Check if the directory is approved in the persistent desktop session
|
727
|
+
if self.session_manager.has_directory_approval(
|
728
|
+
self.claude_desktop_session_id, working_dir
|
729
|
+
):
|
730
|
+
directory_allowed = True
|
731
|
+
logger.info(
|
732
|
+
f"Directory '{working_dir}' is approved in persistent desktop session"
|
733
|
+
)
|
734
|
+
elif auto_approve_in_desktop:
|
735
|
+
# Auto-approve directories in desktop mode if configured
|
736
|
+
directory_allowed = True
|
737
|
+
# Also add to persistent session for future requests
|
738
|
+
self.session_manager.approve_directory(
|
739
|
+
self.claude_desktop_session_id, working_dir
|
740
|
+
)
|
741
|
+
logger.warning(
|
742
|
+
f"Auto-approving directory access in desktop mode: {working_dir}"
|
743
|
+
)
|
744
|
+
else:
|
745
|
+
# Only allow whitelisted directories if auto-approve is off
|
746
|
+
directory_allowed = False
|
747
|
+
logger.warning(
|
748
|
+
f"Directory '{working_dir}' is not whitelisted - restricting access"
|
749
|
+
)
|
750
|
+
else:
|
751
|
+
# If we couldn't extract a directory, default to requiring permission
|
752
|
+
logger.warning("Could not extract working directory from command")
|
753
|
+
working_dir = os.getcwd() # Default to current directory
|
754
|
+
|
755
|
+
# Check whitelist for current directory
|
756
|
+
if is_directory_whitelisted(working_dir, self.whitelisted_directories):
|
757
|
+
directory_allowed = True
|
758
|
+
elif session_id and self.session_manager.has_directory_approval(
|
759
|
+
session_id, working_dir
|
760
|
+
):
|
761
|
+
directory_allowed = True
|
762
|
+
else:
|
763
|
+
# For Claude Desktop compatibility mode
|
764
|
+
require_session_id = self.config.get(
|
765
|
+
"security", "require_session_id", False
|
766
|
+
)
|
767
|
+
auto_approve_in_desktop = self.config.get_section("security").get(
|
768
|
+
"auto_approve_directories_in_desktop_mode", False
|
769
|
+
)
|
770
|
+
|
771
|
+
if not require_session_id:
|
772
|
+
# Check if the directory is approved in the persistent desktop session
|
773
|
+
if self.session_manager.has_directory_approval(
|
774
|
+
self.claude_desktop_session_id, working_dir
|
775
|
+
):
|
776
|
+
directory_allowed = True
|
777
|
+
logger.info(
|
778
|
+
f"Directory '{working_dir}' is approved in persistent desktop session"
|
779
|
+
)
|
780
|
+
elif auto_approve_in_desktop:
|
781
|
+
# Auto-approve directories in desktop mode if configured
|
782
|
+
directory_allowed = True
|
783
|
+
# Also add to persistent session for future requests
|
784
|
+
self.session_manager.approve_directory(
|
785
|
+
self.claude_desktop_session_id, working_dir
|
786
|
+
)
|
787
|
+
logger.warning(
|
788
|
+
f"Auto-approving directory access in desktop mode: {working_dir}"
|
789
|
+
)
|
790
|
+
else:
|
791
|
+
# Only allow whitelisted directories if auto-approve is off
|
792
|
+
directory_allowed = False
|
793
|
+
|
794
|
+
# If directory is not allowed
|
795
|
+
if not directory_allowed:
|
796
|
+
# Check if we're in Claude Desktop mode (no session ID or require_session_id=false)
|
797
|
+
require_session_id = self.config.get(
|
798
|
+
"security", "require_session_id", False
|
799
|
+
)
|
800
|
+
if not session_id or not require_session_id:
|
801
|
+
# Always use the fixed persistent session ID for Claude Desktop
|
802
|
+
desktop_session_id = self.claude_desktop_session_id
|
803
|
+
|
804
|
+
# Include approval request information for Claude Desktop
|
805
|
+
return {
|
806
|
+
"success": False,
|
807
|
+
"output": "",
|
808
|
+
"error": f"Access to directory '{working_dir}' is not allowed. Only whitelisted directories can be accessed.\n"
|
809
|
+
+ f"Whitelisted directories include: {', '.join(self.whitelisted_directories)}\n"
|
810
|
+
+ "Note: To request access to this directory, use the approve_directory tool with:\n"
|
811
|
+
+ f' approve_directory(directory="{working_dir}", session_id="{desktop_session_id}", remember=True)',
|
812
|
+
"directory": working_dir,
|
813
|
+
"session_id": desktop_session_id,
|
814
|
+
"requires_directory_approval": True, # Signal that approval is needed
|
815
|
+
}
|
816
|
+
else:
|
817
|
+
# For normal mode, request approval
|
818
|
+
return {
|
819
|
+
"success": False,
|
820
|
+
"output": "",
|
821
|
+
"error": f"Directory '{working_dir}' requires approval. Use approve_directory tool with session_id '{session_id}'.",
|
822
|
+
"requires_directory_approval": True,
|
823
|
+
"directory": working_dir,
|
824
|
+
"session_id": session_id,
|
825
|
+
}
|
826
|
+
|
827
|
+
# For read commands, bypass command type approval
|
828
|
+
if actual_command_type == "read":
|
829
|
+
# No approval needed for read commands
|
830
|
+
pass
|
831
|
+
# For write and system commands with user confirmation enabled
|
832
|
+
elif actual_command_type in ["write", "system"] and self.config.get(
|
833
|
+
"security", "allow_user_confirmation", True
|
834
|
+
):
|
835
|
+
|
836
|
+
# Check if we require a session ID (turn off for Claude Desktop compatibility)
|
837
|
+
require_session_id = self.config.get(
|
838
|
+
"security", "require_session_id", False
|
839
|
+
)
|
840
|
+
|
841
|
+
# WORKAROUND FOR CLAUDE DESKTOP:
|
842
|
+
# Either auto-approve if no session_id provided OR if require_session_id is False
|
843
|
+
if not session_id or not require_session_id:
|
844
|
+
if not session_id:
|
845
|
+
logger.warning(
|
846
|
+
"No session ID provided, auto-approving command: %s",
|
847
|
+
command,
|
848
|
+
)
|
849
|
+
else:
|
850
|
+
logger.warning(
|
851
|
+
"Session validation disabled, auto-approving command: %s",
|
852
|
+
command,
|
853
|
+
)
|
854
|
+
# Auto-approve without requiring explicit permission
|
855
|
+
pass
|
856
|
+
# Normal session-based approval when require_session_id is True
|
857
|
+
elif not self.session_manager.has_command_approval(
|
858
|
+
session_id, command
|
859
|
+
) and not self.session_manager.has_command_type_approval(
|
860
|
+
session_id, actual_command_type
|
861
|
+
):
|
862
|
+
return {
|
863
|
+
"success": False,
|
864
|
+
"output": "",
|
865
|
+
"error": f"Command '{command}' requires approval. Use approve_command_type tool with session_id '{session_id}'.",
|
866
|
+
"requires_approval": True,
|
867
|
+
"command_type": actual_command_type,
|
868
|
+
"session_id": session_id,
|
869
|
+
}
|
870
|
+
|
871
|
+
# Execute the command
|
872
|
+
try:
|
873
|
+
logger.info(f"Executing command: {command}")
|
874
|
+
# Using subprocess with shell=True is necessary for many shell commands,
|
875
|
+
# but we've validated the command for safety already
|
876
|
+
process = await asyncio.create_subprocess_shell(
|
877
|
+
command,
|
878
|
+
stdout=asyncio.subprocess.PIPE,
|
879
|
+
stderr=asyncio.subprocess.PIPE,
|
880
|
+
)
|
881
|
+
stdout, stderr = await process.communicate()
|
882
|
+
|
883
|
+
output = stdout.decode("utf-8", errors="replace")
|
884
|
+
error = stderr.decode("utf-8", errors="replace")
|
885
|
+
|
886
|
+
# Limit output size to prevent huge responses
|
887
|
+
max_output_size = self.config.get(
|
888
|
+
"output", "max_size", 100 * 1024
|
889
|
+
) # 100KB default
|
890
|
+
if len(output) > max_output_size:
|
891
|
+
output = (
|
892
|
+
output[:max_output_size] + "\n... [output truncated due to size]"
|
893
|
+
)
|
894
|
+
|
895
|
+
return {
|
896
|
+
"success": process.returncode == 0,
|
897
|
+
"output": output,
|
898
|
+
"error": error,
|
899
|
+
"exit_code": process.returncode,
|
900
|
+
"command_type": actual_command_type,
|
901
|
+
}
|
902
|
+
except Exception as e:
|
903
|
+
logger.error(f"Error executing command: {str(e)}")
|
904
|
+
return {"success": False, "output": "", "error": str(e)}
|
905
|
+
|
906
|
+
async def run_async(self):
|
907
|
+
"""Run the MCP server asynchronously."""
|
908
|
+
|
909
|
+
# Clean old sessions periodically
|
910
|
+
async def clean_sessions():
|
911
|
+
while True:
|
912
|
+
session_timeout = self.config.get(
|
913
|
+
"security", "session_timeout", 3600
|
914
|
+
) # 1 hour default
|
915
|
+
await asyncio.sleep(session_timeout)
|
916
|
+
self.session_manager.clean_old_sessions(session_timeout)
|
917
|
+
|
918
|
+
# Start session cleaning task
|
919
|
+
asyncio.create_task(clean_sessions())
|
920
|
+
|
921
|
+
# Run the MCP server using its internal method
|
922
|
+
# This is used when we want to run within an existing event loop
|
923
|
+
await self.app.run_stdio_async()
|
924
|
+
|
925
|
+
def run(self):
|
926
|
+
"""Run the MCP server with its own event loop."""
|
927
|
+
# Let the app handle the event loop directly
|
928
|
+
self.app.run()
|
929
|
+
|
930
|
+
|
931
|
+
def main():
|
932
|
+
"""Run the command-line MCP server."""
|
933
|
+
parser = argparse.ArgumentParser(description="Command-line MCP server")
|
934
|
+
parser.add_argument("--config", "-c", help="Path to configuration file")
|
935
|
+
parser.add_argument("--env", "-e", help="Path to .env file")
|
936
|
+
args = parser.parse_args()
|
937
|
+
|
938
|
+
server = CommandLineMCP(config_path=args.config, env_file_path=args.env)
|
939
|
+
server.run()
|
940
|
+
|
941
|
+
|
942
|
+
if __name__ == "__main__":
|
943
|
+
main()
|