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