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.
- linux_mcp_server/__init__.py +4 -0
- linux_mcp_server/__main__.py +30 -0
- linux_mcp_server/audit.py +331 -0
- linux_mcp_server/connection/__init__.py +0 -0
- linux_mcp_server/connection/ssh.py +338 -0
- linux_mcp_server/logging_config.py +176 -0
- linux_mcp_server/server.py +18 -0
- linux_mcp_server/tools/__init__.py +51 -0
- linux_mcp_server/tools/logs.py +221 -0
- linux_mcp_server/tools/network.py +288 -0
- linux_mcp_server/tools/processes.py +273 -0
- linux_mcp_server/tools/services.py +149 -0
- linux_mcp_server/tools/storage.py +194 -0
- linux_mcp_server/tools/system_info.py +524 -0
- linux_mcp_server/utils/__init__.py +2 -0
- linux_mcp_server/utils/enum.py +16 -0
- linux_mcp_server/utils/format.py +26 -0
- linux_mcp_server/utils/types.py +5 -0
- linux_mcp_server/utils/validation.py +57 -0
- linux_mcp_server-0.1.0a2.dist-info/METADATA +233 -0
- linux_mcp_server-0.1.0a2.dist-info/RECORD +24 -0
- linux_mcp_server-0.1.0a2.dist-info/WHEEL +4 -0
- linux_mcp_server-0.1.0a2.dist-info/entry_points.txt +2 -0
- linux_mcp_server-0.1.0a2.dist-info/licenses/LICENSE +201 -0
|
@@ -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)}"
|