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,149 @@
|
|
|
1
|
+
"""Service management tools."""
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
|
|
5
|
+
from mcp.types import ToolAnnotations
|
|
6
|
+
|
|
7
|
+
from linux_mcp_server.audit import log_tool_call
|
|
8
|
+
from linux_mcp_server.connection.ssh import execute_command
|
|
9
|
+
from linux_mcp_server.server import mcp
|
|
10
|
+
from linux_mcp_server.utils.types import Host
|
|
11
|
+
from linux_mcp_server.utils.types import Username
|
|
12
|
+
from linux_mcp_server.utils.validation import validate_line_count
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@mcp.tool(
|
|
16
|
+
title="List services",
|
|
17
|
+
description="List all systemd services.",
|
|
18
|
+
annotations=ToolAnnotations(readOnlyHint=True),
|
|
19
|
+
)
|
|
20
|
+
@log_tool_call
|
|
21
|
+
async def list_services(
|
|
22
|
+
host: Host | None = None,
|
|
23
|
+
username: Username | None = None,
|
|
24
|
+
) -> str:
|
|
25
|
+
"""
|
|
26
|
+
List all systemd services.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
# Run systemctl to list all services
|
|
30
|
+
returncode, stdout, stderr = await execute_command(
|
|
31
|
+
["systemctl", "list-units", "--type=service", "--all", "--no-pager"],
|
|
32
|
+
host=host,
|
|
33
|
+
username=username,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if returncode != 0:
|
|
37
|
+
return f"Error listing services: {stderr}"
|
|
38
|
+
|
|
39
|
+
# Format the output
|
|
40
|
+
result = ["=== System Services ===\n"]
|
|
41
|
+
result.append(stdout)
|
|
42
|
+
|
|
43
|
+
# Get summary
|
|
44
|
+
returncode_summary, stdout_summary, _ = await execute_command(
|
|
45
|
+
["systemctl", "list-units", "--type=service", "--state=running", "--no-pager"],
|
|
46
|
+
host=host,
|
|
47
|
+
username=username,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if returncode_summary == 0:
|
|
51
|
+
running_count = len([line for line in stdout_summary.split("\n") if ".service" in line])
|
|
52
|
+
result.append(f"\n\nSummary: {running_count} services currently running")
|
|
53
|
+
|
|
54
|
+
return "\n".join(result)
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
return "Error: systemctl command not found. This tool requires systemd."
|
|
57
|
+
except Exception as e:
|
|
58
|
+
return f"Error listing services: {str(e)}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@mcp.tool(
|
|
62
|
+
title="Get service status",
|
|
63
|
+
description="Get detailed status of a specific systemd service.",
|
|
64
|
+
annotations=ToolAnnotations(readOnlyHint=True),
|
|
65
|
+
)
|
|
66
|
+
@log_tool_call
|
|
67
|
+
async def get_service_status(
|
|
68
|
+
service_name: t.Annotated[str, "Name of the service"],
|
|
69
|
+
host: Host | None = None,
|
|
70
|
+
username: Username | None = None,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Get status of a specific service.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
# Ensure service name has .service suffix if not present
|
|
77
|
+
if not service_name.endswith(".service") and "." not in service_name:
|
|
78
|
+
service_name = f"{service_name}.service"
|
|
79
|
+
|
|
80
|
+
# Run systemctl status
|
|
81
|
+
_, stdout, stderr = await execute_command(
|
|
82
|
+
["systemctl", "status", service_name, "--no-pager", "--full"],
|
|
83
|
+
host=host,
|
|
84
|
+
username=username,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Note: systemctl status returns non-zero for inactive services, but that's expected
|
|
88
|
+
if not stdout and stderr:
|
|
89
|
+
# Service not found
|
|
90
|
+
if "not found" in stderr.lower() or "could not be found" in stderr.lower():
|
|
91
|
+
return f"Service '{service_name}' not found on this system."
|
|
92
|
+
return f"Error getting service status: {stderr}"
|
|
93
|
+
|
|
94
|
+
result = [f"=== Status of {service_name} ===\n"]
|
|
95
|
+
result.append(stdout)
|
|
96
|
+
|
|
97
|
+
return "\n".join(result)
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
return "Error: systemctl command not found. This tool requires systemd."
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return f"Error getting service status: {str(e)}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@mcp.tool(
|
|
105
|
+
title="Get service logs",
|
|
106
|
+
description="Get recent logs for a specific systemd service.",
|
|
107
|
+
annotations=ToolAnnotations(readOnlyHint=True),
|
|
108
|
+
)
|
|
109
|
+
@log_tool_call
|
|
110
|
+
async def get_service_logs(
|
|
111
|
+
service_name: t.Annotated[str, "Name of the service"],
|
|
112
|
+
lines: t.Annotated[int, "Number of log lines to retrieve."] = 50,
|
|
113
|
+
host: Host | None = None,
|
|
114
|
+
username: Username | None = None,
|
|
115
|
+
) -> str:
|
|
116
|
+
"""
|
|
117
|
+
Get logs for a specific service.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
# Validate lines parameter (accepts floats from LLMs)
|
|
121
|
+
lines, _ = validate_line_count(lines, default=50)
|
|
122
|
+
|
|
123
|
+
# Ensure service name has .service suffix if not present
|
|
124
|
+
if not service_name.endswith(".service") and "." not in service_name:
|
|
125
|
+
service_name = f"{service_name}.service"
|
|
126
|
+
|
|
127
|
+
# Run journalctl for the service
|
|
128
|
+
returncode, stdout, stderr = await execute_command(
|
|
129
|
+
["journalctl", "-u", service_name, "-n", str(lines), "--no-pager"],
|
|
130
|
+
host=host,
|
|
131
|
+
username=username,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if returncode != 0:
|
|
135
|
+
if "not found" in stderr.lower() or "no entries" in stderr.lower():
|
|
136
|
+
return f"No logs found for service '{service_name}'. The service may not exist or has no log entries."
|
|
137
|
+
return f"Error getting service logs: {stderr}"
|
|
138
|
+
|
|
139
|
+
if not stdout or stdout.strip() == "":
|
|
140
|
+
return f"No log entries found for service '{service_name}'."
|
|
141
|
+
|
|
142
|
+
result = [f"=== Last {lines} log entries for {service_name} ===\n"]
|
|
143
|
+
result.append(stdout)
|
|
144
|
+
|
|
145
|
+
return "\n".join(result)
|
|
146
|
+
except FileNotFoundError:
|
|
147
|
+
return "Error: journalctl command not found. This tool requires systemd."
|
|
148
|
+
except Exception as e:
|
|
149
|
+
return f"Error getting service logs: {str(e)}"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Storage and hardware tools."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import typing as t
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import psutil
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
11
|
+
from mcp.types import ToolAnnotations
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from linux_mcp_server.audit import log_tool_call
|
|
16
|
+
from linux_mcp_server.connection.ssh import execute_command
|
|
17
|
+
from linux_mcp_server.server import mcp
|
|
18
|
+
from linux_mcp_server.utils import format_bytes
|
|
19
|
+
from linux_mcp_server.utils import StrEnum
|
|
20
|
+
from linux_mcp_server.utils.types import Host
|
|
21
|
+
from linux_mcp_server.utils.types import Username
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DirectoryEntry(BaseModel):
|
|
25
|
+
size: int = 0
|
|
26
|
+
modified: float = 0.0
|
|
27
|
+
name: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OrderBy(StrEnum):
|
|
31
|
+
SIZE = "size"
|
|
32
|
+
NAME = "name"
|
|
33
|
+
MODIFIED = "modified"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SortBy(StrEnum):
|
|
37
|
+
ASCENDING = "ascending"
|
|
38
|
+
DESCENDING = "descending"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@mcp.tool(
|
|
42
|
+
title="List block devices",
|
|
43
|
+
description="List block devices on the system",
|
|
44
|
+
annotations=ToolAnnotations(readOnlyHint=True),
|
|
45
|
+
)
|
|
46
|
+
@log_tool_call
|
|
47
|
+
async def list_block_devices(
|
|
48
|
+
host: Host | None = None,
|
|
49
|
+
username: Username | None = None,
|
|
50
|
+
) -> str:
|
|
51
|
+
"""
|
|
52
|
+
List block devices.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
# Try using lsblk first (most readable)
|
|
56
|
+
returncode, stdout, _ = await execute_command(
|
|
57
|
+
["lsblk", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,MODEL", "--no-pager"],
|
|
58
|
+
host=host,
|
|
59
|
+
username=username,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if returncode == 0:
|
|
63
|
+
result = ["=== Block Devices ===\n"]
|
|
64
|
+
result.append(stdout)
|
|
65
|
+
|
|
66
|
+
# Add disk I/O per-disk stats if available (only for local execution)
|
|
67
|
+
if not host:
|
|
68
|
+
try:
|
|
69
|
+
disk_io_per_disk = psutil.disk_io_counters(perdisk=True)
|
|
70
|
+
if disk_io_per_disk:
|
|
71
|
+
result.append("\n=== Disk I/O Statistics (per disk) ===")
|
|
72
|
+
for disk, stats in sorted(disk_io_per_disk.items()):
|
|
73
|
+
result.append(f"\n{disk}:")
|
|
74
|
+
result.append(f" Read: {format_bytes(stats.read_bytes)}")
|
|
75
|
+
result.append(f" Write: {format_bytes(stats.write_bytes)}")
|
|
76
|
+
result.append(f" Read Count: {stats.read_count}")
|
|
77
|
+
result.append(f" Write Count: {stats.write_count}")
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
return "\n".join(result)
|
|
82
|
+
else:
|
|
83
|
+
# Fallback to listing partitions with psutil
|
|
84
|
+
result = ["=== Block Devices (fallback) ===\n"]
|
|
85
|
+
partitions = psutil.disk_partitions(all=True)
|
|
86
|
+
|
|
87
|
+
for partition in partitions:
|
|
88
|
+
result.append(f"\nDevice: {partition.device}")
|
|
89
|
+
result.append(f" Mountpoint: {partition.mountpoint}")
|
|
90
|
+
result.append(f" Filesystem: {partition.fstype}")
|
|
91
|
+
result.append(f" Options: {partition.opts}")
|
|
92
|
+
|
|
93
|
+
return "\n".join(result)
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
# If lsblk is not available, use psutil
|
|
96
|
+
result = ["=== Block Devices ===\n"]
|
|
97
|
+
partitions = psutil.disk_partitions(all=True)
|
|
98
|
+
|
|
99
|
+
for partition in partitions:
|
|
100
|
+
result.append(f"\nDevice: {partition.device}")
|
|
101
|
+
result.append(f" Mountpoint: {partition.mountpoint}")
|
|
102
|
+
result.append(f" Filesystem: {partition.fstype}")
|
|
103
|
+
result.append(f" Options: {partition.opts}")
|
|
104
|
+
|
|
105
|
+
return "\n".join(result)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
return f"Error listing block devices: {str(e)}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@mcp.tool(
|
|
111
|
+
title="List directories",
|
|
112
|
+
description="List directories under a specified path with flexible sorting options.",
|
|
113
|
+
annotations=ToolAnnotations(readOnlyHint=True),
|
|
114
|
+
)
|
|
115
|
+
@log_tool_call
|
|
116
|
+
async def list_directories( # noqa: C901
|
|
117
|
+
path: t.Annotated[str, "The directory path to analyze"],
|
|
118
|
+
order_by: t.Annotated[OrderBy, "Sort order - 'size', 'name', or 'modified' (default: 'name')"] = OrderBy.NAME,
|
|
119
|
+
sort: t.Annotated[SortBy, "Sort direction - 'ascending' or 'descending' (default: 'ascending')"] = SortBy.ASCENDING,
|
|
120
|
+
top_n: t.Annotated[
|
|
121
|
+
int | None,
|
|
122
|
+
Field(
|
|
123
|
+
description="Optional limit on number of directories to return (1-1000, only used with size ordering)",
|
|
124
|
+
gt=0,
|
|
125
|
+
le=1_000,
|
|
126
|
+
),
|
|
127
|
+
] = None,
|
|
128
|
+
host: Host | None = None,
|
|
129
|
+
username: Username | None = None,
|
|
130
|
+
) -> t.Annotated[
|
|
131
|
+
list[DirectoryEntry],
|
|
132
|
+
"List of directories with size or modified timestamp",
|
|
133
|
+
]:
|
|
134
|
+
# For local execution, validate path
|
|
135
|
+
if not host:
|
|
136
|
+
try:
|
|
137
|
+
path_obj = Path(path).resolve(strict=True)
|
|
138
|
+
except (OSError, RuntimeError):
|
|
139
|
+
raise ToolError(f"Path does not exist or cannot be resolved: {path}")
|
|
140
|
+
|
|
141
|
+
if not path_obj.is_dir():
|
|
142
|
+
raise ToolError(f"Path is not a directory: {path}")
|
|
143
|
+
|
|
144
|
+
if not os.access(path_obj, os.R_OK):
|
|
145
|
+
raise ToolError(f"Permission denied to read directory: {path}")
|
|
146
|
+
|
|
147
|
+
path = str(path_obj)
|
|
148
|
+
|
|
149
|
+
match order_by:
|
|
150
|
+
case OrderBy.SIZE:
|
|
151
|
+
# Use du command to get directory sizes efficiently
|
|
152
|
+
command = ["du", "-b", "--max-depth=1", path]
|
|
153
|
+
case OrderBy.NAME:
|
|
154
|
+
# Use find to list only immediate subdirectories
|
|
155
|
+
command = ["find", path, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\\n"]
|
|
156
|
+
case OrderBy.MODIFIED: # pragma: no branch
|
|
157
|
+
# Use find with modification time
|
|
158
|
+
command = ["find", path, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%T@\\t%f\\n"]
|
|
159
|
+
|
|
160
|
+
returncode, stdout, _ = await execute_command(
|
|
161
|
+
command,
|
|
162
|
+
host=host,
|
|
163
|
+
username=username,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if returncode != 0:
|
|
167
|
+
raise ToolError(f"Error running {command[0]} command: command failed with return code {returncode}")
|
|
168
|
+
|
|
169
|
+
lines = [line.strip() for line in stdout.strip().splitlines() if line]
|
|
170
|
+
|
|
171
|
+
match order_by:
|
|
172
|
+
case OrderBy.SIZE:
|
|
173
|
+
directories = [
|
|
174
|
+
DirectoryEntry(size=int(size), name=Path(dir_path_str).name)
|
|
175
|
+
for line in lines
|
|
176
|
+
for size, dir_path_str in [line.split("\t", 1)]
|
|
177
|
+
if dir_path_str != path
|
|
178
|
+
]
|
|
179
|
+
case OrderBy.NAME:
|
|
180
|
+
directories = [DirectoryEntry(name=line) for line in lines]
|
|
181
|
+
case OrderBy.MODIFIED: # pragma: no branch
|
|
182
|
+
directories = [
|
|
183
|
+
DirectoryEntry(modified=float(timestamp), name=dir_name)
|
|
184
|
+
for line in lines
|
|
185
|
+
for timestamp, dir_name in [line.split("\t", 1)]
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
# Sort by the order_by field
|
|
189
|
+
directories.sort(key=lambda x: getattr(x, order_by), reverse=sort == SortBy.DESCENDING)
|
|
190
|
+
|
|
191
|
+
if top_n:
|
|
192
|
+
return directories[:top_n]
|
|
193
|
+
|
|
194
|
+
return directories
|