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.
- linux_mcp_server/__init__.py +4 -0
- linux_mcp_server/__main__.py +30 -0
- linux_mcp_server/audit.py +285 -0
- linux_mcp_server/logging_config.py +144 -0
- linux_mcp_server/server.py +328 -0
- linux_mcp_server/tools/__init__.py +2 -0
- linux_mcp_server/tools/logs.py +225 -0
- linux_mcp_server/tools/network.py +281 -0
- linux_mcp_server/tools/processes.py +257 -0
- linux_mcp_server/tools/services.py +147 -0
- linux_mcp_server/tools/ssh_executor.py +324 -0
- linux_mcp_server/tools/storage.py +358 -0
- linux_mcp_server/tools/system_info.py +507 -0
- linux_mcp_server/tools/utils.py +27 -0
- linux_mcp_server/tools/validation.py +58 -0
- linux_mcp_server-0.1.0.dev0.dist-info/METADATA +331 -0
- linux_mcp_server-0.1.0.dev0.dist-info/RECORD +20 -0
- linux_mcp_server-0.1.0.dev0.dist-info/WHEEL +4 -0
- linux_mcp_server-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- linux_mcp_server-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
|