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/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()