linux-mcp-server 0.1.0.dev0__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,4 @@
1
+ """Linux MCP Server - Read-only diagnostics and troubleshooting for RHEL systems."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,30 @@
1
+ """Main entry point for the Linux MCP Server."""
2
+
3
+ import logging
4
+ import sys
5
+ from .logging_config import setup_logging
6
+ from .server import main
7
+
8
+
9
+ def cli():
10
+ """Console script entry point for the Linux MCP Server."""
11
+ # Initialize logging first, before any other operations
12
+ setup_logging()
13
+
14
+ logger = logging.getLogger(__name__)
15
+ logger.info("Starting Linux MCP Server")
16
+
17
+ try:
18
+ # FastMCP.run() creates its own event loop, don't use asyncio.run()
19
+ main()
20
+ except KeyboardInterrupt:
21
+ logger.info("Linux MCP Server stopped by user")
22
+ sys.exit(0)
23
+ except Exception as e:
24
+ logger.critical(f"Fatal error in Linux MCP Server: {e}", exc_info=True)
25
+ sys.exit(1)
26
+
27
+
28
+ if __name__ == "__main__":
29
+ cli()
30
+
@@ -0,0 +1,285 @@
1
+ """Audit logging utilities for Linux MCP Server.
2
+
3
+ This module provides helper functions for consistent audit logging across
4
+ the entire MCP server. All functions add structured context to log records
5
+ that can be output in both human-readable and JSON formats.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Dict, Optional
10
+ from contextlib import contextmanager
11
+
12
+
13
+ # Sensitive field names that should be redacted in logs
14
+ SENSITIVE_FIELDS = {
15
+ 'password', 'passwd', 'pwd',
16
+ 'secret', 'api_key', 'apikey',
17
+ 'token', 'auth', 'authorization',
18
+ 'private_key', 'privatekey'
19
+ }
20
+
21
+
22
+ def sanitize_parameters(params: Dict[str, Any]) -> Dict[str, Any]:
23
+ """
24
+ Sanitize parameters by redacting sensitive fields.
25
+
26
+ Args:
27
+ params: Dictionary of parameters to sanitize
28
+
29
+ Returns:
30
+ Dictionary with sensitive fields redacted
31
+ """
32
+ if not params:
33
+ return params
34
+
35
+ sanitized = {}
36
+ for key, value in params.items():
37
+ # Check if key is sensitive
38
+ key_lower = key.lower().replace('_', '').replace('-', '')
39
+ is_sensitive = any(sensitive in key_lower for sensitive in
40
+ [s.replace('_', '') for s in SENSITIVE_FIELDS])
41
+
42
+ if is_sensitive:
43
+ sanitized[key] = "***REDACTED***"
44
+ elif isinstance(value, dict):
45
+ # Recursively sanitize nested dicts
46
+ sanitized[key] = sanitize_parameters(value)
47
+ else:
48
+ sanitized[key] = value
49
+
50
+ return sanitized
51
+
52
+
53
+ @contextmanager
54
+ def AuditContext(**extra_fields):
55
+ """
56
+ Context manager for adding extra fields to all log records.
57
+
58
+ Usage:
59
+ with AuditContext(tool="list_services", host="server1.com") as logger:
60
+ logger.info("Starting operation")
61
+
62
+ Args:
63
+ **extra_fields: Additional fields to add to log records
64
+
65
+ Yields:
66
+ Logger with extra fields
67
+ """
68
+ logger = logging.getLogger()
69
+
70
+ # Create adapter with extra fields
71
+ class ContextAdapter(logging.LoggerAdapter):
72
+ def process(self, msg, kwargs):
73
+ # Add extra fields to the record
74
+ if 'extra' not in kwargs:
75
+ kwargs['extra'] = {}
76
+ kwargs['extra'].update(self.extra)
77
+ return msg, kwargs
78
+
79
+ adapter = ContextAdapter(logger, extra_fields)
80
+ yield adapter
81
+
82
+
83
+ def log_tool_call(tool_name: str, parameters: Dict[str, Any]):
84
+ """
85
+ Log a tool invocation.
86
+
87
+ Args:
88
+ tool_name: Name of the tool being called
89
+ parameters: Tool parameters (will be sanitized)
90
+ """
91
+ logger = logging.getLogger(__name__)
92
+
93
+ # Determine if local or remote execution
94
+ execution_mode = "remote" if parameters.get("host") else "local"
95
+
96
+ # Sanitize parameters
97
+ safe_params = sanitize_parameters(parameters)
98
+
99
+ # Build log record with extra fields
100
+ extra = {
101
+ 'event': 'TOOL_CALL',
102
+ 'tool': tool_name,
103
+ 'execution_mode': execution_mode,
104
+ }
105
+
106
+ # Add host and username if present
107
+ if 'host' in parameters:
108
+ extra['host'] = parameters['host']
109
+ if 'username' in parameters:
110
+ extra['username'] = parameters['username']
111
+
112
+ # Add sanitized parameters as string
113
+ params_str = ', '.join(f'{k}={v}' for k, v in safe_params.items()
114
+ if k not in ['host', 'username'])
115
+
116
+ message = f"TOOL_CALL: {tool_name}"
117
+ if params_str:
118
+ message += f" | {params_str}"
119
+
120
+ logger.info(message, extra=extra)
121
+
122
+
123
+ def log_tool_complete(tool_name: str, status: str, duration: float, error: Optional[str] = None):
124
+ """
125
+ Log tool completion.
126
+
127
+ Args:
128
+ tool_name: Name of the tool
129
+ status: Completion status ("success" or "error")
130
+ duration: Execution time in seconds
131
+ error: Optional error message
132
+ """
133
+ logger = logging.getLogger(__name__)
134
+
135
+ extra = {
136
+ 'event': 'TOOL_COMPLETE',
137
+ 'tool': tool_name,
138
+ 'status': status,
139
+ 'duration': f"{duration:.3f}s",
140
+ }
141
+
142
+ message = f"TOOL_COMPLETE: {tool_name}"
143
+
144
+ if status == "error":
145
+ if error:
146
+ extra['error'] = error
147
+ message += f" | error: {error}"
148
+ logger.error(message, extra=extra)
149
+ else:
150
+ logger.info(message, extra=extra)
151
+
152
+
153
+ def log_ssh_connect(
154
+ host: str,
155
+ username: str,
156
+ status: str,
157
+ reused: bool = False,
158
+ key_path: Optional[str] = None,
159
+ error: Optional[str] = None
160
+ ):
161
+ """
162
+ Log SSH connection event.
163
+
164
+ Verbosity is tiered based on log level:
165
+ - INFO: Basic connection success/failure
166
+ - DEBUG: Detailed information including key path, reuse status
167
+
168
+ Args:
169
+ host: Remote host
170
+ username: SSH username
171
+ status: Connection status ("success" or "failed")
172
+ reused: Whether connection was reused (shown at DEBUG level)
173
+ key_path: Path to SSH key used (shown at DEBUG level)
174
+ error: Optional error message
175
+ """
176
+ logger = logging.getLogger(__name__)
177
+
178
+ user_host = f"{username}@{host}"
179
+
180
+ if status == "success":
181
+ extra = {
182
+ 'event': 'SSH_CONNECT',
183
+ 'host': host,
184
+ 'username': username,
185
+ 'status': status,
186
+ }
187
+
188
+ # At INFO level, just log basic success
189
+ message = f"SSH_CONNECT: {user_host}"
190
+
191
+ # At DEBUG level, add more details
192
+ if logger.isEnabledFor(logging.DEBUG):
193
+ if reused is not None:
194
+ extra['reused'] = reused
195
+ if key_path:
196
+ extra['key'] = key_path
197
+
198
+ logger.info(message, extra=extra)
199
+
200
+ else:
201
+ # Connection failed
202
+ extra = {
203
+ 'event': 'SSH_CONNECT_FAILED',
204
+ 'host': host,
205
+ 'username': username,
206
+ 'status': 'failed',
207
+ }
208
+
209
+ if error:
210
+ extra['reason'] = error
211
+
212
+ message = f"SSH_AUTH_FAILED: {user_host}"
213
+ if error:
214
+ message += f" | reason: {error}"
215
+
216
+ logger.warning(message, extra=extra)
217
+
218
+
219
+ def log_ssh_command(
220
+ command: str,
221
+ host: str,
222
+ exit_code: int,
223
+ duration: Optional[float] = None
224
+ ):
225
+ """
226
+ Log SSH command execution.
227
+
228
+ Verbosity is tiered based on log level:
229
+ - INFO: Command and exit code
230
+ - DEBUG: Also includes execution duration
231
+
232
+ Args:
233
+ command: Command that was executed
234
+ host: Remote host
235
+ exit_code: Command exit code
236
+ duration: Optional execution duration in seconds (shown at DEBUG level)
237
+ """
238
+ logger = logging.getLogger(__name__)
239
+
240
+ extra = {
241
+ 'event': 'REMOTE_EXEC',
242
+ 'command': command,
243
+ 'host': host,
244
+ 'exit_code': exit_code,
245
+ }
246
+
247
+ message = f"REMOTE_EXEC: {command} | host={host} | exit_code={exit_code}"
248
+
249
+ # At DEBUG level, include duration
250
+ if duration is not None and logger.isEnabledFor(logging.DEBUG):
251
+ extra['duration'] = f"{duration:.3f}s"
252
+ message += f" | duration={duration:.3f}s"
253
+
254
+ logger.info(message, extra=extra)
255
+
256
+
257
+ def log_operation(
258
+ operation: str,
259
+ message: str,
260
+ level: int = logging.INFO,
261
+ **context
262
+ ):
263
+ """
264
+ Log a general operation with context.
265
+
266
+ This is a generic logging function for operations that don't fit
267
+ other specific logging functions.
268
+
269
+ Args:
270
+ operation: Name of the operation
271
+ message: Log message
272
+ level: Log level (default: INFO)
273
+ **context: Additional context fields
274
+ """
275
+ logger = logging.getLogger(__name__)
276
+
277
+ extra = {
278
+ 'operation': operation,
279
+ **context
280
+ }
281
+
282
+ full_message = f"{operation}: {message}"
283
+
284
+ logger.log(level, full_message, extra=extra)
285
+
@@ -0,0 +1,144 @@
1
+ """Centralized logging configuration for Linux MCP Server.
2
+
3
+ Simplified logging setup with standard Python logging infrastructure.
4
+ Supports structured logging with extra fields for audit and diagnostic purposes.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import logging.handlers
10
+ import os
11
+ from pathlib import Path
12
+
13
+
14
+ def get_log_directory() -> Path:
15
+ """Get the log directory path, creating it if necessary."""
16
+ env_log_dir = os.getenv("LINUX_MCP_LOG_DIR")
17
+ log_dir = Path(env_log_dir) if env_log_dir else Path.home() / ".local" / "share" / "linux-mcp-server" / "logs"
18
+ log_dir.mkdir(parents=True, exist_ok=True)
19
+ return log_dir
20
+
21
+
22
+ def get_log_level() -> int:
23
+ """Get the log level from environment variable (defaults to INFO)."""
24
+ level_name = os.getenv("LINUX_MCP_LOG_LEVEL", "INFO").upper()
25
+ return getattr(logging, level_name, logging.INFO)
26
+
27
+
28
+ def get_retention_days() -> int:
29
+ """Get the log retention days from environment variable (defaults to 10)."""
30
+ try:
31
+ return int(os.getenv("LINUX_MCP_LOG_RETENTION_DAYS", "10"))
32
+ except ValueError:
33
+ return 10
34
+
35
+
36
+ class StructuredFormatter(logging.Formatter):
37
+ """
38
+ Structured log formatter supporting extra fields.
39
+
40
+ Format: TIMESTAMP | LEVEL | MODULE | MESSAGE | key=value ...
41
+ Extra fields added to LogRecord are appended as key=value pairs.
42
+ """
43
+
44
+ STANDARD_FIELDS = {
45
+ 'name', 'msg', 'args', 'created', 'filename', 'funcName', 'levelname',
46
+ 'levelno', 'lineno', 'module', 'msecs', 'message', 'pathname', 'process',
47
+ 'processName', 'relativeCreated', 'thread', 'threadName', 'exc_info',
48
+ 'exc_text', 'stack_info', 'asctime', 'taskName'
49
+ }
50
+
51
+ def format(self, record: logging.LogRecord) -> str:
52
+ """Format a log record with extra fields."""
53
+ # Base message
54
+ base_msg = super().format(record)
55
+
56
+ # Append extra fields as key=value pairs
57
+ extra_fields = [f"{k}={v}" for k, v in record.__dict__.items()
58
+ if k not in self.STANDARD_FIELDS]
59
+
60
+ return f"{base_msg} | {' | '.join(extra_fields)}" if extra_fields else base_msg
61
+
62
+
63
+ class JSONFormatter(logging.Formatter):
64
+ """JSON log formatter for machine-readable logs."""
65
+
66
+ EXCLUDE_FIELDS = {
67
+ 'args', 'exc_text', 'exc_info', 'stack_info', 'filename', 'funcName',
68
+ 'lineno', 'module', 'msecs', 'pathname', 'process', 'processName',
69
+ 'relativeCreated', 'thread', 'threadName', 'taskName'
70
+ }
71
+
72
+ def format(self, record: logging.LogRecord) -> str:
73
+ """Format a log record as JSON."""
74
+ log_data = {
75
+ 'timestamp': self.formatTime(record, self.datefmt),
76
+ 'level': record.levelname,
77
+ 'logger': record.name,
78
+ 'message': record.getMessage(),
79
+ }
80
+
81
+ # Add exception info if present
82
+ if record.exc_info:
83
+ log_data['exception'] = self.formatException(record.exc_info)
84
+
85
+ # Add extra fields
86
+ for key, value in record.__dict__.items():
87
+ if (key not in self.EXCLUDE_FIELDS and key not in log_data
88
+ and key not in {'name', 'msg', 'levelname', 'levelno', 'created'}):
89
+ log_data[key] = value
90
+
91
+ return json.dumps(log_data)
92
+
93
+
94
+ def setup_logging():
95
+ """Set up logging with structured formatters and rotation."""
96
+ log_dir = get_log_directory()
97
+ log_level = get_log_level()
98
+ retention_days = get_retention_days()
99
+
100
+ # Configure root logger
101
+ root_logger = logging.getLogger()
102
+ root_logger.setLevel(log_level)
103
+ root_logger.handlers.clear() # Remove existing handlers
104
+
105
+ # Human-readable text log
106
+ text_handler = logging.handlers.TimedRotatingFileHandler(
107
+ filename=log_dir / "server.log",
108
+ when='midnight',
109
+ interval=1,
110
+ backupCount=retention_days,
111
+ encoding='utf-8',
112
+ )
113
+ text_handler.setLevel(log_level)
114
+ text_handler.setFormatter(StructuredFormatter(
115
+ '%(asctime)s | %(levelname)s | %(name)s | %(message)s',
116
+ datefmt='%Y-%m-%d %H:%M:%S'
117
+ ))
118
+ text_handler.suffix = "%Y-%m-%d"
119
+ root_logger.addHandler(text_handler)
120
+
121
+ # JSON log
122
+ json_handler = logging.handlers.TimedRotatingFileHandler(
123
+ filename=log_dir / "server.json",
124
+ when='midnight',
125
+ interval=1,
126
+ backupCount=retention_days,
127
+ encoding='utf-8',
128
+ )
129
+ json_handler.setLevel(log_level)
130
+ json_handler.setFormatter(JSONFormatter(datefmt='%Y-%m-%dT%H:%M:%S'))
131
+ json_handler.suffix = "%Y-%m-%d"
132
+ root_logger.addHandler(json_handler)
133
+
134
+ # Console handler for development
135
+ console_handler = logging.StreamHandler()
136
+ console_handler.setLevel(log_level)
137
+ console_handler.setFormatter(StructuredFormatter(
138
+ '%(asctime)s | %(levelname)s | %(name)s | %(message)s',
139
+ datefmt='%Y-%m-%d %H:%M:%S'
140
+ ))
141
+ root_logger.addHandler(console_handler)
142
+
143
+ logging.getLogger(__name__).info(f"Logging initialized: {log_dir}")
144
+