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,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