linux-mcp-server 0.1.0a2__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,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
+ root_logger.info(f"Logging initialized: {log_dir}")
@@ -0,0 +1,18 @@
1
+ """Core MCP server for Linux diagnostics using FastMCP."""
2
+
3
+ import logging
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+
8
+ logger = logging.getLogger("linux-mcp-server")
9
+
10
+
11
+ # Initialize FastMCP server
12
+ mcp = FastMCP("linux-diagnostics")
13
+
14
+ from linux_mcp_server.tools import * # noqa: E402, F403
15
+
16
+
17
+ def main():
18
+ mcp.run()
@@ -0,0 +1,51 @@
1
+ # logs
2
+ from linux_mcp_server.tools.logs import get_audit_logs
3
+ from linux_mcp_server.tools.logs import get_journal_logs
4
+ from linux_mcp_server.tools.logs import read_log_file
5
+
6
+ # network
7
+ from linux_mcp_server.tools.network import get_listening_ports
8
+ from linux_mcp_server.tools.network import get_network_connections
9
+ from linux_mcp_server.tools.network import get_network_interfaces
10
+
11
+ # processes
12
+ from linux_mcp_server.tools.processes import get_process_info
13
+ from linux_mcp_server.tools.processes import list_processes
14
+
15
+ # services
16
+ from linux_mcp_server.tools.services import get_service_logs
17
+ from linux_mcp_server.tools.services import get_service_status
18
+ from linux_mcp_server.tools.services import list_services
19
+
20
+ # storage
21
+ from linux_mcp_server.tools.storage import list_block_devices
22
+ from linux_mcp_server.tools.storage import list_directories
23
+
24
+ # system_info
25
+ from linux_mcp_server.tools.system_info import get_cpu_information
26
+ from linux_mcp_server.tools.system_info import get_disk_usage
27
+ from linux_mcp_server.tools.system_info import get_hardware_information
28
+ from linux_mcp_server.tools.system_info import get_memory_information
29
+ from linux_mcp_server.tools.system_info import get_system_information
30
+
31
+
32
+ __all__ = [
33
+ "get_audit_logs",
34
+ "get_cpu_information",
35
+ "get_disk_usage",
36
+ "get_hardware_information",
37
+ "get_journal_logs",
38
+ "get_listening_ports",
39
+ "get_memory_information",
40
+ "get_network_connections",
41
+ "get_network_interfaces",
42
+ "get_process_info",
43
+ "get_service_logs",
44
+ "get_service_status",
45
+ "get_system_information",
46
+ "list_block_devices",
47
+ "list_directories",
48
+ "list_processes",
49
+ "list_services",
50
+ "read_log_file",
51
+ ]
@@ -0,0 +1,221 @@
1
+ """Log and audit tools."""
2
+
3
+ import os
4
+ import typing as t
5
+
6
+ from pathlib import Path
7
+
8
+ from mcp.types import ToolAnnotations
9
+
10
+ from linux_mcp_server.audit import log_tool_call
11
+ from linux_mcp_server.connection.ssh import execute_command
12
+ from linux_mcp_server.server import mcp
13
+ from linux_mcp_server.utils.types import Host
14
+ from linux_mcp_server.utils.types import Username
15
+ from linux_mcp_server.utils.validation import validate_line_count
16
+
17
+
18
+ @mcp.tool(
19
+ title="Get journal logs",
20
+ description="Get systemd journal logs.",
21
+ annotations=ToolAnnotations(readOnlyHint=True),
22
+ )
23
+ @log_tool_call
24
+ async def get_journal_logs(
25
+ unit: t.Annotated[str | None, "Systemd unit name used for filtering."] = None,
26
+ priority: t.Annotated[str | None, "Priority level used for filtering."] = None,
27
+ since: t.Annotated[str | None, "Show entries since specified time."] = None,
28
+ lines: t.Annotated[int, "Number of log lines to retrieve."] = 100,
29
+ host: Host | None = None,
30
+ username: Username | None = None,
31
+ ) -> str:
32
+ """
33
+ Get systemd journal logs.
34
+ """
35
+ try:
36
+ # Validate lines parameter (accepts floats from LLMs)
37
+ lines, _ = validate_line_count(lines, default=100)
38
+
39
+ cmd = ["journalctl", "-n", str(lines), "--no-pager"]
40
+
41
+ if unit:
42
+ cmd.extend(["-u", unit])
43
+
44
+ if priority:
45
+ cmd.extend(["-p", priority])
46
+
47
+ if since:
48
+ cmd.extend(["--since", since])
49
+
50
+ returncode, stdout, stderr = await execute_command(cmd, host=host, username=username)
51
+
52
+ if returncode != 0:
53
+ return f"Error reading journal logs: {stderr}"
54
+
55
+ if not stdout or stdout.strip() == "":
56
+ return "No journal entries found matching the criteria."
57
+
58
+ # Build filter description
59
+ filters = []
60
+ if unit:
61
+ filters.append(f"unit={unit}")
62
+ if priority:
63
+ filters.append(f"priority={priority}")
64
+ if since:
65
+ filters.append(f"since={since}")
66
+
67
+ filter_desc = ", ".join(filters) if filters else "no filters"
68
+
69
+ result = [f"=== Journal Logs (last {lines} entries, {filter_desc}) ===\n"]
70
+ result.append(stdout)
71
+
72
+ return "\n".join(result)
73
+ except FileNotFoundError:
74
+ return "Error: journalctl command not found. This tool requires systemd."
75
+ except Exception as e:
76
+ return f"Error reading journal logs: {str(e)}"
77
+
78
+
79
+ @mcp.tool(
80
+ title="Get audit logs",
81
+ description="Read the system audit logs. This requires root privileges.",
82
+ annotations=ToolAnnotations(readOnlyHint=True),
83
+ )
84
+ @log_tool_call
85
+ async def get_audit_logs(
86
+ lines: t.Annotated[int, "Number of log lines to retrieve."] = 100,
87
+ host: Host | None = None,
88
+ username: Username | None = None,
89
+ ) -> str:
90
+ """
91
+ Get audit logs.
92
+ """
93
+ # Validate lines parameter (accepts floats from LLMs)
94
+ lines, _ = validate_line_count(lines, default=100)
95
+
96
+ audit_log_path = "/var/log/audit/audit.log"
97
+
98
+ try:
99
+ # For local execution, check if file exists
100
+ if not host and not os.path.exists(audit_log_path):
101
+ return f"Audit log file not found at {audit_log_path}. Audit logging may not be enabled."
102
+
103
+ # Use tail to read last N lines
104
+ returncode, stdout, stderr = await execute_command(
105
+ ["tail", "-n", str(lines), audit_log_path],
106
+ host=host,
107
+ username=username,
108
+ )
109
+
110
+ if returncode != 0:
111
+ if "Permission denied" in stderr:
112
+ return f"Permission denied reading audit logs. This tool requires elevated privileges (root) to read {audit_log_path}."
113
+ return f"Error reading audit logs: {stderr}"
114
+
115
+ if not stdout or stdout.strip() == "":
116
+ return "No audit log entries found."
117
+
118
+ result = [f"=== Audit Logs (last {lines} entries) ===\n"]
119
+ result.append(stdout)
120
+
121
+ return "\n".join(result)
122
+ except FileNotFoundError:
123
+ return "Error: tail command not found."
124
+ except Exception as e:
125
+ return f"Error reading audit logs: {str(e)}"
126
+
127
+
128
+ @mcp.tool(
129
+ title="Read log file",
130
+ description="Read a specific log file.",
131
+ annotations=ToolAnnotations(readOnlyHint=True),
132
+ )
133
+ @log_tool_call
134
+ async def read_log_file( # noqa: C901
135
+ log_path: t.Annotated[str, "Path to the log file"],
136
+ lines: t.Annotated[int, "Number of lines to retrieve from the end."] = 100,
137
+ host: Host | None = None,
138
+ username: Username | None = None,
139
+ ) -> str:
140
+ """
141
+ Read a specific log file.
142
+ """
143
+ try:
144
+ # Validate lines parameter (accepts floats from LLMs)
145
+ lines, _ = validate_line_count(lines, default=100)
146
+
147
+ # Get allowed log paths from environment variable
148
+ allowed_paths_env = os.getenv("LINUX_MCP_ALLOWED_LOG_PATHS", "")
149
+
150
+ if not allowed_paths_env:
151
+ return (
152
+ "No log files are allowed. Set LINUX_MCP_ALLOWED_LOG_PATHS environment variable "
153
+ "with comma-separated list of allowed log file paths."
154
+ )
155
+
156
+ allowed_paths = [p.strip() for p in allowed_paths_env.split(",") if p.strip()]
157
+
158
+ # For local execution, validate path
159
+ if not host:
160
+ try:
161
+ requested_path = Path(log_path).resolve()
162
+ except Exception:
163
+ return f"Invalid log file path: {log_path}"
164
+
165
+ # Check if the requested path is in the allowed list
166
+ is_allowed = False
167
+ for allowed_path in allowed_paths:
168
+ try:
169
+ allowed_resolved = Path(allowed_path).resolve()
170
+ if requested_path == allowed_resolved:
171
+ is_allowed = True
172
+ break
173
+ except Exception:
174
+ continue
175
+
176
+ if not is_allowed:
177
+ return (
178
+ f"Access to log file '{log_path}' is not allowed.\n"
179
+ f"Allowed log files: {{', '.join(allowed_paths)}}"
180
+ ) # nofmt
181
+
182
+ # Check if file exists
183
+ if not requested_path.exists():
184
+ return f"Log file not found: {log_path}"
185
+
186
+ if not requested_path.is_file():
187
+ return f"Path is not a file: {log_path}"
188
+
189
+ log_path_str = str(requested_path)
190
+ else:
191
+ # For remote execution, just check against whitelist without resolving
192
+ if log_path not in allowed_paths:
193
+ return (
194
+ f"Access to log file '{log_path}' is not allowed.\n"
195
+ f"Allowed log files: {', '.join(allowed_paths)}"
196
+ ) # nofmt
197
+ log_path_str = log_path
198
+
199
+ # Read the file using tail
200
+ returncode, stdout, stderr = await execute_command(
201
+ ["tail", "-n", str(lines), log_path_str],
202
+ host=host,
203
+ username=username,
204
+ )
205
+
206
+ if returncode != 0:
207
+ if "Permission denied" in stderr:
208
+ return f"Permission denied reading log file: {log_path}"
209
+ return f"Error reading log file: {stderr}"
210
+
211
+ if not stdout or stdout.strip() == "":
212
+ return f"Log file is empty: {log_path}"
213
+
214
+ result = [f"=== Log File: {log_path} (last {lines} lines) ===\n"]
215
+ result.append(stdout)
216
+
217
+ return "\n".join(result)
218
+ except FileNotFoundError:
219
+ return "Error: tail command not found."
220
+ except Exception as e:
221
+ return f"Error reading log file: {str(e)}"