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,328 @@
1
+ """Core MCP server for Linux diagnostics using FastMCP."""
2
+
3
+ import logging
4
+ import time
5
+ from typing import Optional
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from .audit import log_tool_call, log_tool_complete
10
+ from .tools import system_info, services, processes, logs, network, storage
11
+
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ # Initialize FastMCP server
17
+ mcp = FastMCP("linux-diagnostics")
18
+
19
+
20
+ # System Information Tools
21
+ @mcp.tool()
22
+ async def get_system_info(host: Optional[str] = None, username: Optional[str] = None) -> str:
23
+ """Get basic system information including OS version, kernel, hostname, and uptime.
24
+
25
+ Args:
26
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
27
+ username: SSH username for remote host (required if host is provided)
28
+ """
29
+ return await _execute_tool("get_system_info", system_info.get_system_info,
30
+ host=host, username=username)
31
+
32
+
33
+ @mcp.tool()
34
+ async def get_cpu_info(host: Optional[str] = None, username: Optional[str] = None) -> str:
35
+ """Get CPU information and load averages.
36
+
37
+ Args:
38
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
39
+ username: SSH username for remote host (required if host is provided)
40
+ """
41
+ return await _execute_tool("get_cpu_info", system_info.get_cpu_info,
42
+ host=host, username=username)
43
+
44
+
45
+ @mcp.tool()
46
+ async def get_memory_info(host: Optional[str] = None, username: Optional[str] = None) -> str:
47
+ """Get memory usage including RAM and swap details.
48
+
49
+ Args:
50
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
51
+ username: SSH username for remote host (required if host is provided)
52
+ """
53
+ return await _execute_tool("get_memory_info", system_info.get_memory_info,
54
+ host=host, username=username)
55
+
56
+
57
+ @mcp.tool()
58
+ async def get_disk_usage(host: Optional[str] = None, username: Optional[str] = None) -> str:
59
+ """Get filesystem usage and mount points.
60
+
61
+ Args:
62
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
63
+ username: SSH username for remote host (required if host is provided)
64
+ """
65
+ return await _execute_tool("get_disk_usage", system_info.get_disk_usage,
66
+ host=host, username=username)
67
+
68
+
69
+ @mcp.tool()
70
+ async def get_hardware_info(host: Optional[str] = None, username: Optional[str] = None) -> str:
71
+ """Get hardware information including CPU architecture, PCI devices, USB devices, and memory hardware.
72
+
73
+ Args:
74
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
75
+ username: SSH username for remote host (required if host is provided)
76
+ """
77
+ return await _execute_tool("get_hardware_info", system_info.get_hardware_info,
78
+ host=host, username=username)
79
+
80
+
81
+ # Service Management Tools
82
+ @mcp.tool()
83
+ async def list_services(host: Optional[str] = None, username: Optional[str] = None) -> str:
84
+ """List all systemd services with their current status.
85
+
86
+ Args:
87
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
88
+ username: SSH username for remote host (required if host is provided)
89
+ """
90
+ return await _execute_tool("list_services", services.list_services,
91
+ host=host, username=username)
92
+
93
+
94
+ @mcp.tool()
95
+ async def get_service_status(service_name: str, host: Optional[str] = None,
96
+ username: Optional[str] = None) -> str:
97
+ """Get detailed status of a specific systemd service.
98
+
99
+ Args:
100
+ service_name: Name of the service
101
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
102
+ username: SSH username for remote host (required if host is provided)
103
+ """
104
+ return await _execute_tool("get_service_status", services.get_service_status,
105
+ service_name=service_name, host=host, username=username)
106
+
107
+
108
+ @mcp.tool()
109
+ async def get_service_logs(service_name: str, lines: int = 50,
110
+ host: Optional[str] = None, username: Optional[str] = None) -> str:
111
+ """Get recent logs for a specific systemd service.
112
+
113
+ Args:
114
+ service_name: Name of the service
115
+ lines: Number of log lines to retrieve (default: 50)
116
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
117
+ username: SSH username for remote host (required if host is provided)
118
+ """
119
+ return await _execute_tool("get_service_logs", services.get_service_logs,
120
+ service_name=service_name, lines=lines,
121
+ host=host, username=username)
122
+
123
+
124
+ # Process Management Tools
125
+ @mcp.tool()
126
+ async def list_processes(host: Optional[str] = None, username: Optional[str] = None) -> str:
127
+ """List running processes with CPU and memory usage.
128
+
129
+ Args:
130
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
131
+ username: SSH username for remote host (required if host is provided)
132
+ """
133
+ return await _execute_tool("list_processes", processes.list_processes,
134
+ host=host, username=username)
135
+
136
+
137
+ @mcp.tool()
138
+ async def get_process_info(pid: int, host: Optional[str] = None,
139
+ username: Optional[str] = None) -> str:
140
+ """Get detailed information about a specific process.
141
+
142
+ Args:
143
+ pid: Process ID
144
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
145
+ username: SSH username for remote host (required if host is provided)
146
+ """
147
+ return await _execute_tool("get_process_info", processes.get_process_info,
148
+ pid=pid, host=host, username=username)
149
+
150
+
151
+ # Log and Audit Tools
152
+ @mcp.tool()
153
+ async def get_journal_logs(unit: Optional[str] = None, priority: Optional[str] = None,
154
+ since: Optional[str] = None, lines: int = 100,
155
+ host: Optional[str] = None, username: Optional[str] = None) -> str:
156
+ """Query systemd journal logs with optional filters.
157
+
158
+ Args:
159
+ unit: Filter by systemd unit
160
+ priority: Filter by priority (emerg, alert, crit, err, warning, notice, info, debug)
161
+ since: Show entries since specified time (e.g., '1 hour ago', '2024-01-01')
162
+ lines: Number of log lines to retrieve (default: 100)
163
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
164
+ username: SSH username for remote host (required if host is provided)
165
+ """
166
+ return await _execute_tool("get_journal_logs", logs.get_journal_logs,
167
+ unit=unit, priority=priority, since=since, lines=lines,
168
+ host=host, username=username)
169
+
170
+
171
+ @mcp.tool()
172
+ async def get_audit_logs(lines: int = 100, host: Optional[str] = None,
173
+ username: Optional[str] = None) -> str:
174
+ """Get audit logs if available.
175
+
176
+ Args:
177
+ lines: Number of log lines to retrieve (default: 100)
178
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
179
+ username: SSH username for remote host (required if host is provided)
180
+ """
181
+ return await _execute_tool("get_audit_logs", logs.get_audit_logs,
182
+ lines=lines, host=host, username=username)
183
+
184
+
185
+ @mcp.tool()
186
+ async def read_log_file(log_path: str, lines: int = 100,
187
+ host: Optional[str] = None, username: Optional[str] = None) -> str:
188
+ """Read a specific log file (whitelist-controlled via LINUX_MCP_ALLOWED_LOG_PATHS).
189
+
190
+ Args:
191
+ log_path: Path to the log file
192
+ lines: Number of lines to retrieve from the end (default: 100)
193
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
194
+ username: SSH username for remote host (required if host is provided)
195
+ """
196
+ return await _execute_tool("read_log_file", logs.read_log_file,
197
+ log_path=log_path, lines=lines, host=host, username=username)
198
+
199
+
200
+ # Network Tools
201
+ @mcp.tool()
202
+ async def get_network_interfaces(host: Optional[str] = None, username: Optional[str] = None) -> str:
203
+ """Get network interface information including IP addresses.
204
+
205
+ Args:
206
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
207
+ username: SSH username for remote host (required if host is provided)
208
+ """
209
+ return await _execute_tool("get_network_interfaces", network.get_network_interfaces,
210
+ host=host, username=username)
211
+
212
+
213
+ @mcp.tool()
214
+ async def get_network_connections(host: Optional[str] = None, username: Optional[str] = None) -> str:
215
+ """Get active network connections.
216
+
217
+ Args:
218
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
219
+ username: SSH username for remote host (required if host is provided)
220
+ """
221
+ return await _execute_tool("get_network_connections", network.get_network_connections,
222
+ host=host, username=username)
223
+
224
+
225
+ @mcp.tool()
226
+ async def get_listening_ports(host: Optional[str] = None, username: Optional[str] = None) -> str:
227
+ """Get ports that are listening on the system.
228
+
229
+ Args:
230
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
231
+ username: SSH username for remote host (required if host is provided)
232
+ """
233
+ return await _execute_tool("get_listening_ports", network.get_listening_ports,
234
+ host=host, username=username)
235
+
236
+
237
+ # Storage Tools
238
+ @mcp.tool()
239
+ async def list_block_devices(host: Optional[str] = None, username: Optional[str] = None) -> str:
240
+ """List block devices and partitions.
241
+
242
+ Args:
243
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
244
+ username: SSH username for remote host (required if host is provided)
245
+ """
246
+ return await _execute_tool("list_block_devices", storage.list_block_devices,
247
+ host=host, username=username)
248
+
249
+
250
+ @mcp.tool()
251
+ async def list_directories_by_size(path: str, top_n: int,
252
+ host: Optional[str] = None, username: Optional[str] = None) -> str:
253
+ """List directories sorted by size (largest first). Uses efficient Linux du command.
254
+
255
+ Args:
256
+ path: Directory path to analyze
257
+ top_n: Number of top largest directories to return (1-1000)
258
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
259
+ username: SSH username for remote host (required if host is provided)
260
+ """
261
+ return await _execute_tool("list_directories_by_size", storage.list_directories_by_size,
262
+ path=path, top_n=top_n, host=host, username=username)
263
+
264
+
265
+ @mcp.tool()
266
+ async def list_directories_by_name(path: str, reverse: bool = False,
267
+ host: Optional[str] = None, username: Optional[str] = None) -> str:
268
+ """List directories sorted alphabetically by name. Uses efficient Linux find command.
269
+
270
+ Args:
271
+ path: Directory path to analyze
272
+ reverse: Sort in reverse order (Z-A) (default: False)
273
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
274
+ username: SSH username for remote host (required if host is provided)
275
+ """
276
+ return await _execute_tool("list_directories_by_name", storage.list_directories_by_name,
277
+ path=path, reverse=reverse, host=host, username=username)
278
+
279
+
280
+ @mcp.tool()
281
+ async def list_directories_by_modified_date(path: str, newest_first: bool = True,
282
+ host: Optional[str] = None, username: Optional[str] = None) -> str:
283
+ """List directories sorted by modification date. Uses efficient Linux find command.
284
+
285
+ Args:
286
+ path: Directory path to analyze
287
+ newest_first: Show newest first (default: True)
288
+ host: Remote host to connect to via SSH (optional, executes locally if not provided)
289
+ username: SSH username for remote host (required if host is provided)
290
+ """
291
+ return await _execute_tool("list_directories_by_modified_date",
292
+ storage.list_directories_by_modified_date,
293
+ path=path, newest_first=newest_first, host=host, username=username)
294
+
295
+
296
+ async def _execute_tool(tool_name: str, handler, **kwargs):
297
+ """Execute a tool with logging and error handling.
298
+
299
+ Args:
300
+ tool_name: Name of the tool being executed
301
+ handler: The tool function to call
302
+ **kwargs: Arguments to pass to the tool function
303
+ """
304
+ # Log tool invocation
305
+ log_tool_call(tool_name, kwargs)
306
+
307
+ start_time = time.time()
308
+
309
+ try:
310
+ result = await handler(**kwargs)
311
+ duration = time.time() - start_time
312
+ log_tool_complete(tool_name, status="success", duration=duration)
313
+ return result
314
+
315
+ except Exception as e:
316
+ duration = time.time() - start_time
317
+ log_tool_complete(tool_name, status="error", duration=duration, error=str(e))
318
+ raise
319
+
320
+
321
+ def main():
322
+ """Run the MCP server using FastMCP."""
323
+ logger.info(f"Initialized linux-diagnostics v0.1.0")
324
+ logger.info("Starting FastMCP server")
325
+
326
+ # Run the FastMCP server (it creates its own event loop)
327
+ mcp.run()
328
+
@@ -0,0 +1,2 @@
1
+ """Tool modules for Linux diagnostics."""
2
+
@@ -0,0 +1,225 @@
1
+ """Log and audit tools."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from .validation import validate_line_count
8
+ from .ssh_executor import execute_command
9
+
10
+
11
+ async def get_journal_logs(
12
+ unit: str = None,
13
+ priority: str = None,
14
+ since: str = None,
15
+ lines: int = 100,
16
+ host: Optional[str] = None,
17
+ username: Optional[str] = None
18
+ ) -> str:
19
+ """
20
+ Get systemd journal logs.
21
+
22
+ Args:
23
+ unit: Filter by systemd unit
24
+ priority: Filter by priority level
25
+ since: Show entries since specified time
26
+ lines: Number of log lines to retrieve (default: 100)
27
+ host: Optional remote host to connect to
28
+ username: Optional SSH username (required if host is provided)
29
+
30
+ Returns:
31
+ Formatted string with journal logs
32
+ """
33
+ try:
34
+ # Validate lines parameter (accepts floats from LLMs)
35
+ lines, _ = validate_line_count(lines, default=100)
36
+
37
+ cmd = ["journalctl", "-n", str(lines), "--no-pager"]
38
+
39
+ if unit:
40
+ cmd.extend(["-u", unit])
41
+
42
+ if priority:
43
+ cmd.extend(["-p", priority])
44
+
45
+ if since:
46
+ cmd.extend(["--since", since])
47
+
48
+ returncode, stdout, stderr = await execute_command(cmd, host=host, username=username)
49
+
50
+ if returncode != 0:
51
+ return f"Error reading journal logs: {stderr}"
52
+
53
+ if not stdout or stdout.strip() == "":
54
+ return "No journal entries found matching the criteria."
55
+
56
+ # Build filter description
57
+ filters = []
58
+ if unit:
59
+ filters.append(f"unit={unit}")
60
+ if priority:
61
+ filters.append(f"priority={priority}")
62
+ if since:
63
+ filters.append(f"since={since}")
64
+
65
+ filter_desc = ", ".join(filters) if filters else "no filters"
66
+
67
+ result = [f"=== Journal Logs (last {lines} entries, {filter_desc}) ===\n"]
68
+ result.append(stdout)
69
+
70
+ return "\n".join(result)
71
+ except FileNotFoundError:
72
+ return "Error: journalctl command not found. This tool requires systemd."
73
+ except Exception as e:
74
+ return f"Error reading journal logs: {str(e)}"
75
+
76
+
77
+ async def get_audit_logs(
78
+ lines: int = 100,
79
+ host: Optional[str] = None,
80
+ username: Optional[str] = None
81
+ ) -> str:
82
+ """
83
+ Get audit logs.
84
+
85
+ Args:
86
+ lines: Number of log lines to retrieve (default: 100)
87
+ host: Optional remote host to connect to
88
+ username: Optional SSH username (required if host is provided)
89
+
90
+ Returns:
91
+ Formatted string with 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
+ async def read_log_file(
129
+ log_path: str,
130
+ lines: int = 100,
131
+ host: Optional[str] = None,
132
+ username: Optional[str] = None
133
+ ) -> str:
134
+ """
135
+ Read a specific log file.
136
+
137
+ Args:
138
+ log_path: Path to the log file
139
+ lines: Number of lines to retrieve from the end (default: 100)
140
+ host: Optional remote host to connect to
141
+ username: Optional SSH username (required if host is provided)
142
+
143
+ Returns:
144
+ Formatted string with log file contents
145
+ """
146
+ try:
147
+ # Validate lines parameter (accepts floats from LLMs)
148
+ lines, _ = validate_line_count(lines, default=100)
149
+
150
+ # Get allowed log paths from environment variable
151
+ allowed_paths_env = os.getenv("LINUX_MCP_ALLOWED_LOG_PATHS", "")
152
+
153
+ if not allowed_paths_env:
154
+ return (
155
+ "No log files are allowed. Set LINUX_MCP_ALLOWED_LOG_PATHS environment variable "
156
+ "with comma-separated list of allowed log file paths."
157
+ )
158
+
159
+ allowed_paths = [p.strip() for p in allowed_paths_env.split(",") if p.strip()]
160
+
161
+ # For local execution, validate path
162
+ if not host:
163
+ try:
164
+ requested_path = Path(log_path).resolve()
165
+ except Exception:
166
+ return f"Invalid log file path: {log_path}"
167
+
168
+ # Check if the requested path is in the allowed list
169
+ is_allowed = False
170
+ for allowed_path in allowed_paths:
171
+ try:
172
+ allowed_resolved = Path(allowed_path).resolve()
173
+ if requested_path == allowed_resolved:
174
+ is_allowed = True
175
+ break
176
+ except Exception:
177
+ continue
178
+
179
+ if not is_allowed:
180
+ return (
181
+ f"Access to log file '{log_path}' is not allowed.\n"
182
+ f"Allowed log files: {', '.join(allowed_paths)}"
183
+ )
184
+
185
+ # Check if file exists
186
+ if not requested_path.exists():
187
+ return f"Log file not found: {log_path}"
188
+
189
+ if not requested_path.is_file():
190
+ return f"Path is not a file: {log_path}"
191
+
192
+ log_path_str = str(requested_path)
193
+ else:
194
+ # For remote execution, just check against whitelist without resolving
195
+ if log_path not in allowed_paths:
196
+ return (
197
+ f"Access to log file '{log_path}' is not allowed.\n"
198
+ f"Allowed log files: {', '.join(allowed_paths)}"
199
+ )
200
+ log_path_str = log_path
201
+
202
+ # Read the file using tail
203
+ returncode, stdout, stderr = await execute_command(
204
+ ["tail", "-n", str(lines), log_path_str],
205
+ host=host,
206
+ username=username
207
+ )
208
+
209
+ if returncode != 0:
210
+ if "Permission denied" in stderr:
211
+ return f"Permission denied reading log file: {log_path}"
212
+ return f"Error reading log file: {stderr}"
213
+
214
+ if not stdout or stdout.strip() == "":
215
+ return f"Log file is empty: {log_path}"
216
+
217
+ result = [f"=== Log File: {log_path} (last {lines} lines) ===\n"]
218
+ result.append(stdout)
219
+
220
+ return "\n".join(result)
221
+ except FileNotFoundError:
222
+ return "Error: tail command not found."
223
+ except Exception as e:
224
+ return f"Error reading log file: {str(e)}"
225
+