From 92cdbf24222e9cd46468a8512f176db122d41b17 Mon Sep 17 00:00:00 2001 From: Rodolfo Olivieri Date: Thu, 18 Dec 2025 11:45:22 -0300 Subject: [PATCH 01/34] Remove --no-pager option from lsblk (#129) --no-pager is not a valid parameter for lsblk, which causes an error when executing for remote and local devices --- src/linux_mcp_server/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/linux_mcp_server/commands.py b/src/linux_mcp_server/commands.py index f8bc3f3b..22d7129d 100644 --- a/src/linux_mcp_server/commands.py +++ b/src/linux_mcp_server/commands.py @@ -124,7 +124,7 @@ class CommandGroup(BaseModel): # === Storage === "list_block_devices": CommandGroup( commands={ - "default": CommandSpec(args=("lsblk", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,MODEL", "--no-pager")), + "default": CommandSpec(args=("lsblk", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,MODEL")), } ), "disk_usage": CommandGroup( From eef99ba3456ca19dd5e2e884700826d979874049 Mon Sep 17 00:00:00 2001 From: Link Dupont Date: Thu, 18 Dec 2025 12:27:03 -0500 Subject: [PATCH 02/34] Refactor tools and CommandSpec running (#121) Add a method on CommandSpec (run) that performs the execute_command and fallback handling. Update the tools to call the CommandSpec.run method instead of execute_command directly. --- src/linux_mcp_server/commands.py | 59 ++++++++-------- src/linux_mcp_server/tools/logs.py | 18 ++--- src/linux_mcp_server/tools/network.py | 25 ++----- src/linux_mcp_server/tools/processes.py | 12 +--- src/linux_mcp_server/tools/services.py | 17 ++--- src/linux_mcp_server/tools/storage.py | 17 ++--- src/linux_mcp_server/tools/system_info.py | 16 ++--- tests/conftest.py | 18 ++--- tests/tools/test_logs.py | 86 ++++++++++++----------- tests/tools/test_network.py | 6 +- tests/tools/test_processes.py | 49 ++++++------- tests/tools/test_services.py | 32 +++++---- tests/tools/test_storage.py | 73 +++++++++++-------- 13 files changed, 190 insertions(+), 238 deletions(-) diff --git a/src/linux_mcp_server/commands.py b/src/linux_mcp_server/commands.py index 22d7129d..fd891c82 100644 --- a/src/linux_mcp_server/commands.py +++ b/src/linux_mcp_server/commands.py @@ -11,6 +11,8 @@ from pydantic import BaseModel from pydantic import ConfigDict +from linux_mcp_server.connection.ssh import execute_with_fallback + class CommandSpec(BaseModel): """Specification for a single command with optional fallback. @@ -18,12 +20,31 @@ class CommandSpec(BaseModel): Attributes: args: Command arguments as a tuple of strings. fallback: Alternative command arguments if primary fails. + optional_flags: Maps parameter names to flag arguments that are added + when the parameter is truthy. For example: + {"unit": ["--unit", "{unit}"]} adds "--unit " when unit is provided. """ model_config = ConfigDict(frozen=True) args: tuple[str, ...] fallback: tuple[str, ...] | None = None + optional_flags: Mapping[str, tuple[str, ...]] | None = None + + async def run(self, host: str | None = None, **kwargs) -> tuple[int, str, str]: + """Run the command with optional fallback. + + Args: + host: Optional remote host address. + **kwargs: Additional arguments passed to substitute_command_args. + """ + args = list(substitute_command_args(self.args, **kwargs)) + if self.optional_flags: + for param_name, flag_args in self.optional_flags.items(): + if kwargs.get(param_name): + args.extend(substitute_command_args(flag_args, **kwargs)) + + return await execute_with_fallback(tuple(args), fallback=self.fallback, host=host) class CommandGroup(BaseModel): @@ -94,7 +115,14 @@ class CommandGroup(BaseModel): # === Logs === "journal_logs": CommandGroup( commands={ - "default": CommandSpec(args=("journalctl", "-n", "{lines}", "--no-pager")), + "default": CommandSpec( + args=("journalctl", "-n", "{lines}", "--no-pager"), + optional_flags={ + "unit": ("--unit", "{unit}"), + "priority": ("--priority", "{priority}"), + "since": ("--since", "{since}"), + }, + ), } ), "audit_logs": CommandGroup( @@ -288,32 +316,3 @@ def substitute_command_args(args: Sequence[str], **kwargs) -> tuple[str, ...]: raise ValueError(f"Unsubstituted placeholder in command argument: {arg}") return result - - -def build_journal_command( - lines: int, - unit: str | None = None, - priority: str | None = None, - since: str | None = None, -) -> tuple[str, ...]: - """Build journalctl command with optional flags. - - Args: - lines: Number of lines to retrieve. - unit: Optional service unit filter. - priority: Optional priority filter (e.g., "err", "warning"). - since: Optional time filter (e.g., "1 hour ago", "today"). - - Returns: - Complete journalctl command arguments. - """ - cmd = ["journalctl", "-n", str(lines), "--no-pager"] - - if unit: - cmd.extend(["--unit", unit]) - if priority: - cmd.extend(["--priority", priority]) - if since: - cmd.extend(["--since", since]) - - return tuple(cmd) diff --git a/src/linux_mcp_server/tools/logs.py b/src/linux_mcp_server/tools/logs.py index d3e62fae..f65405c8 100644 --- a/src/linux_mcp_server/tools/logs.py +++ b/src/linux_mcp_server/tools/logs.py @@ -9,11 +9,8 @@ from pydantic import Field from linux_mcp_server.audit import log_tool_call -from linux_mcp_server.commands import build_journal_command from linux_mcp_server.commands import get_command -from linux_mcp_server.commands import substitute_command_args from linux_mcp_server.config import CONFIG -from linux_mcp_server.connection.ssh import execute_command from linux_mcp_server.formatters import format_audit_logs from linux_mcp_server.formatters import format_journal_logs from linux_mcp_server.formatters import format_log_file @@ -56,10 +53,9 @@ async def get_journal_logs( # Validate lines parameter (accepts floats from LLMs) lines, _ = validate_line_count(lines, default=100) - # Build command with optional filters - cmd = build_journal_command(lines, unit=unit, priority=priority, since=since) - - returncode, stdout, stderr = await execute_command(cmd, host=host) + # Get command from registry + cmd = get_command("journal_logs") + returncode, stdout, stderr = await cmd.run(host=host, lines=lines, unit=unit, priority=priority, since=since) if returncode != 0: return f"Error reading journal logs: {stderr}" @@ -99,9 +95,7 @@ async def get_audit_logs( return f"Audit log file not found at {audit_log_path}. Audit logging may not be enabled." cmd = get_command("audit_logs") - args = substitute_command_args(cmd.args, lines=lines) - - returncode, stdout, stderr = await execute_command(args, host=host) + returncode, stdout, stderr = await cmd.run(host=host, lines=lines) if returncode != 0: if "Permission denied" in stderr: @@ -190,9 +184,7 @@ async def read_log_file( # noqa: C901 log_path_str = log_path cmd = get_command("read_log_file") - args = substitute_command_args(cmd.args, lines=lines, log_path=log_path_str) - - returncode, stdout, stderr = await execute_command(args, host=host) + returncode, stdout, stderr = await cmd.run(host=host, lines=lines, log_path=log_path_str) if returncode != 0: if "Permission denied" in stderr: diff --git a/src/linux_mcp_server/tools/network.py b/src/linux_mcp_server/tools/network.py index ae188eab..bdf13471 100644 --- a/src/linux_mcp_server/tools/network.py +++ b/src/linux_mcp_server/tools/network.py @@ -4,7 +4,6 @@ from linux_mcp_server.audit import log_tool_call from linux_mcp_server.commands import get_command -from linux_mcp_server.connection.ssh import execute_with_fallback from linux_mcp_server.formatters import format_listening_ports from linux_mcp_server.formatters import format_network_connections from linux_mcp_server.formatters import format_network_interfaces @@ -36,22 +35,14 @@ async def get_network_interfaces( # Get brief interface info brief_cmd = get_command("network_interfaces", "brief") - returncode, stdout, _ = await execute_with_fallback( - brief_cmd.args, - fallback=brief_cmd.fallback, - host=host, - ) + returncode, stdout, _ = await brief_cmd.run(host=host) if returncode == 0 and stdout: interfaces = parse_ip_brief(stdout) # Get network statistics from /proc/net/dev stats_cmd = get_command("network_interfaces", "stats") - returncode, stdout, _ = await execute_with_fallback( - stats_cmd.args, - fallback=stats_cmd.fallback, - host=host, - ) + returncode, stdout, _ = await stats_cmd.run(host=host) if returncode == 0 and stdout: stats = parse_proc_net_dev(stdout) @@ -77,11 +68,7 @@ async def get_network_connections( try: cmd = get_command("network_connections") - returncode, stdout, stderr = await execute_with_fallback( - cmd.args, - fallback=cmd.fallback, - host=host, - ) + returncode, stdout, stderr = await cmd.run(host=host) if returncode == 0 and stdout: connections = parse_ss_connections(stdout) @@ -107,11 +94,7 @@ async def get_listening_ports( try: cmd = get_command("listening_ports") - returncode, stdout, stderr = await execute_with_fallback( - cmd.args, - fallback=cmd.fallback, - host=host, - ) + returncode, stdout, stderr = await cmd.run(host=host) if returncode == 0 and stdout: ports = parse_ss_listening(stdout) diff --git a/src/linux_mcp_server/tools/processes.py b/src/linux_mcp_server/tools/processes.py index 03df3a4e..f203fff2 100644 --- a/src/linux_mcp_server/tools/processes.py +++ b/src/linux_mcp_server/tools/processes.py @@ -7,8 +7,6 @@ from linux_mcp_server.audit import log_tool_call from linux_mcp_server.commands import get_command -from linux_mcp_server.commands import substitute_command_args -from linux_mcp_server.connection.ssh import execute_command from linux_mcp_server.formatters import format_process_detail from linux_mcp_server.formatters import format_process_list from linux_mcp_server.parsers import parse_proc_status @@ -31,7 +29,7 @@ async def list_processes( ) -> str: try: cmd = get_command("list_processes") - returncode, stdout, _ = await execute_command(cmd.args, host=host) + returncode, stdout, _ = await cmd.run(host=host) if returncode == 0 and stdout: processes = parse_ps_output(stdout) @@ -64,9 +62,7 @@ async def get_process_info( try: # Get process details with ps ps_cmd = get_command("process_info", "ps_detail") - args = substitute_command_args(ps_cmd.args, pid=validated_pid) - - returncode, stdout, _ = await execute_command(args, host=host) + returncode, stdout, _ = await ps_cmd.run(host=host, pid=validated_pid) if returncode != 0: return f"Process with PID {validated_pid} does not exist." @@ -77,9 +73,7 @@ async def get_process_info( # Try to get more details from /proc proc_status = None status_cmd = get_command("process_info", "proc_status") - status_args = substitute_command_args(status_cmd.args, pid=validated_pid) - - status_code, status_stdout, _ = await execute_command(status_args, host=host) + status_code, status_stdout, _ = await status_cmd.run(host=host, pid=validated_pid) if status_code == 0 and status_stdout: proc_status = parse_proc_status(status_stdout) diff --git a/src/linux_mcp_server/tools/services.py b/src/linux_mcp_server/tools/services.py index 4902da8c..462c3bdd 100644 --- a/src/linux_mcp_server/tools/services.py +++ b/src/linux_mcp_server/tools/services.py @@ -7,8 +7,6 @@ from linux_mcp_server.audit import log_tool_call from linux_mcp_server.commands import get_command -from linux_mcp_server.commands import substitute_command_args -from linux_mcp_server.connection.ssh import execute_command from linux_mcp_server.formatters import format_service_logs from linux_mcp_server.formatters import format_service_status from linux_mcp_server.formatters import format_services_list @@ -34,17 +32,14 @@ async def list_services( """ try: cmd = get_command("list_services") - returncode, stdout, stderr = await execute_command(cmd.args, host=host) + returncode, stdout, stderr = await cmd.run(host=host) if returncode != 0: return f"Error listing services: {stderr}" # Get running services count running_cmd = get_command("running_services") - returncode_summary, stdout_summary, _ = await execute_command( - running_cmd.args, - host=host, - ) + returncode_summary, stdout_summary, _ = await running_cmd.run(host=host) running_count = None if returncode_summary == 0: @@ -77,9 +72,7 @@ async def get_service_status( service_name = f"{service_name}.service" cmd = get_command("service_status") - args = substitute_command_args(cmd.args, service_name=service_name) - - _, stdout, stderr = await execute_command(args, host=host) + _, stdout, stderr = await cmd.run(host=host, service_name=service_name) # Note: systemctl status returns non-zero for inactive services, but that's expected if not stdout and stderr: @@ -119,9 +112,7 @@ async def get_service_logs( service_name = f"{service_name}.service" cmd = get_command("service_logs") - args = substitute_command_args(cmd.args, service_name=service_name, lines=lines) - - returncode, stdout, stderr = await execute_command(args, host=host) + returncode, stdout, stderr = await cmd.run(host=host, service_name=service_name, lines=lines) if returncode != 0: if "not found" in stderr.lower() or "no entries" in stderr.lower(): diff --git a/src/linux_mcp_server/tools/storage.py b/src/linux_mcp_server/tools/storage.py index ecc7d134..caf61a66 100644 --- a/src/linux_mcp_server/tools/storage.py +++ b/src/linux_mcp_server/tools/storage.py @@ -11,8 +11,6 @@ from linux_mcp_server.audit import log_tool_call from linux_mcp_server.commands import get_command -from linux_mcp_server.commands import substitute_command_args -from linux_mcp_server.connection.ssh import execute_command from linux_mcp_server.formatters import format_block_devices from linux_mcp_server.formatters import format_directory_listing from linux_mcp_server.formatters import format_file_listing @@ -75,7 +73,7 @@ async def list_block_devices( """ try: cmd = get_command("list_block_devices") - returncode, stdout, _ = await execute_command(cmd.args, host=host) + returncode, stdout, _ = await cmd.run(host=host) if returncode == 0 and stdout: return format_block_devices(stdout) @@ -122,11 +120,8 @@ async def list_directories( cmd_name = DIRECTORY_COMMANDS[order_by] cmd = get_command(cmd_name) - # Substitute path into command args - args = substitute_command_args(cmd.args, path=path) - try: - returncode, stdout, stderr = await execute_command(args, host=host) + returncode, stdout, stderr = await cmd.run(host=host, path=path) if returncode != 0: raise ToolError(f"Error running command: command failed with return code {returncode}: {stderr}") @@ -188,11 +183,8 @@ async def list_files( cmd_name = FILE_COMMANDS[order_by] cmd = get_command(cmd_name) - # Substitute path into command args - args = substitute_command_args(cmd.args, path=path) - try: - returncode, stdout, stderr = await execute_command(args, host=host) + returncode, stdout, stderr = await cmd.run(host=host, path=path) if returncode != 0: raise ToolError(f"Error running command: command failed with return code {returncode}: {stderr}") @@ -240,10 +232,9 @@ async def read_file( raise ToolError(f"Path is not a file: {path}") cmd = get_command("read_file") - args = substitute_command_args(cmd.args, path=path) try: - returncode, stdout, stderr = await execute_command(args, host=host) + returncode, stdout, stderr = await cmd.run(host=host, path=path) if returncode != 0: raise ToolError(f"Error running command: command failed with return code {returncode}: {stderr}") diff --git a/src/linux_mcp_server/tools/system_info.py b/src/linux_mcp_server/tools/system_info.py index 663bbbdb..9bc44ad8 100644 --- a/src/linux_mcp_server/tools/system_info.py +++ b/src/linux_mcp_server/tools/system_info.py @@ -5,8 +5,6 @@ from linux_mcp_server.audit import log_tool_call from linux_mcp_server.commands import get_command from linux_mcp_server.commands import get_command_group -from linux_mcp_server.connection.ssh import execute_command -from linux_mcp_server.connection.ssh import execute_with_fallback from linux_mcp_server.formatters import format_cpu_info from linux_mcp_server.formatters import format_disk_usage from linux_mcp_server.formatters import format_hardware_info @@ -39,7 +37,7 @@ async def get_system_information( # Execute all commands in the group for name, cmd in group.commands.items(): - returncode, stdout, _ = await execute_command(cmd.args, host=host) + returncode, stdout, _ = await cmd.run(host=host) if returncode == 0 and stdout: results[name] = stdout @@ -68,7 +66,7 @@ async def get_cpu_information( # Execute all commands in the group for name, cmd in group.commands.items(): - returncode, stdout, _ = await execute_command(cmd.args, host=host) + returncode, stdout, _ = await cmd.run(host=host) if returncode == 0 and stdout: results[name] = stdout @@ -94,7 +92,7 @@ async def get_memory_information( try: # Execute free command free_cmd = get_command("memory_info", "free") - returncode, stdout, _ = await execute_command(free_cmd.args, host=host) + returncode, stdout, _ = await free_cmd.run(host=host) if returncode == 0 and stdout: memory = parse_free_output(stdout) @@ -121,11 +119,7 @@ async def get_disk_usage( try: cmd = get_command("disk_usage") - returncode, stdout, _ = await execute_with_fallback( - cmd.args, - fallback=cmd.fallback, - host=host, - ) + returncode, stdout, _ = await cmd.run(host=host) if returncode == 0 and stdout: return format_disk_usage(stdout) @@ -155,7 +149,7 @@ async def get_hardware_information( # Execute all commands in the group for name, cmd in group.commands.items(): try: - returncode, stdout, stderr = await execute_command(cmd.args, host=host) + returncode, stdout, stderr = await cmd.run(host=host) if returncode == 0: results[name] = stdout except FileNotFoundError: diff --git a/tests/conftest.py b/tests/conftest.py index 998b0509..cc7cd1f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,26 +49,26 @@ async def list_services(*args, **kwargs): @pytest.fixture -def mock_execute_command_for(mocker): - """Factory fixture for mocking execute_command in any module. +def mock_execute_with_fallback_for(mocker): + """Factory fixture for mocking execute_with_fallback in any module. - Returns a callable that creates mocks for execute_command in the specified module. + Returns a callable that creates mocks for execute_with_fallback in the specified module. Uses autospec=True to verify arguments match the real function signature. Usage: @pytest.fixture - def mock_execute_command(mock_execute_command_for): - return mock_execute_command_for("linux_mcp_server.tools.mymodule") + def mock_execute_with_fallback(mock_execute_with_fallback_for): + return mock_execute_with_fallback_for("linux_mcp_server.commands") - async def test_something(mock_execute_command): - mock_execute_command.return_value = (0, "output", "") + async def test_something(mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "output", "") # ... test code ... - mock_execute_command.assert_called_once() + mock_execute_with_fallback.assert_called_once() """ def _mock(module: str): return mocker.patch( - f"{module}.execute_command", + f"{module}.execute_with_fallback", autospec=True, ) diff --git a/tests/tools/test_logs.py b/tests/tools/test_logs.py index a96e062d..197be468 100644 --- a/tests/tools/test_logs.py +++ b/tests/tools/test_logs.py @@ -6,9 +6,9 @@ @pytest.fixture -def mock_execute_command(mock_execute_command_for): - """Logs-specific execute_command mock using the shared factory.""" - return mock_execute_command_for("linux_mcp_server.tools.logs") +def mock_execute_with_fallback(mock_execute_with_fallback_for): + """Logs-specific execute_with_fallback mock using the shared factory.""" + return mock_execute_with_fallback_for("linux_mcp_server.commands") @pytest.fixture @@ -68,10 +68,10 @@ class TestGetJournalLogs: ], ) async def test_get_journal_logs_filters( - self, mcp_client, mock_execute_command, params, expected_args, expected_content + self, mcp_client, mock_execute_with_fallback, params, expected_args, expected_content ): """Test get_journal_logs with various filter combinations.""" - mock_execute_command.return_value = ( + mock_execute_with_fallback.return_value = ( 0, "Jan 01 12:00:00 host systemd[1]: Test log entry.", "", @@ -80,10 +80,10 @@ async def test_get_journal_logs_filters( result = await mcp_client.call_tool("get_journal_logs", params) result_text = result.content[0].text.casefold() expected_content.append("test log entry") - cmd_args = mock_execute_command.call_args.args[0] + cmd_args = mock_execute_with_fallback.call_args.args[0] assert all(content in result_text for content in expected_content), "Did not find all expected values" - assert mock_execute_command.call_count == 1 + assert mock_execute_with_fallback.call_count == 1 assert cmd_args[0] == "journalctl" assert all(arg in cmd_args for arg in expected_args), "Did not find all expected arguments" @@ -97,10 +97,10 @@ async def test_get_journal_logs_filters( ], ) async def test_get_journal_logs_edge_cases( - self, mcp_client, mock_execute_command, returncode, stdout, stderr, expected_error, expectation + self, mcp_client, mock_execute_with_fallback, returncode, stdout, stderr, expected_error, expectation ): """Test get_journal_logs error handling and edge cases.""" - mock_execute_command.return_value = (returncode, stdout, stderr) + mock_execute_with_fallback.return_value = (returncode, stdout, stderr) with expectation: result = await mcp_client.call_tool("get_journal_logs", {}) @@ -117,17 +117,17 @@ async def test_get_journal_logs_edge_cases( ) async def test_get_journal_logs_journalctl_not_found(self, mcp_client, mocker, side_effect): """Test get_journal_logs failure.""" - mock_execute_command = mocker.patch("linux_mcp_server.tools.logs.execute_command", autospec=True) - mock_execute_command.side_effect = side_effect + mock_execute_with_fallback = mocker.patch("linux_mcp_server.commands.execute_with_fallback", autospec=True) + mock_execute_with_fallback.side_effect = side_effect result = await mcp_client.call_tool("get_journal_logs", {}) result_text = result.content[0].text.casefold() assert str(side_effect) in result_text - async def test_get_journal_logs_remote_execution(self, mcp_client, mock_execute_command): + async def test_get_journal_logs_remote_execution(self, mcp_client, mock_execute_with_fallback): """Test get_journal_logs with remote execution.""" - mock_execute_command.return_value = ( + mock_execute_with_fallback.return_value = ( 0, "jan 01 12:00:00 remote systemd[1]: remote log entry.", "", @@ -139,7 +139,7 @@ async def test_get_journal_logs_remote_execution(self, mcp_client, mock_execute_ assert "remote log entry" in result_text # Verify host parameter was passed - call_kwargs = mock_execute_command.call_args[1] + call_kwargs = mock_execute_with_fallback.call_args[1] assert call_kwargs["host"] == "remote.server.com" @@ -153,10 +153,12 @@ class TestGetAuditLogs: (50, "50"), # Custom ], ) - async def test_get_audit_logs_success(self, mcp_client, mocker, mock_execute_command, lines, expected_line_count): + async def test_get_audit_logs_success( + self, mcp_client, mocker, mock_execute_with_fallback, lines, expected_line_count + ): """Test get_audit_logs with various line counts.""" mocker.patch("linux_mcp_server.tools.logs.os.path.exists", return_value=True) - mock_execute_command.return_value = ( + mock_execute_with_fallback.return_value = ( 0, "type=SYSCALL msg=audit(1234567890.123:456): arch=c000003e syscall=1\n" * int(expected_line_count), "", @@ -170,7 +172,7 @@ async def test_get_audit_logs_success(self, mcp_client, mocker, mock_execute_com assert "type=syscall" in result_text # Verify command arguments - cmd_args = mock_execute_command.call_args[0][0] + cmd_args = mock_execute_with_fallback.call_args[0][0] assert cmd_args == ("tail", "-n", expected_line_count, "/var/log/audit/audit.log") async def test_get_audit_logs_file_not_found_local(self, mcp_client, mocker): @@ -194,21 +196,21 @@ async def test_get_audit_logs_file_not_found_local(self, mcp_client, mocker): ], ) async def test_get_audit_logs_errors( - self, mcp_client, mocker, mock_execute_command, returncode, stderr, expected_error + self, mcp_client, mocker, mock_execute_with_fallback, returncode, stderr, expected_error ): """Test get_audit_logs error handling.""" mocker.patch("linux_mcp_server.tools.logs.os.path.exists", return_value=True) - mock_execute_command.return_value = (returncode, "", stderr) + mock_execute_with_fallback.return_value = (returncode, "", stderr) result = await mcp_client.call_tool("get_audit_logs", {}) result_text = result.content[0].text.casefold() assert expected_error in result_text - async def test_get_audit_logs_no_entries(self, mcp_client, mocker, mock_execute_command): + async def test_get_audit_logs_no_entries(self, mcp_client, mocker, mock_execute_with_fallback): """Test get_audit_logs when no entries found.""" mocker.patch("linux_mcp_server.tools.logs.os.path.exists", return_value=True) - mock_execute_command.return_value = (0, "", "") + mock_execute_with_fallback.return_value = (0, "", "") result = await mcp_client.call_tool("get_audit_logs", {}) result_text = result.content[0].text.casefold() @@ -225,18 +227,18 @@ async def test_get_audit_logs_no_entries(self, mcp_client, mocker, mock_execute_ async def test_get_audit_logs_tail_not_found(self, mcp_client, mocker, side_effect): """Test get_audit_logs when tail command is not available.""" mock_exists = mocker.patch("linux_mcp_server.tools.logs.os.path.exists", autospec=True) - mock_execute_command = mocker.patch("linux_mcp_server.tools.logs.execute_command", autospec=True) + mock_execute_with_fallback = mocker.patch("linux_mcp_server.commands.execute_with_fallback", autospec=True) mock_exists.return_value = True - mock_execute_command.side_effect = side_effect + mock_execute_with_fallback.side_effect = side_effect result = await mcp_client.call_tool("get_audit_logs", {}) result_text = result.content[0].text.casefold() assert str(side_effect) in result_text.casefold() - async def test_get_audit_logs_remote_execution(self, mcp_client, mock_execute_command): + async def test_get_audit_logs_remote_execution(self, mcp_client, mock_execute_with_fallback): """Test get_audit_logs with remote execution.""" - mock_execute_command.return_value = ( + mock_execute_with_fallback.return_value = ( 0, "type=SYSCALL msg=audit(1234567890.123:456): remote audit entry", "", @@ -248,7 +250,7 @@ async def test_get_audit_logs_remote_execution(self, mcp_client, mock_execute_co assert "remote audit entry" in result_text # Verify host parameter was passed - call_kwargs = mock_execute_command.call_args[1] + call_kwargs = mock_execute_with_fallback.call_args[1] assert call_kwargs["host"] == "remote.server.com" @@ -275,11 +277,11 @@ def _setup(content="Test log content\nLine 2\nLine 3"): ], ) async def test_read_log_file_success( - self, mcp_client, mock_execute_command, setup_log_file, lines, expected_line_count + self, mcp_client, mock_execute_with_fallback, setup_log_file, lines, expected_line_count ): """Test read_log_file with various line counts.""" log_file = setup_log_file() - mock_execute_command.return_value = (0, "Test log content\nLine 2", "") + mock_execute_with_fallback.return_value = (0, "Test log content\nLine 2", "") params = {"log_path": str(log_file)} if lines != 100: @@ -292,7 +294,7 @@ async def test_read_log_file_success( assert "test log content" in result_text # Verify command arguments - cmd_args = mock_execute_command.call_args[0][0] + cmd_args = mock_execute_with_fallback.call_args[0][0] assert "-n" in cmd_args assert expected_line_count in cmd_args @@ -366,31 +368,31 @@ async def test_read_log_file_path_is_directory(self, mcp_client, mock_allowed_lo ], ) async def test_read_log_file_command_errors( - self, mcp_client, mock_execute_command, setup_log_file, returncode, stderr, expected_error + self, mcp_client, mock_execute_with_fallback, setup_log_file, returncode, stderr, expected_error ): """Test read_log_file command error handling.""" log_file = setup_log_file() - mock_execute_command.return_value = (returncode, "", stderr) + mock_execute_with_fallback.return_value = (returncode, "", stderr) result = await mcp_client.call_tool("read_log_file", {"log_path": str(log_file)}) result_text = result.content[0].text.casefold() assert expected_error in result_text - async def test_read_log_file_empty(self, mcp_client, mock_execute_command, setup_log_file): + async def test_read_log_file_empty(self, mcp_client, mock_execute_with_fallback, setup_log_file): """Test read_log_file with empty log file.""" log_file = setup_log_file(content="") - mock_execute_command.return_value = (0, "", "") + mock_execute_with_fallback.return_value = (0, "", "") result = await mcp_client.call_tool("read_log_file", {"log_path": str(log_file)}) result_text = result.content[0].text.casefold() assert "log file is empty" in result_text - async def test_read_log_file_tail_not_found(self, mcp_client, mock_execute_command, setup_log_file): + async def test_read_log_file_tail_not_found(self, mcp_client, mock_execute_with_fallback, setup_log_file): """Test read_log_file when tail command is not available.""" log_file = setup_log_file() - mock_execute_command.side_effect = FileNotFoundError("tail not found") + mock_execute_with_fallback.side_effect = FileNotFoundError("tail not found") result = await mcp_client.call_tool("read_log_file", {"log_path": str(log_file)}) result_text = result.content[0].text.casefold() @@ -398,7 +400,7 @@ async def test_read_log_file_tail_not_found(self, mcp_client, mock_execute_comma assert "tail command not found" in result_text async def test_read_log_file_multiple_allowed_paths( - self, mcp_client, mock_execute_command, mock_allowed_log_paths, tmp_path + self, mcp_client, mock_execute_with_fallback, mock_allowed_log_paths, tmp_path ): """Test read_log_file with multiple allowed paths.""" log_file1 = tmp_path / "log1.log" @@ -408,18 +410,18 @@ async def test_read_log_file_multiple_allowed_paths( # Set multiple allowed paths mock_allowed_log_paths(f"{log_file1},{log_file2}") - mock_execute_command.return_value = (0, "content2", "") + mock_execute_with_fallback.return_value = (0, "content2", "") result = await mcp_client.call_tool("read_log_file", {"log_path": str(log_file2)}) result_text = result.content[0].text.casefold() assert "content2" in result_text - async def test_read_log_file_remote_execution(self, mcp_client, mock_execute_command, mock_allowed_log_paths): + async def test_read_log_file_remote_execution(self, mcp_client, mock_execute_with_fallback, mock_allowed_log_paths): """Test read_log_file with remote execution.""" log_path = "/var/log/remote.log" mock_allowed_log_paths(log_path) - mock_execute_command.return_value = (0, "Remote log content\nLine 2", "") + mock_execute_with_fallback.return_value = (0, "Remote log content\nLine 2", "") result = await mcp_client.call_tool("read_log_file", {"log_path": log_path, "host": "remote.server.com"}) result_text = result.content[0].text.casefold() @@ -427,16 +429,16 @@ async def test_read_log_file_remote_execution(self, mcp_client, mock_execute_com assert "remote log content" in result_text # Verify host parameter was passed - call_kwargs = mock_execute_command.call_args[1] + call_kwargs = mock_execute_with_fallback.call_args[1] assert call_kwargs["host"] == "remote.server.com" async def test_read_log_file_remote_skips_local_validation( - self, mcp_client, mock_execute_command, mock_allowed_log_paths + self, mcp_client, mock_execute_with_fallback, mock_allowed_log_paths ): """Test that remote execution skips local path validation.""" log_path = "/nonexistent/remote.log" mock_allowed_log_paths(log_path) - mock_execute_command.return_value = (0, "Remote content", "") + mock_execute_with_fallback.return_value = (0, "Remote content", "") # This path doesn't exist locally but should work for remote execution result = await mcp_client.call_tool("read_log_file", {"log_path": log_path, "host": "remote.server.com"}) diff --git a/tests/tools/test_network.py b/tests/tools/test_network.py index 5ee73041..732c1869 100644 --- a/tests/tools/test_network.py +++ b/tests/tools/test_network.py @@ -2,13 +2,11 @@ import pytest -from linux_mcp_server.tools import network - @pytest.fixture -def mock_execute(mocker): +def mock_execute(mock_execute_with_fallback_for): """Mock execute_with_fallback for network module.""" - return mocker.patch.object(network, "execute_with_fallback", autospec=True) + return mock_execute_with_fallback_for("linux_mcp_server.commands") class TestGetNetworkInterfaces: diff --git a/tests/tools/test_processes.py b/tests/tools/test_processes.py index 99bd5ef9..a9a6f9fc 100644 --- a/tests/tools/test_processes.py +++ b/tests/tools/test_processes.py @@ -5,7 +5,11 @@ import pytest -from linux_mcp_server.tools import processes + +@pytest.fixture +def mock_execute_with_fallback(mock_execute_with_fallback_for): + """Processes-specific execute_with_fallback mock using the shared factory.""" + return mock_execute_with_fallback_for("linux_mcp_server.commands") @pytest.mark.skipif(sys.platform != "linux", reason="requires Linux ps command") @@ -48,26 +52,22 @@ async def test_get_process_info_with_nonexistent_process(self, mcp_client): assert any(n in result_text for n in expected), "Did not find any expected values" - async def test_list_processes_with_host(self, mcp_client, mocker): - mocker.patch.object(processes, "execute_command", return_value=(0, "some process", "")) + async def test_list_processes_with_host(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "some process", "") result = await mcp_client.call_tool("list_processes", arguments={"host": "starship.command"}) assert "running processes" in result.content[0].text.casefold() - async def test_get_process_info_with_host(self, mcp_client, mocker): + async def test_get_process_info_with_host(self, mcp_client, mock_execute_with_fallback): """Test getting process info from a remote host.""" ps_output = " PID USER STAT %CPU %MEM VSZ RSS TTY TIME COMMAND ARGS\n 1 root Ss 0.0 0.1 169436 11892 ? 0:01 init /sbin/init" proc_status = "Name:\tinit\nState:\tS (sleeping)\nPid:\t1\nPPid:\t0\nThreads:\t1" - mocker.patch.object( - processes, - "execute_command", - side_effect=[ - (0, ps_output, ""), # ps_detail - (0, proc_status, ""), # proc_status - ], - ) + mock_execute_with_fallback.side_effect = [ + (0, ps_output, ""), # ps_detail + (0, proc_status, ""), # proc_status + ] result = await mcp_client.call_tool("get_process_info", arguments={"pid": 1, "host": "starship.command"}) result_text = result.content[0].text.casefold() @@ -79,18 +79,13 @@ async def test_get_process_info_with_host(self, mcp_client, mocker): class TestProcessesRemoteMocked: """Test process tools with mocked remote execution.""" - @pytest.fixture - def mock_execute(self, mocker): - """Fixture to mock execute_command.""" - return mocker.patch.object(processes, "execute_command", autospec=True) - - async def test_list_processes_parses_ps_output(self, mcp_client, mock_execute): + async def test_list_processes_parses_ps_output(self, mcp_client, mock_execute_with_fallback): """Test that list_processes correctly parses ps aux output.""" ps_output = """USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.0 0.1 169436 11892 ? Ss Dec11 0:01 /sbin/init nobody 100 1.5 2.0 50000 20000 ? S Dec11 5:00 /usr/bin/app""" - mock_execute.return_value = (0, ps_output, "") + mock_execute_with_fallback.return_value = (0, ps_output, "") result = await mcp_client.call_tool("list_processes", arguments={"host": "remote.host"}) result_text = result.content[0].text.casefold() @@ -98,23 +93,23 @@ async def test_list_processes_parses_ps_output(self, mcp_client, mock_execute): assert "running processes" in result_text assert "total processes: 2" in result_text - async def test_list_processes_handles_command_failure(self, mcp_client, mock_execute): + async def test_list_processes_handles_command_failure(self, mcp_client, mock_execute_with_fallback): """Test that list_processes handles command failure gracefully.""" - mock_execute.return_value = (1, "", "Command not found") + mock_execute_with_fallback.return_value = (1, "", "Command not found") result = await mcp_client.call_tool("list_processes", arguments={"host": "remote.host"}) assert "error" in result.content[0].text.casefold() - async def test_get_process_info_handles_nonexistent_process(self, mcp_client, mock_execute): + async def test_get_process_info_handles_nonexistent_process(self, mcp_client, mock_execute_with_fallback): """Test that get_process_info handles non-existent process.""" - mock_execute.return_value = (1, "", "") + mock_execute_with_fallback.return_value = (1, "", "") result = await mcp_client.call_tool("get_process_info", arguments={"pid": 99999, "host": "remote.host"}) assert "does not exist" in result.content[0].text.casefold() - async def test_get_process_info_includes_proc_status(self, mcp_client, mock_execute): + async def test_get_process_info_includes_proc_status(self, mcp_client, mock_execute_with_fallback): """Test that get_process_info includes /proc status when available.""" ps_output = " PID USER STAT\n 1 root Ss" proc_status = """Name: systemd @@ -124,7 +119,7 @@ async def test_get_process_info_includes_proc_status(self, mcp_client, mock_exec Threads: 1 VmRSS: 11892 kB""" - mock_execute.side_effect = [ + mock_execute_with_fallback.side_effect = [ (0, ps_output, ""), # ps command (0, proc_status, ""), # /proc/PID/status ] @@ -137,11 +132,11 @@ async def test_get_process_info_includes_proc_status(self, mcp_client, mock_exec assert "systemd" in result_text assert "vmrss" in result_text - async def test_get_process_info_handles_proc_status_failure(self, mcp_client, mock_execute): + async def test_get_process_info_handles_proc_status_failure(self, mcp_client, mock_execute_with_fallback): """Test that get_process_info works even if /proc status fails.""" ps_output = " PID USER STAT\n 1 root Ss" - mock_execute.side_effect = [ + mock_execute_with_fallback.side_effect = [ (0, ps_output, ""), # ps command succeeds (1, "", "Permission denied"), # /proc/PID/status fails ] diff --git a/tests/tools/test_services.py b/tests/tools/test_services.py index 4c5e1250..c553269e 100644 --- a/tests/tools/test_services.py +++ b/tests/tools/test_services.py @@ -5,6 +5,12 @@ import pytest +@pytest.fixture +def mock_execute_with_fallback(mock_execute_with_fallback_for): + """Services-specific execute_with_fallback mock using the shared factory.""" + return mock_execute_with_fallback_for("linux_mcp_server.commands") + + @pytest.mark.skipif(sys.platform != "linux", reason="Only passes on Linux") class TestServices: async def test_list_services(self, mcp_client): @@ -63,26 +69,24 @@ async def test_get_service_logs_with_nonexistent_service(self, mcp_client): class TestRemoteServices: - async def test_list_services_remote(self, mocker, mcp_client): + """Test remote service management.""" + + async def test_list_services_remote(self, mock_execute_with_fallback, mcp_client): """Test listing services on a remote host.""" mock_output = "UNIT LOAD ACTIVE SUB DESCRIPTION\nnginx.service loaded active running Nginx server\n" - mock_exec = mocker.patch( - "linux_mcp_server.tools.services.execute_command", return_value=(0, mock_output, ""), autospec=True - ) + mock_execute_with_fallback.return_value = (0, mock_output, "") result = await mcp_client.call_tool("list_services", arguments={"host": "remote.example.com"}) result_text = result.content[0].text.casefold() assert "nginx.service" in result_text assert "system services" in result_text - assert mock_exec.call_count > 0 + mock_execute_with_fallback.assert_called() - async def test_get_service_status_remote(self, mocker, mcp_client): + async def test_get_service_status_remote(self, mock_execute_with_fallback, mcp_client): """Test getting service status on a remote host.""" mock_output = "● nginx.service - Nginx HTTP Server\n Loaded: loaded\n Active: active (running)" - mock_exec = mocker.patch( - "linux_mcp_server.tools.services.execute_command", return_value=(0, mock_output, ""), autospec=True - ) + mock_execute_with_fallback.return_value = (0, mock_output, "") result = await mcp_client.call_tool( "get_service_status", arguments={"service_name": "nginx", "host": "remote.example.com"} @@ -91,14 +95,12 @@ async def test_get_service_status_remote(self, mocker, mcp_client): assert "nginx.service" in result_text assert "active" in result_text - assert mock_exec.call_count > 0 + mock_execute_with_fallback.assert_called() - async def test_get_service_logs_remote(self, mocker, mcp_client): + async def test_get_service_logs_remote(self, mock_execute_with_fallback, mcp_client): """Test getting service logs on a remote host.""" mock_output = "Jan 01 12:00:00 host nginx[1234]: Starting Nginx\nJan 01 12:00:01 host nginx[1234]: Started" - mock_exec = mocker.patch( - "linux_mcp_server.tools.services.execute_command", return_value=(0, mock_output, ""), autospec=True - ) + mock_execute_with_fallback.return_value = (0, mock_output, "") result = await mcp_client.call_tool( "get_service_logs", arguments={"service_name": "nginx", "host": "remote.example.com", "lines": 50} @@ -107,4 +109,4 @@ async def test_get_service_logs_remote(self, mocker, mcp_client): assert "nginx" in result_text assert "starting" in result_text - assert mock_exec.call_count > 0 + mock_execute_with_fallback.assert_called() diff --git a/tests/tools/test_storage.py b/tests/tools/test_storage.py index 32dddb79..d2f8499d 100644 --- a/tests/tools/test_storage.py +++ b/tests/tools/test_storage.py @@ -102,9 +102,9 @@ def restricted_path(tmp_path): @pytest.fixture -def mock_storage_execute_command(mock_execute_command_for): - """Storage-specific execute_command mock using the shared factory.""" - return mock_execute_command_for("linux_mcp_server.tools.storage") +def mock_execute_with_fallback(mock_execute_with_fallback_for): + """Storage-specific execute_with_fallback mock using the shared factory.""" + return mock_execute_with_fallback_for("linux_mcp_server.commands") class TestListBlockDevices: @@ -129,19 +129,22 @@ async def test_list_block_devices_lsblk_success( self, lsblk_output, expected_content, - mock_storage_execute_command, + mock_execute_with_fallback, mcp_client, ): """Test list_block_devices with successful lsblk command.""" - mock_storage_execute_command.return_value = (0, lsblk_output, "") + mock_execute_with_fallback.return_value = (0, lsblk_output, "") result = await mcp_client.call_tool("list_block_devices", {}) result_text = result.content[0].text assert all(content in result_text for content in expected_content), "Did not find all expected content" - assert mock_storage_execute_command.call_count == 1 - assert mock_storage_execute_command.call_args.args[0][0] == "lsblk" - assert "-o" in mock_storage_execute_command.call_args.args[0] + + # Verify lsblk was called with correct arguments + mock_execute_with_fallback.assert_called_once() + args = mock_execute_with_fallback.call_args[0][0] + assert args[0] == "lsblk" + assert "-o" in args @pytest.mark.parametrize( "side_effect, expected", @@ -154,7 +157,7 @@ async def test_list_block_devices_lsblk_success( async def test_list_block_devices_command_failure(self, side_effect, expected, mocker, mcp_client): """Test list_block_devices failure.""" mocker.patch( - "linux_mcp_server.tools.storage.execute_command", + "linux_mcp_server.commands.execute_with_fallback", side_effect=side_effect, autospec=True, ) @@ -164,9 +167,9 @@ async def test_list_block_devices_command_failure(self, side_effect, expected, m assert all(case in result_text for case in expected), "Did not find all expected values" - async def test_list_block_devices_remote_execution(self, mock_storage_execute_command, mcp_client): + async def test_list_block_devices_remote_execution(self, mock_execute_with_fallback, mcp_client): """Test list_block_devices with remote execution.""" - mock_storage_execute_command.return_value = (0, "NAME SIZE TYPE\nsda 1TB disk", "") + mock_execute_with_fallback.return_value = (0, "NAME SIZE TYPE\nsda 1TB disk", "") result = await mcp_client.call_tool("list_block_devices", {"host": "remote.host.com"}) result_text = result.content[0].text @@ -174,8 +177,11 @@ async def test_list_block_devices_remote_execution(self, mock_storage_execute_co assert "=== Block Devices ===" in result_text assert "sda" in result_text assert "=== Disk I/O Statistics" not in result_text - assert mock_storage_execute_command.call_count == 1 - assert mock_storage_execute_command.call_args.kwargs["host"] == "remote.host.com" + + # Verify execute_with_fallback was called with host + mock_execute_with_fallback.assert_called_once() + call_kwargs = mock_execute_with_fallback.call_args[1] + assert call_kwargs["host"] == "remote.host.com" @pytest.mark.skipif(sys.platform != "linux", reason="requires GNU version of coreutils/findutils") @@ -290,10 +296,9 @@ async def test_list_directories_restricted_path(self, restricted_path, mcp_clien class TestListDirectoriesRemote: """Test list_directories with mocked remote execution.""" - async def test_list_directories_remote(self, mocker, mcp_client): + async def test_list_directories_remote(self, mock_execute_with_fallback, mcp_client): """Test list_directories with remote execution.""" - mock_execute = AsyncMock(spec=True, return_value=(0, "alpha\nbeta\ngamma", "")) - mocker.patch("linux_mcp_server.tools.storage.execute_command", mock_execute) + mock_execute_with_fallback.return_value = (0, "alpha\nbeta\ngamma", "") result = await mcp_client.call_tool( "list_directories", arguments={"path": "/remote/path", "host": "remote.host"} @@ -303,8 +308,11 @@ async def test_list_directories_remote(self, mocker, mcp_client): assert "alpha" in result_text assert "beta" in result_text assert "gamma" in result_text - assert mock_execute.call_count == 1 - assert mock_execute.call_args.kwargs["host"] == "remote.host" + + # Verify execute_with_fallback was called with host + mock_execute_with_fallback.assert_called_once() + call_kwargs = mock_execute_with_fallback.call_args[1] + assert call_kwargs["host"] == "remote.host" @pytest.mark.skipif(sys.platform != "linux", reason="requires GNU version of coreutils/findutils") @@ -402,16 +410,18 @@ async def test_list_files_nonexistent_path(self, tmp_path, mcp_client): class TestListFilesRemote: """Test list_files with mocked remote execution.""" - async def test_list_files_remote(self, mocker, mcp_client): + async def test_list_files_remote(self, mock_execute_with_fallback, mcp_client): """Test list_files with remote execution.""" - mock_execute = AsyncMock(spec=True, return_value=(0, "file1.txt\nfile2.txt\nfile3.txt", "")) - mocker.patch("linux_mcp_server.tools.storage.execute_command", mock_execute) + mock_execute_with_fallback.return_value = (0, "file1.txt\nfile2.txt\nfile3.txt", "") result = await mcp_client.call_tool("list_files", arguments={"path": "/remote/path", "host": "remote.host"}) assert all(f"file{n}.txt" in result.content[0].text for n in range(1, 4)) - assert mock_execute.call_count == 1 - assert mock_execute.call_args.kwargs.get("host") == "remote.host" + + # Verify execute_with_fallback was called with host + mock_execute_with_fallback.assert_called_once() + call_kwargs = mock_execute_with_fallback.call_args[1] + assert call_kwargs["host"] == "remote.host" class TestReadFile: @@ -440,23 +450,24 @@ async def test_read_file_is_directory(self, tmp_path, mcp_client): assert "not a file" in str(exc_info.value) - async def test_read_file_remote(self, mocker, mcp_client): + async def test_read_file_remote(self, mock_execute_with_fallback, mcp_client): """Test reading a file remotely.""" - mock_execute = AsyncMock(spec=True, return_value=(0, "Remote file content", "")) - mocker.patch("linux_mcp_server.tools.storage.execute_command", mock_execute) + mock_execute_with_fallback.return_value = (0, "Remote file content", "") result = await mcp_client.call_tool( "read_file", arguments={"path": "/remote/path/file.txt", "host": "remote.host"} ) assert result.content[0].text == "Remote file content" - assert mock_execute.call_count == 1 - assert mock_execute.call_args.kwargs.get("host") == "remote.host" - async def test_read_file_remote_failure(self, mocker, mcp_client): + # Verify execute_with_fallback was called with host + mock_execute_with_fallback.assert_called_once() + call_kwargs = mock_execute_with_fallback.call_args[1] + assert call_kwargs["host"] == "remote.host" + + async def test_read_file_remote_failure(self, mock_execute_with_fallback, mcp_client): """Test reading a file remotely with failure.""" - mock_execute = AsyncMock(spec=True, return_value=(1, "", "File not found")) - mocker.patch("linux_mcp_server.tools.storage.execute_command", mock_execute) + mock_execute_with_fallback.return_value = (1, "", "File not found") with pytest.raises(ToolError) as exc_info: await mcp_client.call_tool("read_file", arguments={"path": "/remote/path/file.txt", "host": "remote.host"}) From a50cf67952ff6b854a9bac4941c6bbe67401953b Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Mon, 22 Dec 2025 16:31:37 -0500 Subject: [PATCH 03/34] [RSPEED-2227] Exclude the parent directory from output when listing directories sorted by size (#131) * Reorganize tests Break test in separte modules for easier navigation. Combine tests using paramtrize. * Exclude the parent directory from output when listing directories sorted by size * Add tests cases Update original test case data since it did not match actual output from du. * Simpler approach that omits the last line --- src/linux_mcp_server/parsers.py | 21 +- tests/parsers/__init__.py | 0 tests/parsers/test_parse_cpu_info.py | 33 ++ tests/parsers/test_parse_free_output.py | 32 ++ tests/parsers/test_parse_ip_brief.py | 27 ++ tests/parsers/test_parse_listing.py | 83 +++++ tests/parsers/test_parse_os_release.py | 27 ++ tests/parsers/test_parse_proc.py | 62 ++++ tests/parsers/test_parse_ps_output.py | 58 ++++ tests/parsers/test_parse_service_count.py | 33 ++ tests/parsers/test_parse_ss.py | 74 ++++ tests/parsers/test_parse_system_info.py | 30 ++ tests/test_parsers.py | 402 ---------------------- 13 files changed, 469 insertions(+), 413 deletions(-) create mode 100644 tests/parsers/__init__.py create mode 100644 tests/parsers/test_parse_cpu_info.py create mode 100644 tests/parsers/test_parse_free_output.py create mode 100644 tests/parsers/test_parse_ip_brief.py create mode 100644 tests/parsers/test_parse_listing.py create mode 100644 tests/parsers/test_parse_os_release.py create mode 100644 tests/parsers/test_parse_proc.py create mode 100644 tests/parsers/test_parse_ps_output.py create mode 100644 tests/parsers/test_parse_service_count.py create mode 100644 tests/parsers/test_parse_ss.py create mode 100644 tests/parsers/test_parse_system_info.py delete mode 100644 tests/test_parsers.py diff --git a/src/linux_mcp_server/parsers.py b/src/linux_mcp_server/parsers.py index 4e7a3dd1..2db8607c 100644 --- a/src/linux_mcp_server/parsers.py +++ b/src/linux_mcp_server/parsers.py @@ -4,6 +4,8 @@ structured data that can be used by formatters. """ +from pathlib import Path + from linux_mcp_server.utils.types import CpuInfo from linux_mcp_server.utils.types import ListeningPort from linux_mcp_server.utils.types import MemoryInfo @@ -470,23 +472,20 @@ def parse_directory_listing( """ entries = [] lines = stdout.strip().split("\n") + last = len(lines) - for line in lines: + for idx, line in enumerate(lines, 1): if not line.strip(): continue if sort_by == "size": # Format: SIZE\tNAME (from du -b) - parts = line.split("\t", 1) - if len(parts) == 2: - try: - size = int(parts[0]) - # Extract just the directory name from the path - name = parts[1].rstrip("/").split("/")[-1] - if name: # Skip empty names (current dir) - entries.append(NodeEntry(size=size, name=name)) - except ValueError: - continue + size, path = line.split("\t", 1) + size = int(size) + path = Path(path) + # Omit the last line since it containers the parent directory + if idx < last: + entries.append(NodeEntry(size=size, name=path.name)) elif sort_by == "modified": # Format: TIMESTAMP\tNAME (from find -printf "%T@\t%f\n") parts = line.split("\t", 1) diff --git a/tests/parsers/__init__.py b/tests/parsers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/parsers/test_parse_cpu_info.py b/tests/parsers/test_parse_cpu_info.py new file mode 100644 index 00000000..004cf9c8 --- /dev/null +++ b/tests/parsers/test_parse_cpu_info.py @@ -0,0 +1,33 @@ +import pytest + +from linux_mcp_server.parsers import parse_cpu_info + + +@pytest.mark.parametrize( + "stdout, expected", + [ + ({}, {"model": "", "logical_cores": 0}), + ( + { + "model": "model name\t: Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz\n", + "logical_cores": "8\n", + "physical_cores": "core id\t\t: 0\ncore id\t\t: 1\ncore id\t\t: 2\ncore id\t\t: 3\n", + "frequency": "cpu MHz\t\t: 2900.000\n", + "load_avg": "0.50 0.75 1.00 1/234 5678\n", + }, + { + "model": "Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz", + "logical_cores": 8, + "physical_cores": 4, + "frequency_mhz": 2900.0, + "load_avg_1m": 0.50, + "load_avg_5m": 0.75, + "load_avg_15m": 1.00, + }, + ), + ], +) +def test_parse_cpu_info(stdout, expected): + result = parse_cpu_info(stdout) + + assert all(getattr(result, attr) == value for attr, value in expected.items()) diff --git a/tests/parsers/test_parse_free_output.py b/tests/parsers/test_parse_free_output.py new file mode 100644 index 00000000..f7545f54 --- /dev/null +++ b/tests/parsers/test_parse_free_output.py @@ -0,0 +1,32 @@ +import textwrap + +from linux_mcp_server.parsers import parse_free_output + + +def test_parse_free_output_empty(): + """Test parsing empty output.""" + result = parse_free_output("") + + assert result.ram.total == 0 + assert result.swap is None + + +def test_parse_free_output(): + """Test parsing free -b -w output (wide format with separate buffers/cache).""" + stdout = """ total used free shared buffers cache available + Mem: 16777216000 8388608000 4294967296 134217728 1234567890 2859072814 8000000000 + Swap: 2147483648 104857600 2042626048 + """ + result = parse_free_output(textwrap.dedent(stdout)) + + assert result.ram.total == 16777216000 + assert result.ram.used == 8388608000 + assert result.ram.free == 4294967296 + assert result.ram.shared == 134217728 + assert result.ram.buffers == 1234567890 + assert result.ram.cached == 2859072814 + assert result.ram.available == 8000000000 + assert result.swap is not None + assert result.swap.total == 2147483648 + assert result.swap.used == 104857600 + assert result.swap.free == 2042626048 diff --git a/tests/parsers/test_parse_ip_brief.py b/tests/parsers/test_parse_ip_brief.py new file mode 100644 index 00000000..2bbe6760 --- /dev/null +++ b/tests/parsers/test_parse_ip_brief.py @@ -0,0 +1,27 @@ +import textwrap + +from linux_mcp_server.parsers import parse_ip_brief + + +def test_parse_ip_brief_empty(): + """Test parsing empty output.""" + result = parse_ip_brief("") + + assert result == {} + + +def test_parse_ip_brief(): + """Test parsing ip -brief address output.""" + stdout = """lo UNKNOWN 127.0.0.1/8 ::1/128 + eth0 UP 192.168.1.100/24 fe80::1/64 + """ + result = parse_ip_brief(textwrap.dedent(stdout)) + lo = result["lo"] + eth0 = result["eth0"] + + assert "lo" in result + assert "eth0" in result + assert lo.status == "UNKNOWN" + assert "127.0.0.1/8" in lo.addresses + assert eth0.status == "UP" + assert "192.168.1.100/24" in eth0.addresses diff --git a/tests/parsers/test_parse_listing.py b/tests/parsers/test_parse_listing.py new file mode 100644 index 00000000..5724a499 --- /dev/null +++ b/tests/parsers/test_parse_listing.py @@ -0,0 +1,83 @@ +import pytest + +from linux_mcp_server.parsers import parse_directory_listing +from linux_mcp_server.parsers import parse_file_listing + + +@pytest.mark.parametrize( + "stdout, order_by, expected_count, expected", + [ + ( + "", + "name", + 0, + "result == []", + ), + ( + """4096\t/path/alpha + 8192\t/path/beta + 2048\t/path/gamma + 12448\t/path/""", + "size", + 3, + "result[0].size == 4096 and result[0].name == 'alpha' and result[1].size == 8192 and result[1].name == 'beta'", + ), + ( + """4096\t/path/subdir/alpha + 8192\t/path/subdir/beta + 2048\t/path/subdir/gamma + 12448\t/path/subdir/""", + "size", + 3, + "result[0].size == 4096 and result[0].name == 'alpha' and result[1].size == 8192 and result[1].name == 'beta'", + ), + ( + "1700000000.0\talpha\n1700100000.0\tbeta\n1700200000.0\tgamma", + "modified", + 3, + "result[0].modified == 1700000000.0 and result[0].name == 'alpha'", + ), + ], + ids=["name", "size", "size_subdirs", "modified"], +) +def test_parse_directory_listing(stdout, order_by, expected_count, expected): + result = parse_directory_listing(stdout, order_by) + + assert len(result) == expected_count + assert eval(expected) + + +@pytest.mark.parametrize( + "stdout, order_by, expected_count, expected", + [ + ( + "", + "name", + 0, + "result == []", + ), + ( + "file1.txt\nfile2.txt\nfile3.txt", + "name", + 3, + "[e.name for e in result] == ['file1.txt', 'file2.txt', 'file3.txt']", + ), + ( + "1024\tfile1.txt\n2048\tfile2.txt\n512\tfile3.txt", + "size", + 3, + "result[0].size == 1024 and result[0].name == 'file1.txt'", + ), + ( + "1700000000.0\tfile1.txt\n1700100000.0\tfile2.txt", + "modified", + 2, + "result[0].modified == 1700000000.0 and result[0].name == 'file1.txt'", + ), + ], +) +def test_parse_file_listing(stdout, order_by, expected_count, expected): + result = parse_file_listing(stdout, order_by) + + assert len(result) == expected_count + assert eval(expected) diff --git a/tests/parsers/test_parse_os_release.py b/tests/parsers/test_parse_os_release.py new file mode 100644 index 00000000..24f7febd --- /dev/null +++ b/tests/parsers/test_parse_os_release.py @@ -0,0 +1,27 @@ +import textwrap + +from linux_mcp_server.parsers import parse_os_release + + +def test_parse_os_release_empty(): + """Test parsing empty output.""" + result = parse_os_release("") + + assert result == {} + + +def test_parse_os_release(): + """Test parsing /etc/os-release content.""" + stdout = """NAME="Ubuntu" + VERSION="22.04.3 LTS (Jammy Jellyfish)" + ID=ubuntu + ID_LIKE=debian + PRETTY_NAME="Ubuntu 22.04.3 LTS" + VERSION_ID="22.04" + """ + result = parse_os_release(textwrap.dedent(stdout)) + + assert result["NAME"] == "Ubuntu" + assert result["VERSION_ID"] == "22.04" + assert result["PRETTY_NAME"] == "Ubuntu 22.04.3 LTS" + assert result["ID"] == "ubuntu" diff --git a/tests/parsers/test_parse_proc.py b/tests/parsers/test_parse_proc.py new file mode 100644 index 00000000..61758d6b --- /dev/null +++ b/tests/parsers/test_parse_proc.py @@ -0,0 +1,62 @@ +import textwrap + +from linux_mcp_server.parsers import parse_proc_net_dev +from linux_mcp_server.parsers import parse_proc_status + + +def test_parse_proc_net_dev_empty(): + """Test parsing empty output.""" + result = parse_proc_net_dev("") + + assert result == {} + + +def test_parse_proc_net_dev(): + """Test parsing /proc/net/dev content.""" + stdout = """Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 1234567 12345 0 0 0 0 0 0 1234567 12345 0 0 0 0 0 0 + eth0: 9876543 98765 10 5 0 0 0 100 5432100 54321 20 10 0 0 0 0 + """ + result = parse_proc_net_dev(textwrap.dedent(stdout)) + lo = result["lo"] + eth0 = result["eth0"] + + assert "lo" in result + assert "eth0" in result + assert lo.rx_bytes == 1234567 + assert lo.rx_packets == 12345 + assert eth0.rx_bytes == 9876543 + assert eth0.rx_errors == 10 + assert eth0.tx_bytes == 5432100 + + +def test_parse_proc_status_empty(): + """Test parsing empty output.""" + result = parse_proc_status("") + + assert result == {} + + +def test_parse_proc_status(): + """Test parsing /proc/{pid}/status content.""" + stdout = """Name: bash + State: S (sleeping) + Tgid: 1234 + Pid: 1234 + PPid: 1000 + Threads: 1 + VmPeak: 12000 kB + VmSize: 10000 kB + VmRSS: 5000 kB + SigPnd: 0000000000000000 + """ + result = parse_proc_status(textwrap.dedent(stdout)) + + assert result["Name"] == "bash" + assert result["State"] == "S (sleeping)" + assert result["Pid"] == "1234" + assert result["PPid"] == "1000" + assert result["Threads"] == "1" + assert result["VmRSS"] == "5000 kB" + assert "SigPnd" not in result diff --git a/tests/parsers/test_parse_ps_output.py b/tests/parsers/test_parse_ps_output.py new file mode 100644 index 00000000..3c97751b --- /dev/null +++ b/tests/parsers/test_parse_ps_output.py @@ -0,0 +1,58 @@ +import textwrap + +from linux_mcp_server.parsers import parse_ps_output + + +def test_parse_ps_output_empty(): + """Test parsing empty output.""" + result = parse_ps_output("") + + assert result == [] + + +def test_parse_ps_output_process(): + """Test parsing a process entry.""" + stdout = """USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND + root 1 0.0 0.1 169436 11892 ? Ss Dec11 0:01 /sbin/init + """ + result = parse_ps_output(textwrap.dedent(stdout)) + proc = result[0] + + assert len(result) == 1 + assert proc.user == "root" + assert proc.pid == 1 + assert proc.cpu_percent == 0.0 + assert proc.mem_percent == 0.1 + assert proc.vsz == 169436 + assert proc.rss == 11892 + assert proc.tty == "?" + assert proc.stat == "Ss" + assert proc.start == "Dec11" + assert proc.time == "0:01" + assert proc.command == "/sbin/init" + + +def test_parse_ps_output_multiple_processes(): + """Test parsing multiple processes.""" + stdout = """USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND + root 1 0.0 0.1 169436 11892 ? Ss Dec11 0:01 /sbin/init + nobody 100 1.5 2.0 50000 20000 ? S Dec11 5:00 /usr/bin/app + """ + result = parse_ps_output(textwrap.dedent(stdout)) + + assert len(result) == 2 + + +def test_parse_ps_output_skips_malformed_lines(): + """Test that malformed lines are skipped (too few parts or invalid values).""" + stdout = """USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND + root 1 0.0 0.1 169436 11892 ? Ss Dec11 0:01 /sbin/init + truncated line with too few parts + nobody abc 1.5 2.0 50000 20000 ? S Dec11 5:00 /usr/bin/invalid_pid + valid 200 0.5 0.5 10000 5000 ? S Dec11 1:00 /usr/bin/valid + """ + result = parse_ps_output(textwrap.dedent(stdout)) + + assert len(result) == 2 + assert result[0].pid == 1 + assert result[1].pid == 200 diff --git a/tests/parsers/test_parse_service_count.py b/tests/parsers/test_parse_service_count.py new file mode 100644 index 00000000..b9b80c98 --- /dev/null +++ b/tests/parsers/test_parse_service_count.py @@ -0,0 +1,33 @@ +import textwrap + +import pytest + +from linux_mcp_server.parsers import parse_service_count + + +@pytest.mark.parametrize( + "stdout, expected", + [ + ("", 0), + ( + """UNIT LOAD ACTIVE SUB DESCRIPTION + ssh.service loaded active running OpenBSD Secure Shell server + cron.service loaded active running Regular background program processing + nginx.service loaded active running A high performance web server + """, + 3, + ), + ( + """Some header text + ssh.service is running + another.service is active + not a service line + """, + 2, + ), + ], +) +def test_parse_service_count(stdout, expected): + result = parse_service_count(textwrap.dedent(stdout)) + + assert result == expected diff --git a/tests/parsers/test_parse_ss.py b/tests/parsers/test_parse_ss.py new file mode 100644 index 00000000..defd4ae6 --- /dev/null +++ b/tests/parsers/test_parse_ss.py @@ -0,0 +1,74 @@ +import textwrap + +import pytest + +from linux_mcp_server.parsers import parse_ss_connections +from linux_mcp_server.parsers import parse_ss_listening + + +@pytest.mark.parametrize( + "stdout", + [ + "", + "Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port", + ], +) +def test_parse_ss_connections_empty_or_header_only(stdout): + result = parse_ss_connections(stdout) + + assert result == [] + + +@pytest.mark.parametrize( + "stdout, protocol, state, local_address, local_port", + [ + ( + """Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port + tcp ESTAB 0 0 192.168.1.100:22 192.168.1.1:54321 + """, + "TCP", + "ESTAB", + "192.168.1.100", + "22", + ), + ( + """Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port + udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:* + """, + "UDP", + "UNCONN", + "0.0.0.0", + "68", + ), + ], +) +def test_parse_ss_connections_connection(stdout, protocol, state, local_address, local_port): + result = parse_ss_connections(textwrap.dedent(stdout)) + conn = result[0] + + assert len(result) == 1 + assert conn.protocol == protocol + assert conn.state == state + assert conn.local_address == local_address + assert conn.local_port == local_port + + +def test_parse_ss_listening_empty_output(): + """Test parsing empty output.""" + result = parse_ss_listening("") + + assert result == [] + + +def test_parse_ss_listening_listening_port(): + """Test parsing a listening port.""" + stdout = """Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port + tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* + """ + result = parse_ss_listening(textwrap.dedent(stdout)) + port = result[0] + + assert len(result) == 1 + assert port.protocol == "TCP" + assert port.local_address == "0.0.0.0" + assert port.local_port == "22" diff --git a/tests/parsers/test_parse_system_info.py b/tests/parsers/test_parse_system_info.py new file mode 100644 index 00000000..5deb601b --- /dev/null +++ b/tests/parsers/test_parse_system_info.py @@ -0,0 +1,30 @@ +from linux_mcp_server.parsers import parse_system_info + + +def test_parse_system_info_empty(): + """Test parsing empty results.""" + result = parse_system_info({}) + + assert result.hostname == "" + assert result.os_name == "" + + +def test_parse_system_info(): + """Test parsing system info results.""" + results = { + "hostname": "myserver\n", + "os_release": 'PRETTY_NAME="Ubuntu 22.04.3 LTS"\nVERSION_ID="22.04"\n', + "kernel": "5.15.0-91-generic\n", + "arch": "x86_64\n", + "uptime": "up 5 days, 3 hours\n", + "boot_time": "2024-01-01 10:00:00\n", + } + result = parse_system_info(results) + + assert result.arch == "x86_64" + assert result.boot_time == "2024-01-01 10:00:00" + assert result.hostname == "myserver" + assert result.kernel == "5.15.0-91-generic" + assert result.os_name == "Ubuntu 22.04.3 LTS" + assert result.os_version == "22.04" + assert result.uptime == "up 5 days, 3 hours" diff --git a/tests/test_parsers.py b/tests/test_parsers.py deleted file mode 100644 index cc632971..00000000 --- a/tests/test_parsers.py +++ /dev/null @@ -1,402 +0,0 @@ -"""Tests for parsers module.""" - -from linux_mcp_server.parsers import parse_cpu_info -from linux_mcp_server.parsers import parse_directory_listing -from linux_mcp_server.parsers import parse_file_listing -from linux_mcp_server.parsers import parse_free_output -from linux_mcp_server.parsers import parse_ip_brief -from linux_mcp_server.parsers import parse_os_release -from linux_mcp_server.parsers import parse_proc_net_dev -from linux_mcp_server.parsers import parse_proc_status -from linux_mcp_server.parsers import parse_ps_output -from linux_mcp_server.parsers import parse_service_count -from linux_mcp_server.parsers import parse_ss_connections -from linux_mcp_server.parsers import parse_ss_listening -from linux_mcp_server.parsers import parse_system_info - - -class TestParseSsConnections: - """Tests for parse_ss_connections function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_ss_connections("") - assert result == [] - - def test_parse_header_only(self): - """Test parsing output with only header.""" - stdout = "Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port" - result = parse_ss_connections(stdout) - assert result == [] - - def test_parse_tcp_connection(self): - """Test parsing a TCP connection.""" - stdout = """Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port -tcp ESTAB 0 0 192.168.1.100:22 192.168.1.1:54321""" - result = parse_ss_connections(stdout) - assert len(result) == 1 - conn = result[0] - assert conn.protocol == "TCP" - assert conn.state == "ESTAB" - assert conn.local_address == "192.168.1.100" - assert conn.local_port == "22" - - def test_parse_udp_connection(self): - """Test parsing a UDP connection.""" - stdout = """Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port -udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:*""" - result = parse_ss_connections(stdout) - assert len(result) == 1 - conn = result[0] - assert conn.protocol == "UDP" - - -class TestParseSsListening: - """Tests for parse_ss_listening function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_ss_listening("") - assert result == [] - - def test_parse_listening_port(self): - """Test parsing a listening port.""" - stdout = """Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port -tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*""" - result = parse_ss_listening(stdout) - assert len(result) == 1 - port = result[0] - assert port.protocol == "TCP" - assert port.local_address == "0.0.0.0" - assert port.local_port == "22" - - -class TestParsePsOutput: - """Tests for parse_ps_output function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_ps_output("") - assert result == [] - - def test_parse_process(self): - """Test parsing a process entry.""" - stdout = """USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND -root 1 0.0 0.1 169436 11892 ? Ss Dec11 0:01 /sbin/init""" - result = parse_ps_output(stdout) - assert len(result) == 1 - proc = result[0] - assert proc.user == "root" - assert proc.pid == 1 - assert proc.cpu_percent == 0.0 - assert proc.mem_percent == 0.1 - assert proc.vsz == 169436 - assert proc.rss == 11892 - assert proc.tty == "?" - assert proc.stat == "Ss" - assert proc.start == "Dec11" - assert proc.time == "0:01" - assert proc.command == "/sbin/init" - - def test_parse_multiple_processes(self): - """Test parsing multiple processes.""" - stdout = """USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND -root 1 0.0 0.1 169436 11892 ? Ss Dec11 0:01 /sbin/init -nobody 100 1.5 2.0 50000 20000 ? S Dec11 5:00 /usr/bin/app""" - result = parse_ps_output(stdout) - assert len(result) == 2 - - def test_parse_skips_malformed_lines(self): - """Test that malformed lines are skipped (too few parts or invalid values).""" - stdout = """USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND -root 1 0.0 0.1 169436 11892 ? Ss Dec11 0:01 /sbin/init -truncated line with too few parts -nobody abc 1.5 2.0 50000 20000 ? S Dec11 5:00 /usr/bin/invalid_pid -valid 200 0.5 0.5 10000 5000 ? S Dec11 1:00 /usr/bin/valid""" - result = parse_ps_output(stdout) - assert len(result) == 2 - assert result[0].pid == 1 - assert result[1].pid == 200 - - -class TestParseOsRelease: - """Tests for parse_os_release function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_os_release("") - assert result == {} - - def test_parse_os_release(self): - """Test parsing /etc/os-release content.""" - stdout = """NAME="Ubuntu" -VERSION="22.04.3 LTS (Jammy Jellyfish)" -ID=ubuntu -ID_LIKE=debian -PRETTY_NAME="Ubuntu 22.04.3 LTS" -VERSION_ID="22.04" -""" - result = parse_os_release(stdout) - assert result["NAME"] == "Ubuntu" - assert result["VERSION_ID"] == "22.04" - assert result["PRETTY_NAME"] == "Ubuntu 22.04.3 LTS" - assert result["ID"] == "ubuntu" - - -class TestParseFreeOutput: - """Tests for parse_free_output function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_free_output("") - assert result.ram.total == 0 - assert result.swap is None - - def test_parse_free_output(self): - """Test parsing free -b -w output (wide format with separate buffers/cache).""" - stdout = """ total used free shared buffers cache available -Mem: 16777216000 8388608000 4294967296 134217728 1234567890 2859072814 8000000000 -Swap: 2147483648 104857600 2042626048""" - result = parse_free_output(stdout) - assert result.ram.total == 16777216000 - assert result.ram.used == 8388608000 - assert result.ram.free == 4294967296 - assert result.ram.shared == 134217728 - assert result.ram.buffers == 1234567890 - assert result.ram.cached == 2859072814 - assert result.ram.available == 8000000000 - assert result.swap is not None - assert result.swap.total == 2147483648 - assert result.swap.used == 104857600 - assert result.swap.free == 2042626048 - - -class TestParseProcNetDev: - """Tests for parse_proc_net_dev function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_proc_net_dev("") - assert result == {} - - def test_parse_proc_net_dev(self): - """Test parsing /proc/net/dev content.""" - stdout = """Inter-| Receive | Transmit - face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed - lo: 1234567 12345 0 0 0 0 0 0 1234567 12345 0 0 0 0 0 0 - eth0: 9876543 98765 10 5 0 0 0 100 5432100 54321 20 10 0 0 0 0""" - result = parse_proc_net_dev(stdout) - assert "lo" in result - assert "eth0" in result - lo = result["lo"] - assert lo.rx_bytes == 1234567 - assert lo.rx_packets == 12345 - eth0 = result["eth0"] - assert eth0.rx_bytes == 9876543 - assert eth0.rx_errors == 10 - assert eth0.tx_bytes == 5432100 - - -class TestParseIpBrief: - """Tests for parse_ip_brief function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_ip_brief("") - assert result == {} - - def test_parse_ip_brief(self): - """Test parsing ip -brief address output.""" - stdout = """lo UNKNOWN 127.0.0.1/8 ::1/128 -eth0 UP 192.168.1.100/24 fe80::1/64""" - result = parse_ip_brief(stdout) - assert "lo" in result - assert "eth0" in result - lo = result["lo"] - assert lo.status == "UNKNOWN" - assert "127.0.0.1/8" in lo.addresses - eth0 = result["eth0"] - assert eth0.status == "UP" - assert "192.168.1.100/24" in eth0.addresses - - -class TestParseSystemInfo: - """Tests for parse_system_info function.""" - - def test_parse_empty_results(self): - """Test parsing empty results.""" - result = parse_system_info({}) - assert result.hostname == "" - assert result.os_name == "" - - def test_parse_system_info(self): - """Test parsing system info results.""" - results = { - "hostname": "myserver\n", - "os_release": 'PRETTY_NAME="Ubuntu 22.04.3 LTS"\nVERSION_ID="22.04"\n', - "kernel": "5.15.0-91-generic\n", - "arch": "x86_64\n", - "uptime": "up 5 days, 3 hours\n", - "boot_time": "2024-01-01 10:00:00\n", - } - result = parse_system_info(results) - assert result.hostname == "myserver" - assert result.os_name == "Ubuntu 22.04.3 LTS" - assert result.os_version == "22.04" - assert result.kernel == "5.15.0-91-generic" - assert result.arch == "x86_64" - - -class TestParseCpuInfo: - """Tests for parse_cpu_info function.""" - - def test_parse_empty_results(self): - """Test parsing empty results.""" - result = parse_cpu_info({}) - assert result.model == "" - assert result.logical_cores == 0 - - def test_parse_cpu_info(self): - """Test parsing CPU info results.""" - results = { - "model": "model name\t: Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz\n", - "logical_cores": "8\n", - "physical_cores": "core id\t\t: 0\ncore id\t\t: 1\ncore id\t\t: 2\ncore id\t\t: 3\n", - "frequency": "cpu MHz\t\t: 2900.000\n", - "load_avg": "0.50 0.75 1.00 1/234 5678\n", - } - result = parse_cpu_info(results) - assert result.model == "Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz" - assert result.logical_cores == 8 - assert result.physical_cores == 4 - assert result.frequency_mhz == 2900.0 - assert result.load_avg_1m == 0.50 - assert result.load_avg_5m == 0.75 - assert result.load_avg_15m == 1.00 - - -class TestParseProcStatus: - """Tests for parse_proc_status function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_proc_status("") - assert result == {} - - def test_parse_proc_status(self): - """Test parsing /proc/{pid}/status content.""" - stdout = """Name: bash -State: S (sleeping) -Tgid: 1234 -Pid: 1234 -PPid: 1000 -Threads: 1 -VmPeak: 12000 kB -VmSize: 10000 kB -VmRSS: 5000 kB -SigPnd: 0000000000000000""" - result = parse_proc_status(stdout) - assert result["Name"] == "bash" - assert result["State"] == "S (sleeping)" - assert result["Pid"] == "1234" - assert result["PPid"] == "1000" - assert result["Threads"] == "1" - assert result["VmRSS"] == "5000 kB" - # SigPnd should not be included (not in relevant_fields) - assert "SigPnd" not in result - - -class TestParseServiceCount: - """Tests for parse_service_count function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_service_count("") - assert result == 0 - - def test_parse_service_count(self): - """Test counting services.""" - stdout = """UNIT LOAD ACTIVE SUB DESCRIPTION -ssh.service loaded active running OpenBSD Secure Shell server -cron.service loaded active running Regular background program processing -nginx.service loaded active running A high performance web server -""" - result = parse_service_count(stdout) - assert result == 3 - - def test_parse_service_count_mixed(self): - """Test counting services with mixed content.""" - stdout = """Some header text -ssh.service is running -another.service is active -not a service line""" - result = parse_service_count(stdout) - assert result == 2 - - -class TestParseDirectoryListing: - """Tests for parse_directory_listing function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_directory_listing("", "name") - assert result == [] - - def test_parse_by_name(self): - """Test parsing directory names.""" - stdout = "alpha\nbeta\ngamma" - result = parse_directory_listing(stdout, "name") - assert len(result) == 3 - names = [e.name for e in result] - assert names == ["alpha", "beta", "gamma"] - - def test_parse_by_size(self): - """Test parsing directories with sizes from du output.""" - stdout = "4096\t/path/alpha\n8192\t/path/beta\n2048\t/path/gamma" - result = parse_directory_listing(stdout, "size") - assert len(result) == 3 - assert result[0].size == 4096 - assert result[0].name == "alpha" - assert result[1].size == 8192 - assert result[1].name == "beta" - - def test_parse_by_modified(self): - """Test parsing directories with modification times.""" - stdout = "1700000000.0\talpha\n1700100000.0\tbeta\n1700200000.0\tgamma" - result = parse_directory_listing(stdout, "modified") - assert len(result) == 3 - assert result[0].modified == 1700000000.0 - assert result[0].name == "alpha" - - -class TestParseFileListing: - """Tests for parse_file_listing function.""" - - def test_parse_empty_output(self): - """Test parsing empty output.""" - result = parse_file_listing("", "name") - assert result == [] - - def test_parse_by_name(self): - """Test parsing file names.""" - stdout = "file1.txt\nfile2.txt\nfile3.txt" - result = parse_file_listing(stdout, "name") - assert len(result) == 3 - names = [e.name for e in result] - assert names == ["file1.txt", "file2.txt", "file3.txt"] - - def test_parse_by_size(self): - """Test parsing files with sizes.""" - stdout = "1024\tfile1.txt\n2048\tfile2.txt\n512\tfile3.txt" - result = parse_file_listing(stdout, "size") - assert len(result) == 3 - assert result[0].size == 1024 - assert result[0].name == "file1.txt" - - def test_parse_by_modified(self): - """Test parsing files with modification times.""" - stdout = "1700000000.0\tfile1.txt\n1700100000.0\tfile2.txt" - result = parse_file_listing(stdout, "modified") - assert len(result) == 2 - assert result[0].modified == 1700000000.0 - assert result[0].name == "file1.txt" From 867a3b175017966a8511048f51ec31901572ff42 Mon Sep 17 00:00:00 2001 From: Major Hayden Date: Mon, 5 Jan 2026 09:09:00 -0600 Subject: [PATCH 04/34] Add MkDocs with Material theme for documentation (#133) - Add docs dependency group with mkdocs, mkdocs-material, mkdocstrings - Create mkdocs.yml with Material theme and navigation structure - Add docs and docs-serve Makefile targets - Create docs/index.md home page - Rename docs files to lowercase for consistency - Fix internal links to use lowercase filenames - Add site/ to .gitignore (build output) Signed-off-by: Major Hayden --- .gitignore | 3 + Makefile | 14 +- docs/{Architecture.md => architecture.md} | 0 docs/{CONTRIBUTING.md => contributing.md} | 0 docs/{Debugging.md => debugging.md} | 0 docs/index.md | 57 ++++ docs/{Install.md => install.md} | 7 +- docs/{Usage.md => usage.md} | 8 +- mkdocs.yml | 76 +++++ pyproject.toml | 6 + uv.lock | 388 ++++++++++++++++++++++ 11 files changed, 549 insertions(+), 10 deletions(-) rename docs/{Architecture.md => architecture.md} (100%) rename docs/{CONTRIBUTING.md => contributing.md} (100%) rename docs/{Debugging.md => debugging.md} (100%) create mode 100644 docs/index.md rename docs/{Install.md => install.md} (97%) rename docs/{Usage.md => usage.md} (97%) create mode 100644 mkdocs.yml diff --git a/.gitignore b/.gitignore index b34ccac4..971f2dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ coverage/ htmlcov/ .tox/ +# Documentation build +site/ + # IDEs .vscode/ .idea/ diff --git a/Makefile b/Makefile index 39b0e4fd..c07efa50 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help sync lint format types test ci verify fix clean +.PHONY: help sync lint format types test ci verify fix clean docs docs-serve # Default target help: @@ -14,6 +14,10 @@ help: @echo " make sync - Install/sync all dependencies" @echo " make fix - Auto-fix lint and format issues" @echo " make clean - Remove build artifacts and caches" + @echo "" + @echo "📚 Documentation Targets:" + @echo " make docs - Build documentation" + @echo " make docs-serve - Serve docs locally with live reload" sync: uv sync --locked @@ -41,6 +45,12 @@ fix: uv run --locked ruff format clean: - rm -rf .pytest_cache .ruff_cache .pyright coverage dist build + rm -rf .pytest_cache .ruff_cache .pyright coverage dist build site rm -rf src/*.egg-info find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + +docs: + uv run --locked --group docs mkdocs build --strict + +docs-serve: + uv run --locked --group docs mkdocs serve diff --git a/docs/Architecture.md b/docs/architecture.md similarity index 100% rename from docs/Architecture.md rename to docs/architecture.md diff --git a/docs/CONTRIBUTING.md b/docs/contributing.md similarity index 100% rename from docs/CONTRIBUTING.md rename to docs/contributing.md diff --git a/docs/Debugging.md b/docs/debugging.md similarity index 100% rename from docs/Debugging.md rename to docs/debugging.md diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..84649fe7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,57 @@ +# Linux MCP Server + +[![CI](https://github.com/rhel-lightspeed/linux-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/rhel-lightspeed/linux-mcp-server/actions/workflows/ci.yml) +[![Coverage](https://codecov.io/gh/rhel-lightspeed/linux-mcp-server/graph/badge.svg?token=TtUkG1y0rx)](https://codecov.io/gh/rhel-lightspeed/linux-mcp-server) +[![PyPI](https://img.shields.io/pypi/v/linux-mcp-server?label=PyPI)](https://pypi.org/project/linux-mcp-server) + +A Model Context Protocol (MCP) server for read-only Linux system administration, diagnostics, and troubleshooting on RHEL-based systems. + +## Features + +- **Read-Only Operations**: All tools are strictly read-only for safe diagnostics +- **Remote SSH Execution**: Execute commands on remote systems via SSH with key-based authentication +- **Multi-Host Management**: Connect to different remote hosts in the same session +- **Comprehensive Diagnostics**: System info, services, processes, logs, network, and storage +- **Configurable Log Access**: Control which log files can be accessed via environment variables +- **RHEL/systemd Focused**: Optimized for Red Hat Enterprise Linux systems + +## Quick Start + +```bash +pip install linux-mcp-server +linux-mcp-server +``` + +See the [Installation Guide](install.md) for detailed setup instructions. + +## Available Tools + +| Category | Tools | +|----------|-------| +| **System Info** | `get_system_information`, `get_cpu_information`, `get_memory_information`, `get_disk_usage`, `get_hardware_information` | +| **Services** | `list_services`, `get_service_status`, `get_service_logs` | +| **Processes** | `list_processes`, `get_process_info` | +| **Logs** | `get_journal_logs`, `get_audit_logs`, `read_log_file` | +| **Network** | `get_network_interfaces`, `get_network_connections`, `get_listening_ports` | +| **Storage** | `list_block_devices`, `list_directories`, `list_files`, `read_file` | + +## Key Components + +- **FastMCP Server**: Core MCP protocol server handling tool registration and invocation +- **Tool Categories**: Six categories of read-only diagnostic tools +- **SSH Executor**: Routes commands to local subprocess or remote SSH execution with connection pooling +- **Audit Logger**: Comprehensive logging in both human-readable and JSON formats with automatic rotation +- **Multi-Target Execution**: Single server instance can execute commands on local system or multiple remote hosts + +## Configuration + +Key environment variables: + +| Variable | Description | +|----------|-------------| +| `LINUX_MCP_ALLOWED_LOG_PATHS` | Comma-separated list of log files that can be accessed | +| `LINUX_MCP_LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | +| `LINUX_MCP_SSH_KEY_PATH` | Path to SSH private key for remote execution | +| `LINUX_MCP_USER` | Username used for SSH connections (optional) | + +See [Installation](install.md) for complete configuration details. diff --git a/docs/Install.md b/docs/install.md similarity index 97% rename from docs/Install.md rename to docs/install.md index e590ff10..08099ca6 100644 --- a/docs/Install.md +++ b/docs/install.md @@ -397,10 +397,9 @@ This section explains issues that may be present when using the MCP server to in ## Additional Resources -- **[Usage Guide](Usage.md):** Detailed guide on using all available tools -- **[Contributing](CONTRIBUTING.md):** Development workflow and guidelines -- **[Debugging](Debugging.md):** Information for debugging and fixing common problems -- **Main [README](../README.md):** Project overview and architecture +- **[Usage Guide](usage.md):** Detailed guide on using all available tools +- **[Contributing](contributing.md):** Development workflow and guidelines +- **[Debugging](debugging.md):** Information for debugging and fixing common problems - **[MCP Documentation](https://modelcontextprotocol.io/)** - **[MCP Inspector](https://github.com/modelcontextprotocol/inspector)** diff --git a/docs/Usage.md b/docs/usage.md similarity index 97% rename from docs/Usage.md rename to docs/usage.md index 4e296879..71225677 100644 --- a/docs/Usage.md +++ b/docs/usage.md @@ -7,7 +7,7 @@ This guide provides detailed instructions on how to use the Linux MCP Server for ### Prerequisites Before using the MCP server, you need to either install it or have the necessary tools to run it on-demand (uvx). -See [Installation](Install.md) for complete installation instructions. +See [Installation](install.md) for complete installation instructions. **Quick install with pip:** ```bash @@ -32,7 +32,7 @@ pip install linux-mcp-server For the best experience, integrate the MCP server with an AI Agent of your preference. #### For Claude Desktop -See [Claude Desktop Integration](Install.md#claude-desktop-integration). +See [Claude Desktop Integration](install.md#claude-desktop-integration). ## Available Tools @@ -209,7 +209,7 @@ Lists immediate subdirectories under a specified path with flexible sorting opti ## Configuration -See the [Installation Guide](Install.md#configuration-file-location) for configuration details including environment variables and AI agent integration. +See the [Installation Guide](install.md#configuration-file-location) for configuration details including environment variables and AI agent integration. ## Example Troubleshooting Sessions @@ -267,7 +267,7 @@ Run the MCP server with the minimum required privileges. Consider: ## Troubleshooting -See [Troubleshooting](Install.md#troubleshooting) for detailed troubleshooting, security considerations, and permission setup. +See [Troubleshooting](install.md#troubleshooting) for detailed troubleshooting, security considerations, and permission setup. ## Best Practices diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..3e2b29ce --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,76 @@ +site_name: Linux MCP Server +site_description: MCP server for read-only Linux system administration, diagnostics, and troubleshooting +site_url: https://rhel-lightspeed.github.io/linux-mcp-server/ +repo_url: https://github.com/rhel-lightspeed/linux-mcp-server +repo_name: rhel-lightspeed/linux-mcp-server + +theme: + name: material + features: + - navigation.instant + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - content.code.copy + - content.code.annotate + - search.suggest + - search.highlight + palette: + # Light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: red + accent: red + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: red + accent: red + toggle: + icon: material/brightness-4 + name: Switch to light mode + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: [src] + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.tabbed: + alternate_style: true + - tables + - toc: + permalink: true + +nav: + - Home: index.md + - Getting Started: + - Installation: install.md + - Usage: usage.md + - Reference: + - Architecture: architecture.md + - Debugging: debugging.md + - Development: + - Contributing: contributing.md + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/rhel-lightspeed/linux-mcp-server diff --git a/pyproject.toml b/pyproject.toml index dfa4f5c8..f61f323b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,12 @@ test = [ "pytest>=8.0.0", ] +docs = [ + "mkdocs>=1.6.1", + "mkdocs-material>=9.7.1", + "mkdocstrings[python]>=1.0.0", +] + [tool.hatch.build.targets.wheel] packages = ["src/linux_mcp_server"] diff --git a/uv.lock b/uv.lock index 87679ebc..982e3d1d 100644 --- a/uv.lock +++ b/uv.lock @@ -81,6 +81,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, ] +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -99,6 +108,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] +[[package]] +name = "backrefs" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, +] + [[package]] name = "bcrypt" version = "5.0.0" @@ -708,6 +731,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/82/72401d09dc27c27fdf72ad6c2fe331e553e3c3646e01b5ff16473191033d/fastmcp-2.14.1-py3-none-any.whl", hash = "sha256:fb3e365cc1d52573ab89caeba9944dd4b056149097be169bce428e011f0a57e5", size = 412176, upload-time = "2025-12-15T02:26:25.356Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffe" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -918,6 +965,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -1003,6 +1062,11 @@ dev = [ { name = "pytest-randomly" }, { name = "ruff" }, ] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] lint = [ { name = "pyright" }, { name = "ruff" }, @@ -1037,6 +1101,11 @@ dev = [ { name = "pytest-randomly" }, { name = "ruff" }, ] +docs = [ + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-material", specifier = ">=9.7.1" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.0" }, +] lint = [ { name = "pyright" }, { name = "ruff" }, @@ -1123,6 +1192,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, ] +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1135,6 +1213,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -1181,6 +1344,135 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/13/10bbf9d56565fd91b91e6f5a8cd9b9d8a2b101c4e8ad6eeafa35a706301d/mkdocstrings-1.0.0.tar.gz", hash = "sha256:351a006dbb27aefce241ade110d3cd040c1145b7a3eb5fd5ac23f03ed67f401a", size = 101086, upload-time = "2025-11-27T15:39:40.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/fc/80aa31b79133634721cf7855d37b76ea49773599214896f2ff10be03de2a/mkdocstrings-1.0.0-py3-none-any.whl", hash = "sha256:4c50eb960bff6e05dfc631f6bc00dfabffbcb29c5ff25f676d64daae05ed82fa", size = 35135, upload-time = "2025-11-27T15:39:39.301Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, +] + [[package]] name = "more-itertools" version = "10.8.0" @@ -1289,6 +1581,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + [[package]] name = "parso" version = "0.8.5" @@ -1307,6 +1608,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "pathvalidate" version = "3.3.1" @@ -1660,6 +1970,19 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/35/e3814a5b7df295df69d035cfb8aab78b2967cdf11fcfae7faed726b66664/pymdown_extensions-10.20.tar.gz", hash = "sha256:5c73566ab0cf38c6ba084cb7c5ea64a119ae0500cce754ccb682761dfea13a52", size = 852774, upload-time = "2025-12-31T19:59:42.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/10/47caf89cbb52e5bb764696fd52a8c591a2f0e851a93270c05a17f36000b5/pymdown_extensions-10.20-py3-none-any.whl", hash = "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f", size = 268733, upload-time = "2025-12-31T19:59:40.652Z" }, +] + [[package]] name = "pyperclip" version = "1.11.0" @@ -1752,6 +2075,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/3e/a4a9227807b56869790aad3e24472a554b585974fe7e551ea350f50897ae/pytest_randomly-4.0.1-py3-none-any.whl", hash = "sha256:e0dfad2fd4f35e07beff1e47c17fbafcf98f9bf4531fd369d9260e2f858bfcb7", size = 8304, upload-time = "2025-09-12T15:22:58.946Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1874,6 +2209,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "redis" version = "7.1.0" @@ -2111,6 +2458,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -2277,6 +2633,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14" From ed0a3586f91623e8c4becbc581fbcc5c6c54ad9d Mon Sep 17 00:00:00 2001 From: Major Hayden Date: Mon, 5 Jan 2026 09:23:35 -0600 Subject: [PATCH 05/34] Add GitHub Actions workflow for documentation deployment (#135) Add workflow to automatically build and deploy documentation to GitHub Pages when changes are pushed to main branch. Triggers on changes to: - docs/** - src/** - mkdocs.yml - pyproject.toml Also supports manual workflow_dispatch for on-demand builds. Signed-off-by: Major Hayden --- .github/workflows/docs.yml | 62 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 ++ 2 files changed, 64 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..acb0bb10 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,62 @@ +name: Documentation + +on: + push: + branches: + - main + paths: + - "docs/**" + - "src/**" + - "mkdocs.yml" + - "pyproject.toml" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6.0.1 + + - name: Install Python + uses: actions/setup-python@v6.1.0 + with: + python-version-file: pyproject.toml + + - name: Install uv + uses: astral-sh/setup-uv@v7.1.6 + with: + enable-cache: true + prune-cache: false + + - name: Install dependencies + run: uv sync --locked --group docs + + - name: Build documentation + run: uv run --locked mkdocs build --strict + + - name: Upload artifact + uses: actions/upload-pages-artifact@v4.0.0 + with: + path: site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4.0.5 diff --git a/pyproject.toml b/pyproject.toml index f61f323b..690ca60d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,9 +132,11 @@ output = "coverage/coverage.xml" [tool.gha-update] tag-only = [ "actions/checkout", + "actions/deploy-pages", "actions/download-artifact", "actions/setup-python", "actions/upload-artifact", + "actions/upload-pages-artifact", "astral-sh/setup-uv", "codecov/codecov-action", "pypa/gh-action-pypi-publish", From 664bf36e1f0b9010d538d47f667078ef4eda750d Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Mon, 5 Jan 2026 10:32:57 -0500 Subject: [PATCH 06/34] Update GitHub Actions (#137) --- .github/workflows/build-publish.yml | 6 +++--- .github/workflows/ci.yml | 14 +++++++------- .github/workflows/ci_installation.yaml | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml index 8f07214a..c8aeb5fe 100644 --- a/.github/workflows/build-publish.yml +++ b/.github/workflows/build-publish.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5.0.0 + uses: actions/checkout@v6.0.1 with: fetch-depth: 0 @@ -36,7 +36,7 @@ jobs: run: python -m build --sdist --wheel - name: Upload artifacts - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v6.0.0 with: name: artifacts path: dist @@ -57,7 +57,7 @@ jobs: steps: - name: Download artifacts - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@v7.0.0 with: name: artifacts path: dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e02546d..6111cd7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,15 +38,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5.0.0 + uses: actions/checkout@v6.0.1 - name: Install Python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@v6.1.0 with: python-version-file: pyproject.toml - name: Install uv - uses: astral-sh/setup-uv@v7.1.0 + uses: astral-sh/setup-uv@v7.1.6 with: enable-cache: true prune-cache: false @@ -83,15 +83,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5.0.0 + uses: actions/checkout@v6.0.1 - name: Install Python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@v6.1.0 with: python-version-file: pyproject.toml - name: Install uv - uses: astral-sh/setup-uv@v7.1.0 + uses: astral-sh/setup-uv@v7.1.6 with: enable-cache: true prune-cache: false @@ -112,7 +112,7 @@ jobs: run: uv run --locked pytest --cov-report=xml - name: Upload coverage report - uses: codecov/codecov-action@v5.5.1 + uses: codecov/codecov-action@v5.5.2 with: env_vars: OS,PYTHON disable_search: true diff --git a/.github/workflows/ci_installation.yaml b/.github/workflows/ci_installation.yaml index acdcbe94..d7d22741 100644 --- a/.github/workflows/ci_installation.yaml +++ b/.github/workflows/ci_installation.yaml @@ -41,15 +41,15 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v5.0.0 + uses: actions/checkout@v6.0.1 - name: Install Python ${{ matrix.python-version }} - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@v6.1.0 with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v7.1.0 + uses: astral-sh/setup-uv@v7.1.6 with: enable-cache: true prune-cache: false From 2f146704254aaf12ee9556a0de804a1c46d2c2b3 Mon Sep 17 00:00:00 2001 From: Link Dupont Date: Mon, 5 Jan 2026 15:08:13 -0500 Subject: [PATCH 07/34] Run deploy only if repo is not a fork (#139) --- .github/workflows/docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index acb0bb10..c77d551c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -51,6 +51,7 @@ jobs: path: site deploy: + if: github.event.repository.fork == false environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} From 97dbd9a7362c47b3fbc9a5a82068c326926f8777 Mon Sep 17 00:00:00 2001 From: Major Hayden Date: Mon, 5 Jan 2026 14:25:29 -0600 Subject: [PATCH 08/34] docs: add API reference documentation with mkdocstrings (#136) - Add API Reference nav section to mkdocs.yml - Create docs/api/ with stub files for all modules - Configure mkdocstrings with google docstring style - Remove --strict from docs build (griffe warnings on **kwargs) API documentation is auto-generated from source code docstrings for: - Core: server, config, commands, audit - Tools: system_info, services, processes, logs, network, storage - Utilities: connection, formatters, parsers Signed-off-by: Major Hayden --- Makefile | 2 +- docs/api/audit.md | 6 ++++++ docs/api/commands.md | 6 ++++++ docs/api/config.md | 6 ++++++ docs/api/connection.md | 6 ++++++ docs/api/formatters.md | 6 ++++++ docs/api/index.md | 29 +++++++++++++++++++++++++++++ docs/api/parsers.md | 6 ++++++ docs/api/server.md | 6 ++++++ docs/api/tools/logs.md | 6 ++++++ docs/api/tools/network.md | 6 ++++++ docs/api/tools/processes.md | 6 ++++++ docs/api/tools/services.md | 6 ++++++ docs/api/tools/storage.md | 6 ++++++ docs/api/tools/system_info.md | 6 ++++++ mkdocs.yml | 24 ++++++++++++++++++++++++ 16 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 docs/api/audit.md create mode 100644 docs/api/commands.md create mode 100644 docs/api/config.md create mode 100644 docs/api/connection.md create mode 100644 docs/api/formatters.md create mode 100644 docs/api/index.md create mode 100644 docs/api/parsers.md create mode 100644 docs/api/server.md create mode 100644 docs/api/tools/logs.md create mode 100644 docs/api/tools/network.md create mode 100644 docs/api/tools/processes.md create mode 100644 docs/api/tools/services.md create mode 100644 docs/api/tools/storage.md create mode 100644 docs/api/tools/system_info.md diff --git a/Makefile b/Makefile index c07efa50..5a614224 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ clean: find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true docs: - uv run --locked --group docs mkdocs build --strict + uv run --locked --group docs mkdocs build docs-serve: uv run --locked --group docs mkdocs serve diff --git a/docs/api/audit.md b/docs/api/audit.md new file mode 100644 index 00000000..f49879d2 --- /dev/null +++ b/docs/api/audit.md @@ -0,0 +1,6 @@ +# Audit + +::: linux_mcp_server.audit + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/commands.md b/docs/api/commands.md new file mode 100644 index 00000000..bf2760a4 --- /dev/null +++ b/docs/api/commands.md @@ -0,0 +1,6 @@ +# Commands + +::: linux_mcp_server.commands + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/config.md b/docs/api/config.md new file mode 100644 index 00000000..f81e24ee --- /dev/null +++ b/docs/api/config.md @@ -0,0 +1,6 @@ +# Configuration + +::: linux_mcp_server.config + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/connection.md b/docs/api/connection.md new file mode 100644 index 00000000..676e89ed --- /dev/null +++ b/docs/api/connection.md @@ -0,0 +1,6 @@ +# Connection + +::: linux_mcp_server.connection + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/formatters.md b/docs/api/formatters.md new file mode 100644 index 00000000..10520dfe --- /dev/null +++ b/docs/api/formatters.md @@ -0,0 +1,6 @@ +# Formatters + +::: linux_mcp_server.formatters + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 00000000..d891d0ee --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,29 @@ +# API Reference + +This section provides auto-generated documentation from the source code docstrings. + +## Package Structure + +### Core Modules + +- **[Server](server.md)** - FastMCP server initialization +- **[Config](config.md)** - Configuration settings via Pydantic +- **[Commands](commands.md)** - Command registry and execution +- **[Audit](audit.md)** - Audit logging with rotation + +### Tools + +MCP tools organized by category: + +- **[System Info](tools/system_info.md)** - OS, CPU, memory, disk, hardware information +- **[Services](tools/services.md)** - Systemd service management +- **[Processes](tools/processes.md)** - Process listing and details +- **[Logs](tools/logs.md)** - Journal, audit, and log file access +- **[Network](tools/network.md)** - Network interfaces, connections, ports +- **[Storage](tools/storage.md)** - Block devices, directory and file listing + +### Utilities + +- **[Connection](connection.md)** - SSH connection pooling +- **[Formatters](formatters.md)** - Output formatting functions +- **[Parsers](parsers.md)** - Command output parsing diff --git a/docs/api/parsers.md b/docs/api/parsers.md new file mode 100644 index 00000000..1533e010 --- /dev/null +++ b/docs/api/parsers.md @@ -0,0 +1,6 @@ +# Parsers + +::: linux_mcp_server.parsers + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/server.md b/docs/api/server.md new file mode 100644 index 00000000..999cc703 --- /dev/null +++ b/docs/api/server.md @@ -0,0 +1,6 @@ +# Server + +::: linux_mcp_server.server + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/tools/logs.md b/docs/api/tools/logs.md new file mode 100644 index 00000000..ed528f6b --- /dev/null +++ b/docs/api/tools/logs.md @@ -0,0 +1,6 @@ +# Log Tools + +::: linux_mcp_server.tools.logs + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/tools/network.md b/docs/api/tools/network.md new file mode 100644 index 00000000..c2cff0e9 --- /dev/null +++ b/docs/api/tools/network.md @@ -0,0 +1,6 @@ +# Network Tools + +::: linux_mcp_server.tools.network + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/tools/processes.md b/docs/api/tools/processes.md new file mode 100644 index 00000000..84c53307 --- /dev/null +++ b/docs/api/tools/processes.md @@ -0,0 +1,6 @@ +# Process Tools + +::: linux_mcp_server.tools.processes + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/tools/services.md b/docs/api/tools/services.md new file mode 100644 index 00000000..bb5a2af5 --- /dev/null +++ b/docs/api/tools/services.md @@ -0,0 +1,6 @@ +# Service Tools + +::: linux_mcp_server.tools.services + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/tools/storage.md b/docs/api/tools/storage.md new file mode 100644 index 00000000..b1b23b76 --- /dev/null +++ b/docs/api/tools/storage.md @@ -0,0 +1,6 @@ +# Storage Tools + +::: linux_mcp_server.tools.storage + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/api/tools/system_info.md b/docs/api/tools/system_info.md new file mode 100644 index 00000000..c0046a50 --- /dev/null +++ b/docs/api/tools/system_info.md @@ -0,0 +1,6 @@ +# System Info Tools + +::: linux_mcp_server.tools.system_info + options: + show_root_heading: true + show_root_full_path: false diff --git a/mkdocs.yml b/mkdocs.yml index 3e2b29ce..de53a17c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,12 @@ plugins: handlers: python: paths: [src] + options: + docstring_style: google + show_signature_annotations: true + separate_signature: true + import: + - https://docs.python.org/3/objects.inv markdown_extensions: - admonition @@ -67,6 +73,24 @@ nav: - Reference: - Architecture: architecture.md - Debugging: debugging.md + - API Reference: + - api/index.md + - Core: + - Server: api/server.md + - Config: api/config.md + - Commands: api/commands.md + - Audit: api/audit.md + - Tools: + - System Info: api/tools/system_info.md + - Services: api/tools/services.md + - Processes: api/tools/processes.md + - Logs: api/tools/logs.md + - Network: api/tools/network.md + - Storage: api/tools/storage.md + - Utilities: + - Connection: api/connection.md + - Formatters: api/formatters.md + - Parsers: api/parsers.md - Development: - Contributing: contributing.md From 94926dd6b66ad211eb0b8750be26f653555f5071 Mon Sep 17 00:00:00 2001 From: Major Hayden Date: Mon, 5 Jan 2026 14:28:16 -0600 Subject: [PATCH 09/34] fix(types): add type annotations for **kwargs parameters (#138) Add type annotations to fix griffe warnings that prevented --strict mode in mkdocs build: - audit.py: Add types to AuditContext(**extra_fields) and return type - commands.py: Add types to run(**kwargs) and substitute_command_args(**kwargs) Signed-off-by: Major Hayden --- src/linux_mcp_server/audit.py | 6 +++--- src/linux_mcp_server/commands.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/linux_mcp_server/audit.py b/src/linux_mcp_server/audit.py index 4f54ed47..65259979 100644 --- a/src/linux_mcp_server/audit.py +++ b/src/linux_mcp_server/audit.py @@ -88,7 +88,7 @@ def sanitize_parameters(params: dict[str, t.Any]) -> dict[str, t.Any]: @contextmanager -def AuditContext(**extra_fields): +def AuditContext(**extra_fields: t.Any) -> t.Generator[logging.LoggerAdapter, None, None]: """ Context manager for adding extra fields to all log records. @@ -97,10 +97,10 @@ def AuditContext(**extra_fields): logger.info("Starting operation") Args: - **extra_fields: Additional fields to add to log records + **extra_fields: Additional fields to add to log records. Yields: - Logger with extra fields + logging.LoggerAdapter: Logger adapter with extra fields attached. """ logger = logging.getLogger() diff --git a/src/linux_mcp_server/commands.py b/src/linux_mcp_server/commands.py index fd891c82..a33b0724 100644 --- a/src/linux_mcp_server/commands.py +++ b/src/linux_mcp_server/commands.py @@ -31,7 +31,7 @@ class CommandSpec(BaseModel): fallback: tuple[str, ...] | None = None optional_flags: Mapping[str, tuple[str, ...]] | None = None - async def run(self, host: str | None = None, **kwargs) -> tuple[int, str, str]: + async def run(self, host: str | None = None, **kwargs: object) -> tuple[int, str, str]: """Run the command with optional fallback. Args: @@ -287,7 +287,7 @@ def get_command(name: str, subcommand: str = "default") -> CommandSpec: raise KeyError(f"Subcommand '{subcommand}' not found for '{name}'. Available: {available}") from e -def substitute_command_args(args: Sequence[str], **kwargs) -> tuple[str, ...]: +def substitute_command_args(args: Sequence[str], **kwargs: object) -> tuple[str, ...]: """Substitute placeholder values in command arguments. Args: From 457c3720eba3ef1b61ca4718330005aa2ac29625 Mon Sep 17 00:00:00 2001 From: Major Hayden Date: Mon, 5 Jan 2026 15:29:14 -0600 Subject: [PATCH 10/34] docs: reorganize installation guide into focused pages (#140) docs: reorganize installation guide with improved clarity Reorganize documentation into focused pages: - Split installation guide into focused, single-purpose pages - Add quick start to docs homepage for immediate onboarding - Prefer pip installation (marked as recommended) over containers Improve SSH and container documentation clarity: - Add layered SSH documentation (quick setup + expandable prerequisites) - Document remote host parameter formats (alias, user@host, hostname) - Explain container UID 1001 permissions with expandable details - Add Docker vs Podman differences callout - Add verification steps after pip/uv install with PATH troubleshooting - Add SSH Connection Issues troubleshooting section - Fix broken internal documentation links Add MCP client configurations: - Claude Code, Codex, Cursor, Gemini CLI, Goose, opencode, VS Code/Copilot, Windsurf - Restructure environment variables into logical categories - Add tip callout linking to environment variables reference Signed-off-by: Major Hayden --- docs/clients.md | 596 ++++++++++++++++++++++++++++++++++++++++ docs/index.md | 10 +- docs/install.md | 460 ++++++++++++------------------- docs/troubleshooting.md | 340 +++++++++++++++++++++++ docs/usage.md | 6 +- mkdocs.yml | 4 +- 6 files changed, 1120 insertions(+), 296 deletions(-) create mode 100644 docs/clients.md create mode 100644 docs/troubleshooting.md diff --git a/docs/clients.md b/docs/clients.md new file mode 100644 index 00000000..868ad972 --- /dev/null +++ b/docs/clients.md @@ -0,0 +1,596 @@ +# Client Configuration + +Configure your MCP client to use the Linux MCP Server. + +!!! tip "Environment Variables" + Most configurations require environment variables for SSH connections and features. See [Environment Variables](#environment-variables) for the full reference. + +**MCP Client Configuration Examples** + +- [Claude Code](#claude-code) +- [Claude Desktop](#claude-desktop) +- [Codex](#codex) +- [Cursor](#cursor) +- [Gemini CLI](#gemini-cli) +- [Goose](#goose) +- [opencode](#opencode) +- [VS Code / Copilot](#vs-code-copilot) +- [Windsurf](#windsurf) + +--- + +## Claude Code + +[Claude Code](https://docs.anthropic.com/en/docs/claude-code) is Anthropic's official CLI tool. + +### Configuration + +Edit `~/.claude.json`: + +=== "pip/uv (Recommended)" + + ```json + { + "mcpServers": { + "linux-mcp-server": { + "command": "~/.local/bin/linux-mcp-server", + "args": [], + "env": { + "LINUX_MCP_USER": "your-ssh-username" + } + } + } + } + ``` + +=== "Container (Podman)" + + ```json + { + "mcpServers": { + "linux-mcp-server": { + "command": "podman", + "args": [ + "run", "--rm", "--interactive", + "--userns", "keep-id:uid=1001,gid=0", + "-e", "LINUX_MCP_KEY_PASSPHRASE", + "-e", "LINUX_MCP_USER", + "-v", "/home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro,Z", + "-v", "/home/YOUR_USER/.local/share/linux-mcp-server/logs:/var/lib/mcp/.local/share/linux-mcp-server/logs:rw,Z", + "quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest" + ], + "env": { + "LINUX_MCP_KEY_PASSPHRASE": "", + "LINUX_MCP_USER": "YOUR_USER" + } + } + } + } + ``` + + !!! warning "Replace Paths" + Replace `YOUR_USER` with your actual username. + +--- + +## Claude Desktop + +### Configuration File Location + +Edit your Claude Desktop configuration file: + +- **Linux:** `~/.config/Claude/claude_desktop_config.json` +- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` + +### Configuration Examples + +The value for `command` will vary depending on how `linux-mcp-server` was installed. + +=== "pip (Recommended)" + + ```json + { + "mcpServers": { + "linux-diagnostics": { + "command": "~/.local/bin/linux-mcp-server", + "args": [], + "env": { + "LINUX_MCP_ALLOWED_LOG_PATHS": "/var/log/messages,/var/log/secure,/var/log/audit/audit.log" + } + } + } + } + ``` + +=== "uv" + + ```json + { + "mcpServers": { + "linux-diagnostics": { + "command": "~/.local/bin/linux-mcp-server", + "args": [], + "env": { + "LINUX_MCP_ALLOWED_LOG_PATHS": "/var/log/messages,/var/log/secure,/var/log/audit/audit.log" + } + } + } + } + ``` + +=== "Container (Podman)" + + ```json + { + "mcpServers": { + "Linux Tools": { + "command": "podman", + "args": [ + "run", + "--rm", + "--interactive", + "--userns", "keep-id:uid=1001,gid=0", + "-e", "LINUX_MCP_KEY_PASSPHRASE", + "-e", "LINUX_MCP_USER", + "-v", "/home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro,Z", + "-v", "/home/YOUR_USER/.local/share/linux-mcp-server/logs:/var/lib/mcp/.local/share/linux-mcp-server/logs:rw,Z", + "quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest" + ], + "env": { + "LINUX_MCP_KEY_PASSPHRASE": "", + "LINUX_MCP_USER": "YOUR_USER" + } + } + } + } + ``` + + !!! warning "Replace Paths" + Replace `YOUR_USER` with your actual username and adjust paths as needed. + +### Applying Configuration Changes + +After editing the configuration file: + +1. **Restart Claude Desktop** completely (quit and relaunch) +2. Look for the MCP server indicator in Claude Desktop +3. The server should appear in the list of available tools + +--- + +## Codex + +[Codex](https://github.com/openai/codex) is OpenAI's CLI tool. + +### Configuration + +Edit `~/.codex/config.toml`: + +=== "pip/uv (Recommended)" + + ```toml + [mcp_servers.linux-mcp-server] + command = "~/.local/bin/linux-mcp-server" + args = [] + + [mcp_servers.linux-mcp-server.env] + LINUX_MCP_USER = "your-ssh-username" + ``` + +=== "Container (Podman)" + + ```toml + [mcp_servers.linux-mcp-server] + command = "podman" + args = [ + "run", "--rm", "--interactive", + "--userns", "keep-id:uid=1001,gid=0", + "-e", "LINUX_MCP_KEY_PASSPHRASE", + "-e", "LINUX_MCP_USER", + "-v", "/home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro,Z", + "-v", "/home/YOUR_USER/.local/share/linux-mcp-server/logs:/var/lib/mcp/.local/share/linux-mcp-server/logs:rw,Z", + "quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest" + ] + + [mcp_servers.linux-mcp-server.env] + LINUX_MCP_KEY_PASSPHRASE = "" + LINUX_MCP_USER = "YOUR_USER" + ``` + + !!! warning "Replace Paths" + Replace `YOUR_USER` with your actual username. + +--- + +## Cursor + +[Cursor](https://cursor.sh/) is an AI-powered code editor with MCP support. + +### Configuration + +Edit `~/.cursor/mcp.json`: + +=== "pip/uv (Recommended)" + + ```json + { + "mcpServers": { + "linux-mcp-server": { + "command": "~/.local/bin/linux-mcp-server", + "args": [], + "env": { + "LINUX_MCP_USER": "your-ssh-username" + } + } + } + } + ``` + +=== "Container (Podman)" + + ```json + { + "mcpServers": { + "linux-mcp-server": { + "command": "podman", + "args": [ + "run", "--rm", "--interactive", + "--userns", "keep-id:uid=1001,gid=0", + "-e", "LINUX_MCP_KEY_PASSPHRASE", + "-e", "LINUX_MCP_USER", + "-v", "/home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro,Z", + "-v", "/home/YOUR_USER/.local/share/linux-mcp-server/logs:/var/lib/mcp/.local/share/linux-mcp-server/logs:rw,Z", + "quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest" + ], + "env": { + "LINUX_MCP_KEY_PASSPHRASE": "", + "LINUX_MCP_USER": "YOUR_USER" + } + } + } + } + ``` + + !!! warning "Replace Paths" + Replace `YOUR_USER` with your actual username. + +--- + +## Gemini CLI + +[Gemini CLI](https://github.com/google-gemini/gemini-cli) is Google's command-line tool for Gemini. + +### Configuration + +Edit `~/.gemini/settings.json`: + +=== "pip/uv (Recommended)" + + ```json + { + "mcpServers": { + "linux-mcp-server": { + "command": "~/.local/bin/linux-mcp-server", + "args": [], + "env": { + "LINUX_MCP_USER": "your-ssh-username" + } + } + } + } + ``` + +=== "Container (Podman)" + + ```json + { + "mcpServers": { + "linux-mcp-server": { + "command": "podman", + "args": [ + "run", "--rm", "--interactive", + "--userns", "keep-id:uid=1001,gid=0", + "-e", "LINUX_MCP_KEY_PASSPHRASE", + "-e", "LINUX_MCP_USER", + "-v", "/home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro,Z", + "-v", "/home/YOUR_USER/.local/share/linux-mcp-server/logs:/var/lib/mcp/.local/share/linux-mcp-server/logs:rw,Z", + "quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest" + ], + "env": { + "LINUX_MCP_KEY_PASSPHRASE": "", + "LINUX_MCP_USER": "YOUR_USER" + } + } + } + } + ``` + + !!! warning "Replace Paths" + Replace `YOUR_USER` with your actual username. + +--- + +## Goose + +[Goose](https://block.github.io/goose/) is Block's open-source AI agent. + +### Configuration Examples + +=== "pip/uv (Recommended)" + + ```yaml + extensions: + linux-tools: + enabled: true + type: stdio + name: linux-tools + description: Linux tools + cmd: ~/.local/bin/linux-mcp-server + envs: {} + env_keys: + - LINUX_MCP_KEY_PASSPHRASE + - LINUX_MCP_USER + timeout: 30 + bundled: null + available_tools: [] + ``` + +=== "Container (Podman)" + + ```yaml + extensions: + linux-tools: + enabled: true + type: stdio + name: linux-tools + description: Linux tools + cmd: podman + args: + - run + - --rm + - --interactive + - --userns + - "keep-id:uid=1001,gid=0" + - -e + - LINUX_MCP_KEY_PASSPHRASE + - -e + - LINUX_MCP_USER + - -v + - /home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro + - -v + - /home/YOUR_USER/.local/share/linux-mcp-server/logs:/var/lib/mcp/.local/share/linux-mcp-server/logs:rw + - quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest + envs: {} + env_keys: + - LINUX_MCP_KEY_PASSPHRASE + - LINUX_MCP_USER + timeout: 30 + bundled: null + available_tools: [] + ``` + + !!! warning "Replace Paths" + Replace `YOUR_USER` with your actual username and adjust paths as needed. + +--- + +## opencode + +[opencode](https://opencode.ai/) is an AI-powered terminal coding assistant. + +### Configuration + +Edit `~/.config/opencode/opencode.json`: + +=== "pip/uv (Recommended)" + + ```json + { + "$schema": "https://opencode.ai/config.json", + "mcp": { + "linux-mcp-server": { + "type": "local", + "command": ["~/.local/bin/linux-mcp-server"], + "enabled": true, + "env": { + "LINUX_MCP_USER": "your-ssh-username" + } + } + } + } + ``` + +=== "Container (Podman)" + + ```json + { + "$schema": "https://opencode.ai/config.json", + "mcp": { + "linux-mcp-server": { + "type": "local", + "command": [ + "podman", "run", "--rm", "--interactive", + "--userns", "keep-id:uid=1001,gid=0", + "-e", "LINUX_MCP_KEY_PASSPHRASE", + "-e", "LINUX_MCP_USER", + "-v", "/home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro,Z", + "-v", "/home/YOUR_USER/.local/share/linux-mcp-server/logs:/var/lib/mcp/.local/share/linux-mcp-server/logs:rw,Z", + "quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest" + ], + "enabled": true, + "env": { + "LINUX_MCP_KEY_PASSPHRASE": "", + "LINUX_MCP_USER": "YOUR_USER" + } + } + } + } + ``` + + !!! warning "Replace Paths" + Replace `YOUR_USER` with your actual username. + +--- + +## VS Code / Copilot + +VS Code with GitHub Copilot supports MCP servers in agent mode. + +### Configuration + +Add to your VS Code `settings.json`: + +=== "pip/uv (Recommended)" + + ```json + { + "mcp": { + "servers": { + "linux-mcp-server": { + "command": "~/.local/bin/linux-mcp-server", + "args": [], + "env": { + "LINUX_MCP_USER": "your-ssh-username" + } + } + } + } + } + ``` + +=== "Container (Podman)" + + ```json + { + "mcp": { + "servers": { + "linux-mcp-server": { + "command": "podman", + "args": [ + "run", "--rm", "--interactive", + "--userns", "keep-id:uid=1001,gid=0", + "-e", "LINUX_MCP_KEY_PASSPHRASE", + "-e", "LINUX_MCP_USER", + "-v", "/home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro,Z", + "-v", "/home/YOUR_USER/.local/share/linux-mcp-server/logs:/var/lib/mcp/.local/share/linux-mcp-server/logs:rw,Z", + "quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest" + ], + "env": { + "LINUX_MCP_KEY_PASSPHRASE": "", + "LINUX_MCP_USER": "YOUR_USER" + } + } + } + } + } + ``` + + !!! warning "Replace Paths" + Replace `YOUR_USER` with your actual username. + +!!! tip + Use the command palette (`Ctrl+Shift+P`) and search for "MCP" to manage servers. + +--- + +## Windsurf + +[Windsurf](https://codeium.com/windsurf) is Codeium's AI-powered IDE. + +### Configuration + +Edit `~/.codeium/windsurf/mcp_config.json`: + +=== "pip/uv (Recommended)" + + ```json + { + "mcpServers": { + "linux-mcp-server": { + "command": "~/.local/bin/linux-mcp-server", + "args": [], + "env": { + "LINUX_MCP_USER": "your-ssh-username" + } + } + } + } + ``` + +=== "Container (Podman)" + + ```json + { + "mcpServers": { + "linux-mcp-server": { + "command": "podman", + "args": [ + "run", "--rm", "--interactive", + "--userns", "keep-id:uid=1001,gid=0", + "-e", "LINUX_MCP_KEY_PASSPHRASE", + "-e", "LINUX_MCP_USER", + "-v", "/home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro,Z", + "-v", "/home/YOUR_USER/.local/share/linux-mcp-server/logs:/var/lib/mcp/.local/share/linux-mcp-server/logs:rw,Z", + "quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest" + ], + "env": { + "LINUX_MCP_KEY_PASSPHRASE": "", + "LINUX_MCP_USER": "YOUR_USER" + } + } + } + } + ``` + + !!! warning "Replace Paths" + Replace `YOUR_USER` with your actual username. + +--- + +## Environment Variables + +Configure these environment variables in the `env` section of your client configuration. + +### SSH Connection Settings + +| Variable | Description | Example | +|----------|-------------|---------| +| `LINUX_MCP_USER` | Default username for SSH connections | `admin` | +| `LINUX_MCP_SSH_KEY_PATH` | Path to SSH private key | `~/.ssh/id_ed25519` | +| `LINUX_MCP_KEY_PASSPHRASE` | Passphrase for encrypted SSH key | (set value in env) | +| `LINUX_MCP_SEARCH_FOR_SSH_KEY` | Auto-discover keys in `~/.ssh` | `yes` | +| `LINUX_MCP_COMMAND_TIMEOUT` | SSH command timeout in seconds (default: 30) | `60` | + +### SSH Security Settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `LINUX_MCP_VERIFY_HOST_KEYS` | `False` | Verify remote host identity via known_hosts | +| `LINUX_MCP_KNOWN_HOSTS_PATH` | (none) | Custom path to known_hosts file | + +### Feature-Specific Settings + +| Variable | Required For | Description | Example | +|----------|--------------|-------------|---------| +| `LINUX_MCP_ALLOWED_LOG_PATHS` | `read_log_file` tool | Comma-separated allowlist of log files | `/var/log/messages,/var/log/secure` | + +### Logging Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `LINUX_MCP_LOG_DIR` | `~/.local/share/linux-mcp-server/logs` | Server log directory | +| `LINUX_MCP_LOG_LEVEL` | `INFO` | Log verbosity (`DEBUG`, `INFO`, `WARNING`) | +| `LINUX_MCP_LOG_RETENTION_DAYS` | `10` | Days to keep log files | + +--- + +## Other MCP Clients + +The Linux MCP Server works with any MCP-compatible client. The general configuration pattern is: + +1. **Command**: Path to `linux-mcp-server` executable (or `podman`/`docker` for container) +2. **Arguments**: Empty for native install, or container run arguments +3. **Environment**: Set variables from the table above as needed + +Refer to your client's documentation for the specific configuration format. diff --git a/docs/index.md b/docs/index.md index 84649fe7..331be845 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,12 +17,16 @@ A Model Context Protocol (MCP) server for read-only Linux system administration, ## Quick Start +**1. Install** ```bash -pip install linux-mcp-server -linux-mcp-server +pip install --user linux-mcp-server ``` -See the [Installation Guide](install.md) for detailed setup instructions. +**2. Configure your MCP client** ([details](clients.md)) + +**3. Start diagnosing Linux systems!** + +See the [Installation Guide](install.md) for container installs, SSH setup, and more. ## Available Tools diff --git a/docs/install.md b/docs/install.md index 08099ca6..79643659 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,16 +1,38 @@ # Installation Guide -If you want to use the Linux MCP Server with LLM clients, follow these instructions to install the MCP server permanently on your system. +Get the Linux MCP Server running quickly with your favorite LLM client. **Table of Contents** -- [Claude Desktop Integration](#claude-desktop-integration) +- [Quick Start](#quick-start) +- [Installation Options](#installation-options) +- [SSH Configuration](#ssh-configuration) - [Platform Specific Notes](#platform-specific-notes) -- [Troubleshooting](#troubleshooting) -Linux MCP Server can be installed using `pip` or `uv`. Choose the installation that best suites your environment. +--- + +## Quick Start + +Get up and running in three steps: + +**1. Install** +```bash +pip install --user linux-mcp-server +``` + +**2. Configure SSH** (for remote hosts) -## Installation +Ensure SSH key-based authentication is set up for any remote hosts you want to manage. See [SSH Configuration](#ssh-configuration) for details. + +**3. Configure your MCP client** + +Add the server to your client configuration. See [Client Configuration](clients.md) for Claude Desktop, Goose, and other clients. + +--- + +## Installation Options + +The Linux MCP Server can be installed using pip, uv, or containers. Choose the method that best suits your environment. ### Prerequisites @@ -22,105 +44,178 @@ Linux MCP Server can be installed using `pip` or `uv`. Choose the installation t pip install --user linux-mcp-server ``` -Run the server +**Verify installation:** ```bash -~/.local/bin/linux-mcp-server +~/.local/bin/linux-mcp-server --help ``` +??? failure "Command not found?" + + The `~/.local/bin` directory may not be in your PATH. + + **Quick fix:** Use the full path: + ```bash + ~/.local/bin/linux-mcp-server + ``` + + **Permanent fix:** Add to your shell config: + + === "bash" + ```bash + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc + source ~/.bashrc + ``` + + === "zsh" + ```bash + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc + source ~/.zshrc + ``` + + === "fish" + ```bash + fish_add_path ~/.local/bin + ``` + ### Install with `uv` -- [Install `uv` first](https://github.com/astral-sh/uv#installation). -- Run `uv tool update-shell` or manually add `~/.local/bin/` to `PATH`. +[Install `uv` first](https://github.com/astral-sh/uv#installation), then: ```bash uv tool install linux-mcp-server ``` -Run the server +**Verify installation:** + +```bash +linux-mcp-server --help ``` -linux-mcp-server + +!!! tip + If the command is not found, run `uv tool update-shell` to add `~/.local/bin` to your PATH, then restart your shell. + +!!! note + It is not necessary to run `linux-mcp-server` directly for normal use. The LLM client will handle starting and stopping the server. + +### Install with Container (Podman) + +A container runtime such as [Podman](https://podman-desktop.io) is required. + +**Container image:** +``` +quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest ``` -The server should start and display initialization messages. Press `Ctrl+C`, then `Return` to stop it. +#### Container Setup for SSH Keys -> [!Note] -> It is not necessary to run `linux-mcp-server` directly for normal use. The LLM client will handle starting and stopping `linux-mcp-server`. +The container needs access to your SSH keys for remote connections. Set up the required directories and permissions: + +```bash +# Create directories +mkdir -p ~/.local/share/linux-mcp-server/logs + +# Copy your SSH key and set ownership +cp ~/.ssh/id_ed25519 ~/.local/share/linux-mcp-server/ +sudo chown -R 1001:1001 ~/.local/share/linux-mcp-server/ +``` + +??? info "Why UID 1001? Understanding container permissions" + + **The container runs as a non-root user** (UID 1001) for security. Files mounted from your host must be readable by this user. + + **What's happening:** + + - The container process runs as user ID `1001`, not your host user + - Mounted SSH keys must be owned by `1001` to be readable + - Log directory must be writable by `1001` to store logs + + **If you see permission errors:** + + ```bash + # Check current ownership + ls -la ~/.local/share/linux-mcp-server/ + + # Fix ownership (should show 1001 as owner) + sudo chown -R 1001:1001 ~/.local/share/linux-mcp-server/ + ``` + +??? warning "Docker vs Podman differences" + + **Podman** uses `--userns keep-id:uid=1001,gid=0` to map user namespaces. + + **Docker** does NOT support this flag. When using Docker: + + - Remove the `--userns` parameter from the run command + - Ensure files are owned by UID 1001 on the host + - Create directories beforehand (Docker won't auto-create them) --- -## Claude Desktop Integration +## SSH Configuration -### Configuration File Location +### Quick Setup -Edit your Claude Desktop configuration file: -- **Linux:** `~/.config/Claude/claude_desktop_config.json` -- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` -- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` +1. Ensure passwordless SSH works: `ssh user@hostname "echo success"` +2. Add host aliases to `~/.ssh/config` for convenience +3. Set `LINUX_MCP_USER` environment variable if using a consistent username -### Configuration Example +??? info "SSH Key Prerequisites" -The value for `command` will vary depending on how `linux-mcp-server` was installed. + The MCP server requires **passwordless SSH authentication** (key-based, not password). -For installion with `pip`: + **Check if you have SSH keys:** + ```bash + ls -la ~/.ssh/id_* + ``` -```json -{ - "mcpServers": { - "linux-diagnostics": { - "command": "[path to venv]/bin/linux-mcp-server", - "args": [], - "env": { - "LINUX_MCP_ALLOWED_LOG_PATHS": "/var/log/messages,/var/log/secure,/var/log/audit/audit.log" - } - } - } -} -``` + **If no keys exist, generate them:** + ```bash + ssh-keygen -t ed25519 -C "your_email@example.com" + ``` -For installion with `uv`: - -```json -{ - "mcpServers": { - "linux-diagnostics": { - "command": "~/.local/bin/linux-mcp-server", - "args": [""], - "env": { - "LINUX_MCP_ALLOWED_LOG_PATHS": "/var/log/messages,/var/log/secure,/var/log/audit/audit.log" - } - } - } -} -``` + **Copy your key to a remote host:** + ```bash + ssh-copy-id user@hostname + ``` + + **Test the connection:** + ```bash + ssh user@hostname "echo 'SSH working!'" + ``` -### Environment Variables + If prompted for a password, key-based authentication is not configured correctly. -Configure these environment variables in the `env` section: +### Specifying Remote Hosts -| Variable | Required | Default | Description | Example | -|----------|----------|---------|-------------|---------| -| `LINUX_MCP_USER` | No | `None` | User name used when making remote connections over `ssh`. | `tljones` | -| `LINUX_MCP_LOG_DIR` | No | `~/.local/share/linux-mcp-server/logs` | Custom directory for MCP server logs | `/var/log/linux-mcp-server` | -| `LINUX_MCP_LOG_LEVEL` | No | INFO | Logging level for the MCP server | `INFO`, `DEBUG`, `WARNING` | -| `LINUX_MCP_LOG_RETENTION_DAYS` | No | 10 | Days to retain log files (default: 10) | `30` | -| `LINUX_MCP_ALLOWED_LOG_PATHS` | `None` | Yes* | Comma-separated list of log files that `read_log_file` can access | `/var/log/messages,/var/log/secure` | -| `LINUX_MCP_SSH_KEY_PATH` | No | `None` | Path to SSH private key for remote execution | `~/.ssh/id_ed25519` | -| `LINUX_MCP_KEY_PASSPHRASE` | No | `None` | Passphrase used to decrypt the SSH private key, if required | `` | -| `LINUX_MCP_SEARCH_FOR_SSH_KEY` | No | `False` | Whether to look in `~/.ssh` for SSH keys | yes | -| `LINUX_MCP_VERIFY_HOST_KEYS` | No | `False` | Verify identity of remote hosts when connecting over SSH. | yes | -| `LINUX_MCP_KNOWN_HOSTS_PATH` | No | `None` | Path to SSH known_hosts file | `~/.ssh/other_known_hosts` | -| `LINUX_MCP_COMMAND_TIMEOUT` | No | 30 | Max timeout for remote SSH commands | 60 | +When using MCP tools, the `host` parameter accepts several formats: -*Required if you want to use the `read_log_file` tool. +| Format | Example | Description | +|--------|---------|-------------| +| SSH alias | `webserver` | Uses settings from `~/.ssh/config` | +| user@host | `admin@10.0.0.50` | Direct connection with username | +| hostname | `server.example.com` | Uses `LINUX_MCP_USER` for username | -### Applying Configuration Changes +### Per-Host Configuration -After editing the configuration file: +Use `~/.ssh/config` for per-host connection settings: -1. **Restart Claude Desktop** completely (quit and relaunch) -2. Look for the MCP server indicator in Claude Desktop -3. The server should appear in the list of available tools +``` +# ~/.ssh/config +Host webserver + HostName 10.0.0.64 + User admin + +Host dbserver + HostName 10.0.0.128 + User postgres + Port 2222 +``` + +With this config, use `host="webserver"` in MCP tool calls instead of the full hostname. + +!!! tip + If `ssh-agent` is running, keys loaded into the session will be used automatically. --- @@ -175,231 +270,18 @@ Verify: `python --version` in Command Prompt or PowerShell --- -## Troubleshooting - -### Using MCP Inspector for Debugging - -The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is an official tool for testing and debugging MCP servers. - -**Install MCP Inspector:** - -**Note:** Requires Node.js to be installed on your system. - -```bash -npm install -g @modelcontextprotocol/inspector -``` - -**Run the inspector with your MCP server:** - -```bash -# For pip-installed version -mcp-inspector linux-mcp-server - -# For uvx version -mcp-inspector uvx linux-mcp-server - -# For development version -cd /path/to/linux-mcp-server -mcp-inspector uv run linux-mcp-server -``` - -The inspector provides a web UI where you can: -- View all available tools -- Test tool calls with different parameters -- See real-time request/response data -- Debug connection issues -- Inspect server capabilities - -### Local Debugging of Tool Calls - -You can test MCP server tools locally without Claude Desktop or the inspector. - -**Method 1: Interactive Python Session** - -```bash -# Activate your virtual environment first -source .venv/bin/activate # Linux/macOS -# OR -.venv\Scripts\activate # Windows - -# Start Python -python - -# Import and test tools ->>> from linux_mcp_server.tools import system_info ->>> import asyncio ->>> result = asyncio.run(system_info.get_system_information()) ->>> print(result) -``` - -**Method 2: Create a Test Script** - -Create a file `test_tool.py`: - -```python -import asyncio -from linux_mcp_server.tools import system_info, services - -async def main(): - # Test system info tool - print("=== System Info ===") - result = await system_info.get_system_information() - print(result) - - # Test service listing - print("\n=== Services ===") - result = await services.list_services() - print(result) - -if __name__ == "__main__": - asyncio.run(main()) -``` - -Run it: -```bash -python test_tool.py -``` - -**Method 3: Run pytest in verbose mode** - -```bash -# Run specific test -pytest tests/test_system_info.py -v - -# Run with output showing -pytest tests/test_system_info.py -v -s - -# Run all tests for a module -pytest tests/ -k "system_info" -v -``` - -### Common Installation Issues - -#### "command not found: linux-mcp-server" - -**Cause:** The package isn't installed or the installation directory isn't in your PATH. - -**Solutions:** -1. Verify installation: `pip show linux-mcp-server` -2. Try running as module: `python -m linux_mcp_server` -3. Check if pip install location is in PATH: - ```bash - pip show linux-mcp-server | grep Location - ``` -4. Add pip's bin directory to PATH, or use a virtual environment - -#### "No module named 'linux_mcp_server'" - -**Cause:** The package isn't installed in the current Python environment. - -**Solutions:** -1. Ensure you're using the correct Python: `which python` or `where python` -2. Install the package: `pip install linux-mcp-server` -3. If using virtual environment, make sure it's activated - -#### "Permission denied" when reading system logs - -**Cause:** The user running the MCP server doesn't have permission to read system logs. - -**Solutions:** -1. Add user to `adm` or `systemd-journal` group: - ```bash - sudo usermod -a -G adm $USER - sudo usermod -a -G systemd-journal $USER - ``` -2. Log out and log back in for group changes to take effect -3. Only whitelist log files that the user can read in `LINUX_MCP_ALLOWED_LOG_PATHS` - -#### "Permission denied" when reading a local application log file - -**Cause:** If the server throws an error when starting (e.g., PermissionError: [Errno 13] Permission denied: '.../server.log'), it's usually because the log file or its parent directory is owned by a different user (often root due to a previous sudo run). - -**Solutions:** -1. Verify the current user is the owner of the log directory: - ```bash - ls -ld /home/$USER/.local/share/linux-mcp-server/logs - ``` -2. If the owner is not $USER (e.g., if it shows root or a different User ID), reclaim the folder ownership: - ```bash - sudo chown -R $USER:$USER /home/$USER/.local/share/linux-mcp-server/logs - ``` - -#### Claude Desktop doesn't show the MCP server - -Common causes: -- **Syntax error:** Validate JSON at https://jsonlint.com/ -- **Wrong file location:** See [Configuration File Location](#configuration-file-location) -- **Command not in PATH:** Use full path in `command` field or ensure command is in PATH -- **Server won't start:** Test command manually; check Claude Desktop logs (`~/Library/Logs/Claude/` on macOS, `~/.config/Claude/logs/` on Linux, `%APPDATA%\Claude\logs\` on Windows) -- **Config not reloaded:** Completely quit and restart Claude Desktop - -#### ImportError or ModuleNotFoundError during development - -**Cause:** Dependencies aren't installed or virtual environment isn't activated. - -**Solutions:** -1. Ensure virtual environment is activated: - ```bash - source .venv/bin/activate # Linux/macOS - .venv\Scripts\activate # Windows - ``` -2. Reinstall dependencies: - ```bash - uv sync --group dev - # OR - pip install -e ".[dev]" - ``` - -### Platform-Specific Issues - -This section explains issues that may be present when using the MCP server to interact with a system that is not compatible. - -#### Linux: "systemctl: command not found" - -**Cause:** System doesn't use systemd (very old distributions or non-standard systems). - -**Solution:** This MCP server requires systemd to be available on the target system for cetain tools to function properly. -- The main use case is to troubleshoot modern RHEL-alike Linux systems (e.g. RHEL 9.x, 10.x, Fedora 40 and above, etc.) -- Consider upgrading to a modern Linux distribution (RHEL 7+, Fedora, etc.). - -#### macOS: Limited functionality warnings - -**Cause:** Some Linux-specific commands don't exist or behave differently on macOS. - -**Note:** This is expected. The MCP server is designed to diagnose Linux systems (see above). -- Some tools may work on macOS, but some may have reduced functionality or not work at all. - -#### Windows: Most or all tools not working - -**Cause:** The MCP server relies on Linux-specific tools (systemd, journalctl, etc.) that don't exist on Windows. - -**Solution:** This is expected behavior. The MCP server is not designed to diagnose Windows systems. -- On Windows, use the MCP server primarily for: - - Remote SSH execution to manage Linux servers - - Testing and development -- For local Windows management, use a Windows-specific MCP server - -### Getting Additional Help +## Having Problems? -1. **Check logs:** Server logs in `~/.local/share/linux-mcp-server/logs/`, Claude Desktop logs (see above) -2. **Enable debug:** Set `"LINUX_MCP_LOG_LEVEL": "DEBUG"` in config, restart your AI Agent (e.g. Claude Desktop) -3. **Test with MCP Inspector:** Isolate whether issue is with server or client -4. **Run the MCP server manually:** Make sure the MCP server does not crash upon start and is able to receive messages. -5. **Open an issue:** https://github.com/rhel-lightspeed/linux-mcp-server/issues - - Include: - - Your OS and version - - Python version - - Installation method used - - Error messages and logs - - Steps to reproduce +See the [Troubleshooting Guide](troubleshooting.md) for solutions to common issues. --- ## Additional Resources +- **[Client Configuration](clients.md):** Configure Claude Desktop, Goose, and other MCP clients +- **[Troubleshooting](troubleshooting.md):** Solutions for common issues - **[Usage Guide](usage.md):** Detailed guide on using all available tools - **[Contributing](contributing.md):** Development workflow and guidelines -- **[Debugging](debugging.md):** Information for debugging and fixing common problems - **[MCP Documentation](https://modelcontextprotocol.io/)** - **[MCP Inspector](https://github.com/modelcontextprotocol/inspector)** diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..a86a47c7 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,340 @@ +# Troubleshooting + +Solutions for common issues when installing and using the Linux MCP Server. + +**Quick Links** + +- [Using MCP Inspector](#using-mcp-inspector-for-debugging) +- [Common Installation Issues](#common-installation-issues) +- [SSH Connection Issues](#ssh-connection-issues) +- [Platform-Specific Issues](#platform-specific-issues) +- [Getting Additional Help](#getting-additional-help) + +--- + +## Using MCP Inspector for Debugging + +The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is an official tool for testing and debugging MCP servers. + +**Install MCP Inspector:** + +!!! note + Requires Node.js to be installed on your system. + +```bash +npm install -g @modelcontextprotocol/inspector +``` + +**Run the inspector with your MCP server:** + +```bash +# For pip-installed version +mcp-inspector linux-mcp-server + +# For uvx version +mcp-inspector uvx linux-mcp-server + +# For development version +cd /path/to/linux-mcp-server +mcp-inspector uv run linux-mcp-server +``` + +The inspector provides a web UI where you can: + +- View all available tools +- Test tool calls with different parameters +- See real-time request/response data +- Debug connection issues +- Inspect server capabilities + +--- + +## Local Debugging of Tool Calls + +You can test MCP server tools locally without Claude Desktop or the inspector. + +### Method 1: Interactive Python Session + +```bash +# Activate your virtual environment first +source .venv/bin/activate # Linux/macOS +# OR +.venv\Scripts\activate # Windows + +# Start Python +python + +# Import and test tools +>>> from linux_mcp_server.tools import system_info +>>> import asyncio +>>> result = asyncio.run(system_info.get_system_information()) +>>> print(result) +``` + +### Method 2: Create a Test Script + +Create a file `test_tool.py`: + +```python +import asyncio +from linux_mcp_server.tools import system_info, services + +async def main(): + # Test system info tool + print("=== System Info ===") + result = await system_info.get_system_information() + print(result) + + # Test service listing + print("\n=== Services ===") + result = await services.list_services() + print(result) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +Run it: +```bash +python test_tool.py +``` + +### Method 3: Run pytest in verbose mode + +```bash +# Run specific test +pytest tests/test_system_info.py -v + +# Run with output showing +pytest tests/test_system_info.py -v -s + +# Run all tests for a module +pytest tests/ -k "system_info" -v +``` + +--- + +## Common Installation Issues + +### "command not found: linux-mcp-server" + +**Cause:** The package isn't installed or the installation directory isn't in your PATH. + +**Solutions:** + +1. Verify installation: `pip show linux-mcp-server` +2. Try running as module: `python -m linux_mcp_server` +3. Check if pip install location is in PATH: + ```bash + pip show linux-mcp-server | grep Location + ``` +4. Add pip's bin directory to PATH, or use a virtual environment + +### "No module named 'linux_mcp_server'" + +**Cause:** The package isn't installed in the current Python environment. + +**Solutions:** + +1. Ensure you're using the correct Python: `which python` or `where python` +2. Install the package: `pip install linux-mcp-server` +3. If using virtual environment, make sure it's activated + +### "Permission denied" when reading system logs + +**Cause:** The user running the MCP server doesn't have permission to read system logs. + +**Solutions:** + +1. Add user to `adm` or `systemd-journal` group: + ```bash + sudo usermod -a -G adm $USER + sudo usermod -a -G systemd-journal $USER + ``` +2. Log out and log back in for group changes to take effect +3. Only whitelist log files that the user can read in `LINUX_MCP_ALLOWED_LOG_PATHS` + +### "Permission denied" when reading a local application log file + +**Cause:** If the server throws an error when starting (e.g., PermissionError: [Errno 13] Permission denied: '.../server.log'), it's usually because the log file or its parent directory is owned by a different user (often root due to a previous sudo run). + +**Solutions:** + +1. Verify the current user is the owner of the log directory: + ```bash + ls -ld /home/$USER/.local/share/linux-mcp-server/logs + ``` +2. If the owner is not $USER (e.g., if it shows root or a different User ID), reclaim the folder ownership: + ```bash + sudo chown -R $USER:$USER /home/$USER/.local/share/linux-mcp-server/logs + ``` + +### Claude Desktop doesn't show the MCP server + +Common causes: + +- **Syntax error:** Validate JSON at https://jsonlint.com/ +- **Wrong file location:** See [Client Configuration](clients.md#claude-desktop) +- **Command not in PATH:** Use full path in `command` field or ensure command is in PATH +- **Server won't start:** Test command manually; check Claude Desktop logs (`~/Library/Logs/Claude/` on macOS, `~/.config/Claude/logs/` on Linux, `%APPDATA%\Claude\logs\` on Windows) +- **Config not reloaded:** Completely quit and restart Claude Desktop + +### ImportError or ModuleNotFoundError during development + +**Cause:** Dependencies aren't installed or virtual environment isn't activated. + +**Solutions:** + +1. Ensure virtual environment is activated: + ```bash + source .venv/bin/activate # Linux/macOS + .venv\Scripts\activate # Windows + ``` +2. Reinstall dependencies: + ```bash + uv sync --group dev + # OR + pip install -e ".[dev]" + ``` + +--- + +## SSH Connection Issues + +### "Permission denied (publickey)" + +**Cause:** SSH key authentication failed. + +**Solutions:** + +1. **Verify key exists and is readable:** + ```bash + ls -la ~/.ssh/id_* + ``` + +2. **Test SSH directly with verbose output:** + ```bash + ssh -v user@hostname "echo test" + ``` + The `-v` flag shows detailed connection info to identify the failure point. + +3. **Ensure key is loaded in ssh-agent:** + ```bash + ssh-add -l # List loaded keys + ssh-add ~/.ssh/id_ed25519 # Add if missing + ``` + +4. **For containers, verify key ownership:** + ```bash + ls -la ~/.local/share/linux-mcp-server/id_* + # Should be owned by UID 1001 + sudo chown 1001:1001 ~/.local/share/linux-mcp-server/id_ed25519 + ``` + +### "Host key verification failed" + +**Cause:** Remote host is not in your known_hosts file. + +**Solutions:** + +1. **Connect manually first to accept the key:** + ```bash + ssh user@hostname + # Type "yes" when prompted to accept the host key + ``` + +2. **Or disable host key verification (less secure):** + ```json + "env": { + "LINUX_MCP_VERIFY_HOST_KEYS": "False" + } + ``` + +### Connection timeouts + +**Cause:** Network issues, firewall blocking SSH, or incorrect hostname. + +**Solutions:** + +1. **Test basic connectivity:** + ```bash + ping hostname + ssh -o ConnectTimeout=5 user@hostname "echo test" + ``` + +2. **Increase timeout in client config:** + ```json + "env": { + "LINUX_MCP_COMMAND_TIMEOUT": "60" + } + ``` + +3. **Check firewall rules** on both local and remote systems for port 22 (or custom SSH port). + +### "No such file or directory" for SSH key + +**Cause:** The SSH key path is incorrect or key doesn't exist. + +**Solutions:** + +1. **Check if the key exists:** + ```bash + ls -la ~/.ssh/ + ``` + +2. **Generate a new key if needed:** + ```bash + ssh-keygen -t ed25519 -C "your_email@example.com" + ``` + +3. **Verify `LINUX_MCP_SSH_KEY_PATH` points to correct file.** + +--- + +## Platform-Specific Issues + +This section explains issues that may be present when using the MCP server to interact with a system that is not compatible. + +### Linux: "systemctl: command not found" + +**Cause:** System doesn't use systemd (very old distributions or non-standard systems). + +**Solution:** This MCP server requires systemd to be available on the target system for certain tools to function properly. + +- The main use case is to troubleshoot modern RHEL-alike Linux systems (e.g. RHEL 9.x, 10.x, Fedora 40 and above, etc.) +- Consider upgrading to a modern Linux distribution (RHEL 7+, Fedora, etc.). + +### macOS: Limited functionality warnings + +**Cause:** Some Linux-specific commands don't exist or behave differently on macOS. + +**Note:** This is expected. The MCP server is designed to diagnose Linux systems (see above). + +- Some tools may work on macOS, but some may have reduced functionality or not work at all. + +### Windows: Most or all tools not working + +**Cause:** The MCP server relies on Linux-specific tools (systemd, journalctl, etc.) that don't exist on Windows. + +**Solution:** This is expected behavior. The MCP server is not designed to diagnose Windows systems. + +- On Windows, use the MCP server primarily for: + - Remote SSH execution to manage Linux servers + - Testing and development +- For local Windows management, use a Windows-specific MCP server + +--- + +## Getting Additional Help + +1. **Check logs:** Server logs in `~/.local/share/linux-mcp-server/logs/`, Claude Desktop logs (see above) +2. **Enable debug:** Set `"LINUX_MCP_LOG_LEVEL": "DEBUG"` in config, restart your AI Agent (e.g. Claude Desktop) +3. **Test with MCP Inspector:** Isolate whether issue is with server or client +4. **Run the MCP server manually:** Make sure the MCP server does not crash upon start and is able to receive messages. +5. **Open an issue:** https://github.com/rhel-lightspeed/linux-mcp-server/issues + - Include: + - Your OS and version + - Python version + - Installation method used + - Error messages and logs + - Steps to reproduce diff --git a/docs/usage.md b/docs/usage.md index 71225677..fb790c5d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -32,7 +32,7 @@ pip install linux-mcp-server For the best experience, integrate the MCP server with an AI Agent of your preference. #### For Claude Desktop -See [Claude Desktop Integration](install.md#claude-desktop-integration). +See [Client Configuration](clients.md#claude-desktop). ## Available Tools @@ -209,7 +209,7 @@ Lists immediate subdirectories under a specified path with flexible sorting opti ## Configuration -See the [Installation Guide](install.md#configuration-file-location) for configuration details including environment variables and AI agent integration. +See [Client Configuration](clients.md) for environment variables and AI agent integration details. ## Example Troubleshooting Sessions @@ -267,7 +267,7 @@ Run the MCP server with the minimum required privileges. Consider: ## Troubleshooting -See [Troubleshooting](install.md#troubleshooting) for detailed troubleshooting, security considerations, and permission setup. +See the [Troubleshooting Guide](troubleshooting.md) for detailed solutions, debugging steps, and permission setup. ## Best Practices diff --git a/mkdocs.yml b/mkdocs.yml index de53a17c..16c41da5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,10 +69,12 @@ nav: - Home: index.md - Getting Started: - Installation: install.md + - Client Configuration: clients.md - Usage: usage.md + - Troubleshooting: troubleshooting.md - Reference: - Architecture: architecture.md - - Debugging: debugging.md + - Debug Logging: debugging.md - API Reference: - api/index.md - Core: From 260459b7e68237912f9aec538c64db79c2c1945e Mon Sep 17 00:00:00 2001 From: Major Hayden Date: Mon, 5 Jan 2026 15:35:09 -0600 Subject: [PATCH 11/34] docs: enhance features section with detailed value propositions (#141) Expand the Features section in the main documentation to better communicate the value of linux-mcp-server to both casual Linux users (Fedora, home labs) and enterprise users (RHEL, production). Changes: - Add emoji icons to each feature for visual clarity - Expand descriptions to explain real-world benefits - Highlight safety for production systems - Mention home labs, cloud VMs, and data centers - Contrast natural language queries vs memorizing command flags - Explicitly name Fedora and CentOS Stream alongside RHEL - Add installation verification step Signed-off-by: Major Hayden --- docs/index.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/index.md b/docs/index.md index 331be845..499710d8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,22 +4,28 @@ [![Coverage](https://codecov.io/gh/rhel-lightspeed/linux-mcp-server/graph/badge.svg?token=TtUkG1y0rx)](https://codecov.io/gh/rhel-lightspeed/linux-mcp-server) [![PyPI](https://img.shields.io/pypi/v/linux-mcp-server?label=PyPI)](https://pypi.org/project/linux-mcp-server) -A Model Context Protocol (MCP) server for read-only Linux system administration, diagnostics, and troubleshooting on RHEL-based systems. +A Model Context Protocol (MCP) server for read-only Linux system administration, diagnostics, and troubleshooting on Linux systems. ## Features -- **Read-Only Operations**: All tools are strictly read-only for safe diagnostics -- **Remote SSH Execution**: Execute commands on remote systems via SSH with key-based authentication -- **Multi-Host Management**: Connect to different remote hosts in the same session -- **Comprehensive Diagnostics**: System info, services, processes, logs, network, and storage -- **Configurable Log Access**: Control which log files can be accessed via environment variables -- **RHEL/systemd Focused**: Optimized for Red Hat Enterprise Linux systems +- 🔒 **Read-Only Operations**: All tools are strictly read-only—diagnose with confidence knowing nothing will be modified. Perfect for production systems where you need answers without risk. + +- 🌐 **Remote SSH Execution**: Troubleshoot remote servers from your local machine using secure SSH key-based authentication. No need to hop between terminals or remember complex command syntax. + +- 🖥️ **Multi-Host Management**: Connect to your home lab, cloud VMs, or an entire data center in a single session. Seamlessly switch between hosts without reconfiguring. + +- 🔍 **Comprehensive Diagnostics**: Get the full picture—system info, services, processes, logs, network connections, and storage—all through natural language queries. Ask "why is my system slow?" instead of memorizing `ps`, `journalctl`, and `ss` flags. + +- 📋 **Configurable Log Access**: Control exactly which log files can be accessed via environment variables. Enterprise teams can enforce security policies while still enabling effective troubleshooting. + +- 🎯 **RHEL/systemd Focused**: Built for Red Hat Enterprise Linux, Fedora, CentOS Stream, and other systemd-based distributions. Whether you're managing a personal Fedora workstation or a fleet of RHEL servers, this tool speaks your system's language. ## Quick Start **1. Install** ```bash -pip install --user linux-mcp-server +pip install --user linux-mcp-server # Install the MCP server +~/.local/bin/linux-mcp-server --help # Verify installation ``` **2. Configure your MCP client** ([details](clients.md)) From 1033eeaa5718efc2cc572247241a02958aae2480 Mon Sep 17 00:00:00 2001 From: Major Hayden Date: Mon, 5 Jan 2026 15:41:20 -0600 Subject: [PATCH 12/34] docs: add Goose GUI wizard instructions for extension setup (#142) Add step-by-step instructions for configuring the linux-mcp-server extension via the Goose desktop app's GUI wizard, alongside the existing YAML configuration examples. Signed-off-by: Major Hayden --- docs/clients.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/clients.md b/docs/clients.md index 868ad972..168703b8 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -313,9 +313,35 @@ Edit `~/.gemini/settings.json`: ## Goose -[Goose](https://block.github.io/goose/) is Block's open-source AI agent. +[Goose](https://block.github.io/goose/) is Block's open-source AI agent. You can configure extensions via the GUI wizard or by editing the YAML config file directly. -### Configuration Examples +### GUI Wizard (Desktop App) + +The Goose desktop app provides a wizard for adding extensions: + +1. Open Goose and click the **three dots menu** (⋯) in the top-right corner +2. Select **Settings** → **Extensions** +3. Click **Add custom extension** +4. Fill in the fields: + + | Field | Value | + |-------|-------| + | **Type** | `Standard IO` | + | **ID** | `linux-tools` | + | **Name** | `linux-tools` | + | **Description** | `Linux system diagnostics` | + | **Command** | `~/.local/bin/linux-mcp-server` | + | **Arguments** | *(leave empty)* | + | **Environment Variables** | `LINUX_MCP_USER=your-ssh-username` | + +5. Click **Add** to save the extension + +!!! tip "Container Installation" + For container-based installs, set **Command** to `podman` and add the container arguments in the **Arguments** field (one per line). + +### YAML Configuration (CLI) + +If you prefer editing config files directly, add to `~/.config/goose/config.yaml`: === "pip/uv (Recommended)" From e2ca6493a4a72247c5c299494e31f64bdeef75a6 Mon Sep 17 00:00:00 2001 From: Major Hayden Date: Mon, 5 Jan 2026 15:43:26 -0600 Subject: [PATCH 13/34] docs: Show quick start first in install.md (#143) Signed-off-by: Major Hayden --- docs/index.md | 52 ++++++++++++++------------------------------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/docs/index.md b/docs/index.md index 499710d8..ce3970ad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,20 @@ A Model Context Protocol (MCP) server for read-only Linux system administration, diagnostics, and troubleshooting on Linux systems. +## Quick Start + +1. Install via pip +```bash +pip install --user linux-mcp-server # Install the MCP server +~/.local/bin/linux-mcp-server --help # Verify installation +``` + +2. [Configure your favorite MCP client](clients.md) + +3. Start diagnosing Linux systems! + +See the [Installation Guide](install.md) for container installs, SSH setup, and more. + ## Features - 🔒 **Read-Only Operations**: All tools are strictly read-only—diagnose with confidence knowing nothing will be modified. Perfect for production systems where you need answers without risk. @@ -20,31 +34,6 @@ A Model Context Protocol (MCP) server for read-only Linux system administration, - 🎯 **RHEL/systemd Focused**: Built for Red Hat Enterprise Linux, Fedora, CentOS Stream, and other systemd-based distributions. Whether you're managing a personal Fedora workstation or a fleet of RHEL servers, this tool speaks your system's language. -## Quick Start - -**1. Install** -```bash -pip install --user linux-mcp-server # Install the MCP server -~/.local/bin/linux-mcp-server --help # Verify installation -``` - -**2. Configure your MCP client** ([details](clients.md)) - -**3. Start diagnosing Linux systems!** - -See the [Installation Guide](install.md) for container installs, SSH setup, and more. - -## Available Tools - -| Category | Tools | -|----------|-------| -| **System Info** | `get_system_information`, `get_cpu_information`, `get_memory_information`, `get_disk_usage`, `get_hardware_information` | -| **Services** | `list_services`, `get_service_status`, `get_service_logs` | -| **Processes** | `list_processes`, `get_process_info` | -| **Logs** | `get_journal_logs`, `get_audit_logs`, `read_log_file` | -| **Network** | `get_network_interfaces`, `get_network_connections`, `get_listening_ports` | -| **Storage** | `list_block_devices`, `list_directories`, `list_files`, `read_file` | - ## Key Components - **FastMCP Server**: Core MCP protocol server handling tool registration and invocation @@ -52,16 +41,3 @@ See the [Installation Guide](install.md) for container installs, SSH setup, and - **SSH Executor**: Routes commands to local subprocess or remote SSH execution with connection pooling - **Audit Logger**: Comprehensive logging in both human-readable and JSON formats with automatic rotation - **Multi-Target Execution**: Single server instance can execute commands on local system or multiple remote hosts - -## Configuration - -Key environment variables: - -| Variable | Description | -|----------|-------------| -| `LINUX_MCP_ALLOWED_LOG_PATHS` | Comma-separated list of log files that can be accessed | -| `LINUX_MCP_LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | -| `LINUX_MCP_SSH_KEY_PATH` | Path to SSH private key for remote execution | -| `LINUX_MCP_USER` | Username used for SSH connections (optional) | - -See [Installation](install.md) for complete configuration details. From 3603d5c9b739c10304de174d5804544fb05a8bfb Mon Sep 17 00:00:00 2001 From: Major Hayden Date: Mon, 5 Jan 2026 16:00:10 -0600 Subject: [PATCH 14/34] docs: improve contributing guide and AGENTS.md for developer onboarding (#144) * docs: improve contributing guide and AGENTS.md for developer onboarding contributing.md: - Add quick start TL;DR for experienced developers - Add project structure overview - Update tool example to match actual codebase patterns - Add linting/type checking section (ruff, pyright) - Fix step numbering bug in setup instructions - Add cross-references to related documentation - Reorganize with visual section dividers AGENTS.md: - Expand from minimal guidelines to comprehensive reference - Add project layout, commands, and workflow - Include conventional commits format requirement - Add security rules and testing requirements - Specify small, focused PRs requirement Signed-off-by: Major Hayden * fixup! docs: improve contributing guide and AGENTS.md for developer onboarding Add language specifiers to fenced code blocks, fix nested list indentation - Add 'text' language specifier to project structure tree - Add 'text' language specifier to commit message format blocks - Fix nested list indentation to 2 spaces for MD007 compliance Signed-off-by: Major Hayden --------- Signed-off-by: Major Hayden --- AGENTS.md | 62 ++++- docs/contributing.md | 528 +++++++++++++++++++++++-------------------- 2 files changed, 339 insertions(+), 251 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c6349ea9..044ff45f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,54 @@ # Linux MCP Server -## Development Guidelines - -- Always run tests, linters, and type checkers before committing code with `make verify`. -- Extend existing tests using parameterized tests rather than adding new test cases. -- Use fixtures to deduplicate setup code across tests. -- If a fixture could be used in multiple test modules, place it in `conftest.py`. -- Use mocks sparingly and try to pass objects to the code under test instead. -- Use `autospec=True` when patching to verify arguments match the real function signature. -- Use `spec=` with MagicMock to restrict attributes to those of the real object. -- Prefer Pydantic models over dataclasses. +Read-only MCP server for Linux system diagnostics. + +## Commands + +```bash +uv sync # Install dependencies +uv run pytest # Run tests +uv run ruff check src tests # Lint +uv run pyright # Type check +make verify # All checks (required before commit) +``` + +## Project Layout + +- `src/linux_mcp_server/tools/` - MCP tools (logs, network, processes, services, storage, system_info) +- `src/linux_mcp_server/commands.py` - Command definitions +- `src/linux_mcp_server/formatters.py` / `parsers.py` - Output formatting and parsing +- `tests/` - Mirrors src structure + +## Rules + +**Code:** PEP 8, type hints required, async/await for I/O, 120 char max, prefer Pydantic over dataclasses + +**Testing:** +- Run `make verify` before committing +- Use parameterized tests and fixtures (shared fixtures go in `conftest.py`) +- Use `autospec=True` when patching; `spec=` with MagicMock +- 100% patch coverage for new code + +**Security (Critical):** +- All tools must be read-only with `readOnlyHint=True` +- Validate all input, use allowlists for file paths, sanitize shell params + +## Adding Tools + +1. Create tool in `src/linux_mcp_server/tools/` using `@mcp.tool()`, `@log_tool_call`, `@disallow_local_execution_in_containers` decorators +2. Register command in `commands.py` +3. Write tests in `tests/tools/` + +See `src/linux_mcp_server/tools/processes.py` for reference. + +## Commits & PRs + +Use [Conventional Commits](https://www.conventionalcommits.org/): `(): ` + +Types: `feat`, `fix`, `docs`, `test`, `refactor`, `perf`, `chore` + +**PRs must be small and focused** - one logical change per PR. Split large changes into incremental PRs. + +## Docs + +Full details: `docs/contributing.md` | Architecture: `docs/architecture.md` | API: `docs/api/` diff --git a/docs/contributing.md b/docs/contributing.md index 6d910b81..b8a66518 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,341 +1,375 @@ # Contributing to Linux MCP Server -Thank you for your interest in contributing! This document provides guidelines for contributing to the Linux MCP Server project. +Thank you for your interest in contributing! This guide will help you set up your development environment and understand our workflow. -### Prerequisites +## Quick Start (TL;DR) -- **Python 3.10 or higher** -- **git** -- **pip** -- **uv** - https://github.com/astral-sh/uv#installation - -### Method 1: Setup with pip and a virtual environment - -**Step 1: Clone the repository** +Experienced developers can get started in 60 seconds: ```bash git clone https://github.com/rhel-lightspeed/linux-mcp-server.git cd linux-mcp-server +uv sync # Install dependencies +uv run pytest # Verify everything works +uv run linux-mcp-server # Run the server ``` -**Step 2: Create and activate virtual environment** - -```bash -python -m venv .venv -source .venv/bin/activate # On Linux/macOS -# OR -.venv\Scripts\activate # On Windows -``` - -**Step 3: Install the package in editable mode with dev dependencies** - -```bash -pip install -e . --group dev -``` +Read on for detailed setup and contribution guidelines. -**Step 4: Verify the installation** +--- -```bash -python -m linux_mcp_server -``` +## Prerequisites -**Step 5: Run the tests** +| Requirement | Version | Notes | +|-------------|---------|-------| +| Python | 3.10+ | Check with `python3 --version` | +| git | Any | For cloning and version control | +| uv | Latest | [Installation guide](https://github.com/astral-sh/uv#installation) (recommended) | -```bash -pytest -``` +--- -All tests should pass. +## Development Setup -### Method 2: Setup with uv +### Option 1: Using uv (Recommended) -**Step 1: Clone the repository** +[uv](https://github.com/astral-sh/uv) is the fastest way to set up your environment. ```bash +# Clone the repository git clone https://github.com/rhel-lightspeed/linux-mcp-server.git cd linux-mcp-server -``` -**Step 2: Create virtual environment and install dev dependencies** +# Create virtual environment and install all dependencies (dev + lint + test) +uv sync -Note that by default `uv` creates an editable install as well as installs all packages in the `dev` dependency group. +# Verify installation +uv run linux-mcp-server --help -```bash -uv sync +# Run tests +uv run pytest ``` -**Step 3: Verify the installation** +### Option 2: Using pip ```bash -uv run linux-mcp-server -``` +# Clone the repository +git clone https://github.com/rhel-lightspeed/linux-mcp-server.git +cd linux-mcp-server -**Step 5: Run the tests** +# Create and activate virtual environment +python3 -m venv .venv +source .venv/bin/activate # Linux/macOS +# .venv\Scripts\activate # Windows -```bash -uv run pytest +# Install in editable mode with dev dependencies +pip install -e ".[dev]" + +# Verify installation +python -m linux_mcp_server --help + +# Run tests +pytest ``` -All tests should pass. +--- + +## Project Structure + +```text +linux-mcp-server/ +├── src/linux_mcp_server/ +│ ├── tools/ # MCP tool implementations +│ │ ├── logs.py # Log reading tools +│ │ ├── network.py # Network diagnostic tools +│ │ ├── processes.py # Process management tools +│ │ ├── services.py # Systemd service tools +│ │ ├── storage.py # Storage/disk tools +│ │ └── system_info.py # System information tools +│ ├── connection/ # SSH connection handling +│ ├── utils/ # Shared utilities +│ ├── audit.py # Audit logging +│ ├── commands.py # Command definitions +│ ├── config.py # Configuration management +│ ├── formatters.py # Output formatting +│ ├── parsers.py # Command output parsing +│ └── server.py # FastMCP server setup +├── tests/ # Test suite (mirrors src structure) +├── docs/ # Documentation +└── pyproject.toml # Project configuration +``` +--- ## Development Workflow -We follow Test-Driven Development (TDD) principles: +We follow Test-Driven Development (TDD): + +### 1. RED: Write a Failing Test -### 1. RED - Write a Failing Test ```python -# tests/test_new_feature.py +# tests/tools/test_my_feature.py import pytest -from linux_mcp_server.tools import new_module -async def test_new_feature(): - result = await new_module.new_function() +async def test_my_feature_returns_expected_format(): + from linux_mcp_server.tools import my_module + result = await my_module.my_function() assert "expected" in result ``` -### 2. GREEN - Implement Minimal Code to Pass +### 2. GREEN: Write Minimal Code to Pass + ```python -# src/linux_mcp_server/tools/new_module.py -async def new_function(): +# src/linux_mcp_server/tools/my_module.py +async def my_function() -> str: return "expected result" ``` -### 3. REFACTOR - Improve Code Quality -- Improve readability +### 3. REFACTOR: Improve Without Breaking Tests + +- Clean up code structure - Remove duplication - Ensure all tests still pass -### 4. Commit -```bash -git add . -git commit -m "feat: add new feature +--- + +## Adding New Tools -- Detailed description of what was added -- Tests included -- All tests passing" +Tools are the core functionality of the MCP server. Here's how to add a new diagnostic tool: + +### 1. Create the Tool + +```python +# src/linux_mcp_server/tools/my_tool.py +import typing as t + +from mcp.types import ToolAnnotations +from pydantic import Field + +from linux_mcp_server.audit import log_tool_call +from linux_mcp_server.commands import get_command +from linux_mcp_server.server import mcp +from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers +from linux_mcp_server.utils.types import Host + + +@mcp.tool( + title="My Tool Title", + description="Brief description for LLM to understand when to use this tool.", + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def my_tool_name( + param1: t.Annotated[str, Field(description="Parameter description")], + host: Host | None = None, +) -> str: + """Extended documentation if needed.""" + cmd = get_command("my_command") + returncode, stdout, stderr = await cmd.run(host=host) + + if returncode != 0: + return f"Error: {stderr}" + + return stdout ``` -## Code Standards +### 2. Register the Command -### Style Guidelines -- Follow PEP 8 for Python code -- Use type hints for function parameters and return values -- Use async/await for I/O operations -- Maximum line length: 120 characters +Add the command definition to `src/linux_mcp_server/commands.py`: -### Documentation -- Add docstrings to all public functions -- Use clear, descriptive variable names -- Comment complex logic +```python +"my_command": Command(cmd=["my-binary", "--option"], sudo=False), +``` -### Testing -- Write tests for all new features -- Maintain project test coverage above 70%, patch test coverage must be 100%. -- Use descriptive test names that explain what is being tested +### 3. Write Tests -## Adding New Tools +```python +# tests/tools/test_my_tool.py +import pytest +from unittest.mock import AsyncMock, patch -When adding a new diagnostic tool: - -1. **Create the tool function in appropriate module:** - ```python - # src/linux_mcp_server/tools/my_tool.py - import typing as t - - @mcp.tool( - title="Useful Tool", - description="Description for LLM to understand the tool.", - annotations=ToolAnnotations(readOnlyHint=True), - ) - @log_tool_call - async def my_tool_name( - param1: str, - ) -> str: - """Documentation string further describing the tool if necessary. - """ - returncode, stdout, _ = await execute_command(["ps", "aux", "--sort=-%cpu"], host=host) - if returncode != 0: - raise ToolError - - return stdout - ``` - -2. **Write tests:** - ```python - # tests/test_my_tool.py - import pytest - from linux_mcp_server.tools import my_tool - - async def test_my_tool(): - result = await my_tool.my_diagnostic_function() - assert isinstance(result, str) - assert "expected content" in result.lower() - - # Test server integration - async def test_server_has_my_tool(): - from linux_mcp_server.server import mcp - tools = await mcp.list_tools() - tool_names = [t.name for t in tools] - assert "my_tool_name" in tool_names - ``` - -3. **Update documentation:** - - Add tool description to README.md - - Add usage examples to USAGE.md - -## Commit Message Format +async def test_my_tool_returns_output(): + with patch("linux_mcp_server.tools.my_tool.get_command") as mock_cmd: + mock_cmd.return_value.run = AsyncMock(return_value=(0, "output", "")) -We use [Conventional Commits](https://www.conventionalcommits.org/): + from linux_mcp_server.tools.my_tool import my_tool_name + result = await my_tool_name(param1="value") + + assert "output" in result +async def test_server_exposes_my_tool(): + from linux_mcp_server.server import mcp + tools = await mcp.list_tools() + tool_names = [t.name for t in tools] + assert "my_tool_name" in tool_names ``` -(): - +### 4. Update Documentation -