From f56b9eed9edd0cc3ecd4d51beddb4797ca75568e Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:27:05 +0100 Subject: [PATCH 01/26] feat(network): add ip route tool --- src/linux_mcp_server/commands.py | 6 +++ src/linux_mcp_server/tools/__init__.py | 2 + src/linux_mcp_server/tools/network.py | 51 ++++++++++++++++++++ tests/tools/test_network.py | 65 ++++++++++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/src/linux_mcp_server/commands.py b/src/linux_mcp_server/commands.py index 67165b5d..f6755a9b 100644 --- a/src/linux_mcp_server/commands.py +++ b/src/linux_mcp_server/commands.py @@ -135,6 +135,12 @@ class CommandGroup(BaseModel): "stats": CommandSpec(args=("cat", "/proc/net/dev")), } ), + "ip_route": CommandGroup( + commands={ + "ipv4": CommandSpec(args=("ip", "route", "show")), + "ipv6": CommandSpec(args=("ip", "-6", "route", "show")), + } + ), # === Logs === "journal_logs": CommandGroup( commands={ diff --git a/src/linux_mcp_server/tools/__init__.py b/src/linux_mcp_server/tools/__init__.py index 06a7d834..972c15cc 100644 --- a/src/linux_mcp_server/tools/__init__.py +++ b/src/linux_mcp_server/tools/__init__.py @@ -3,6 +3,7 @@ from linux_mcp_server.tools.logs import read_log_file # network +from linux_mcp_server.tools.network import get_ip_route_table from linux_mcp_server.tools.network import get_listening_ports from linux_mcp_server.tools.network import get_network_connections from linux_mcp_server.tools.network import get_network_interfaces @@ -34,6 +35,7 @@ "get_cpu_information", "get_disk_usage", "get_hardware_information", + "get_ip_route_table", "get_journal_logs", "get_listening_ports", "get_memory_information", diff --git a/src/linux_mcp_server/tools/network.py b/src/linux_mcp_server/tools/network.py index fee3f424..9285cce7 100644 --- a/src/linux_mcp_server/tools/network.py +++ b/src/linux_mcp_server/tools/network.py @@ -12,11 +12,24 @@ from linux_mcp_server.parsers import parse_ss_connections from linux_mcp_server.parsers import parse_ss_listening from linux_mcp_server.server import mcp +from linux_mcp_server.utils import StrEnum from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers from linux_mcp_server.utils.types import Host from linux_mcp_server.utils.validation import is_successful_output +class RouteFamily(StrEnum): + IPV4 = "ipv4" + IPV6 = "ipv6" + ALL = "all" + + +def _format_route_output(stdout: str, label: str) -> str: + lines = [f"=== IP Route Table ({label}) ===\n"] + lines.append(stdout.strip()) + return "\n".join(lines) + + @mcp.tool( title="Get network interfaces", description="Get detailed information about network interfaces including address and traffic statistics.", @@ -103,3 +116,41 @@ async def get_listening_ports( ports = parse_ss_listening(stdout) return format_listening_ports(ports) return f"Error getting listening ports: return code {returncode}, stderr: {stderr}" + + +@mcp.tool( + title="Get IP route table", + description="Get IPv4/IPv6 route entries using ip route.", + tags={"network", "routing"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def get_ip_route_table( + family: RouteFamily = RouteFamily.IPV4, + host: Host = None, +) -> str: + """Get routing table entries. + + Retrieves routing table entries via the ip command for IPv4, IPv6, or both. + """ + if family == RouteFamily.ALL: + outputs: list[str] = [] + for subcommand, label in (("ipv4", "IPv4"), ("ipv6", "IPv6")): + cmd = get_command("ip_route", subcommand) + returncode, stdout, stderr = await cmd.run(host=host) + if is_successful_output(returncode, stdout): + outputs.append(_format_route_output(stdout, label)) + else: + outputs.append( + f"Error getting {label} routes: return code {returncode}, stderr: {stderr}", + ) + return "\n\n".join(outputs) + + cmd = get_command("ip_route", family.value) + returncode, stdout, stderr = await cmd.run(host=host) + + if is_successful_output(returncode, stdout): + label = "IPv4" if family == RouteFamily.IPV4 else "IPv6" + return _format_route_output(stdout, label) + return f"Error getting IP routes: return code {returncode}, stderr: {stderr}" diff --git a/tests/tools/test_network.py b/tests/tools/test_network.py index 504fe8a2..f2733d2a 100644 --- a/tests/tools/test_network.py +++ b/tests/tools/test_network.py @@ -211,3 +211,68 @@ async def test_get_listening_ports_error(self, mcp_client, mock_execute): match = re.compile(r"error calling tool.*raised intentionally", flags=re.I) with pytest.raises(ToolError, match=match): await mcp_client.call_tool("get_listening_ports") + + +class TestGetIpRouteTable: + """Test get_ip_route_table function.""" + + @pytest.mark.parametrize( + ("family", "mock_output", "expected_content"), + [ + pytest.param( + "ipv4", + "default via 192.168.1.1 dev eth0\n192.168.1.0/24 dev eth0 proto kernel scope link", + ["ip route table (ipv4)", "default via 192.168.1.1"], + id="ipv4", + ), + pytest.param( + "ipv6", + "default via fe80::1 dev eth0\n2001:db8::/64 dev eth0 proto kernel metric 256", + ["ip route table (ipv6)", "2001:db8::/64"], + id="ipv6", + ), + ], + ) + async def test_get_ip_route_table_success(self, mcp_client, mock_execute, family, mock_output, expected_content): + """Test getting ip route table for IPv4/IPv6.""" + mock_execute.return_value = (0, mock_output, "") + result = await mcp_client.call_tool("get_ip_route_table", arguments={"family": family}) + result_text = result.content[0].text.casefold() + + assert all(content.casefold() in result_text for content in expected_content) + + async def test_get_ip_route_table_all(self, mcp_client, mock_execute): + """Test getting both IPv4 and IPv6 route tables.""" + mock_execute.side_effect = [ + (0, "default via 192.168.1.1 dev eth0", ""), + (0, "default via fe80::1 dev eth0", ""), + ] + + result = await mcp_client.call_tool("get_ip_route_table", arguments={"family": "all"}) + result_text = result.content[0].text.casefold() + + assert "ip route table (ipv4)" in result_text + assert "ip route table (ipv6)" in result_text + assert mock_execute.call_count == 2 + + @pytest.mark.parametrize( + ("return_value",), + [ + pytest.param((1, "", "Command not found"), id="command_fails"), + pytest.param((0, "", ""), id="empty_output"), + ], + ) + async def test_get_ip_route_table_failure(self, mcp_client, mock_execute, return_value): + """Test getting ip route table when command fails or returns empty.""" + mock_execute.return_value = return_value + result = await mcp_client.call_tool("get_ip_route_table") + result_text = result.content[0].text.casefold() + + assert "error getting ip routes" in result_text + + async def test_get_ip_route_table_error(self, mcp_client, mock_execute): + """Test getting ip route table with general error.""" + mock_execute.side_effect = ValueError("Raised intentionally") + match = re.compile(r"error calling tool.*raised intentionally", flags=re.I) + with pytest.raises(ToolError, match=match): + await mcp_client.call_tool("get_ip_route_table") From 0baea073a8c47e899ea019d8a2d9c74198dfd17b Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:27:08 +0100 Subject: [PATCH 02/26] docs(network): document ip route tool --- docs/api/index.md | 2 +- docs/cheatsheet.md | 1 + docs/usage.md | 11 ++++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index d891d0ee..f4c4c7f8 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -19,7 +19,7 @@ MCP tools organized by category: - **[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 +- **[Network](tools/network.md)** - Network interfaces, connections, ports, routes - **[Storage](tools/storage.md)** - Block devices, directory and file listing ### Utilities diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 69d8f27b..06b1fdd2 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -31,6 +31,7 @@ A quick reference guide for common tasks and the tools to use. | **IP Addresses** | `get_network_interfaces` | "What is my IP address?" | | **Open Ports** | `get_listening_ports` | "What ports are open?" | | **Connections** | `get_network_connections` | "Who is connected to port 22?" | +| **Routes** | `get_ip_route_table` | "Show me the routing table" | ## 📂 Files & Storage diff --git a/docs/usage.md b/docs/usage.md index 907951d6..a3ecfa16 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -208,6 +208,15 @@ Returns ports that are listening on the system. **Example use case:** "What services are listening on network ports?" +#### `get_ip_route_table` +Returns IPv4/IPv6 routing table entries using `ip route`. + +**Parameters:** +- `family` (string, optional): `ipv4`, `ipv6`, or `all` (default: `ipv4`) +- `host` (string, optional): Remote host identifier + +**Example use case:** "Show me the IPv4 routing table." + ### Storage & Disk Analysis #### `list_block_devices` @@ -262,6 +271,7 @@ See [Client Configuration](clients.md) for environment variables and AI agent in 1. "Show me all network interfaces and their status" → `get_network_interfaces` 2. "What ports are listening on this system?" → `get_listening_ports` 3. "Show me active network connections" → `get_network_connections` +4. "Show me the routing table" → `get_ip_route_table` ### Disk Space Problems 1. "Show me disk usage for all filesystems" → `get_disk_usage` @@ -314,4 +324,3 @@ See the [Troubleshooting Guide](troubleshooting.md) for detailed solutions, debu 4. **Security First**: Only whitelist log files that are necessary for diagnostics. 5. **Regular Updates**: Keep the MCP server and its dependencies updated for security and compatibility. - From 65e15133f21770d921f8556f6ed06bb9ded91a98 Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:27:10 +0100 Subject: [PATCH 03/26] test(storage): skip restricted path test when root --- tests/tools/storage/test_list_directories.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/tools/storage/test_list_directories.py b/tests/tools/storage/test_list_directories.py index dc139a53..9f6d894d 100644 --- a/tests/tools/storage/test_list_directories.py +++ b/tests/tools/storage/test_list_directories.py @@ -1,3 +1,4 @@ +import os import sys import pytest @@ -110,6 +111,7 @@ async def test_list_directories_nonexistent_path(tmp_path, mcp_client): @pytest.mark.skipif(sys.platform != "linux", reason="requires GNU version of coreutils/findutils") +@pytest.mark.skipif(os.geteuid() == 0, reason="root can access restricted paths") async def test_list_directories_restricted_path(restricted_path, mcp_client): with pytest.raises(ToolError) as exc_info: await mcp_client.call_tool("list_directories", arguments={"path": str(restricted_path)}) From 74b8fb0fa8b2cd6cd93d9d39d3c98277828823f4 Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:00:11 +0100 Subject: [PATCH 04/26] test(network): cover partial failure for ip route table --- tests/tools/test_network.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/tools/test_network.py b/tests/tools/test_network.py index f2733d2a..82157d40 100644 --- a/tests/tools/test_network.py +++ b/tests/tools/test_network.py @@ -255,6 +255,20 @@ async def test_get_ip_route_table_all(self, mcp_client, mock_execute): assert "ip route table (ipv6)" in result_text assert mock_execute.call_count == 2 + async def test_get_ip_route_table_all_partial_failure(self, mcp_client, mock_execute): + """Test getting both IPv4 and IPv6 route tables with a failure.""" + mock_execute.side_effect = [ + (0, "default via 192.168.1.1 dev eth0", ""), + (1, "", "Command not found"), + ] + + result = await mcp_client.call_tool("get_ip_route_table", arguments={"family": "all"}) + result_text = result.content[0].text.casefold() + + assert "ip route table (ipv4)" in result_text + assert "error getting ipv6 routes" in result_text + assert mock_execute.call_count == 2 + @pytest.mark.parametrize( ("return_value",), [ From 73fbe45fa5371e8a9d74a1425683872e7e23014d Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:56:39 +0100 Subject: [PATCH 05/26] feat(dnf): add read-only dnf tools --- README.md | 1 + docs/api/index.md | 3 +- docs/api/tools/dnf.md | 6 + docs/cheatsheet.md | 9 ++ docs/contributing.md | 1 + docs/usage.md | 35 ++++++ src/linux_mcp_server/commands.py | 21 ++++ src/linux_mcp_server/tools/__init__.py | 10 ++ src/linux_mcp_server/tools/dnf.py | 110 ++++++++++++++++++ src/linux_mcp_server/utils/validation.py | 26 +++++ tests/tools/test_dnf.py | 137 +++++++++++++++++++++++ 11 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 docs/api/tools/dnf.md create mode 100644 src/linux_mcp_server/tools/dnf.py create mode 100644 tests/tools/test_dnf.py diff --git a/README.md b/README.md index 6ba5d3b1..8a1674be 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A Model Context Protocol (MCP) server for read-only Linux system administration, - **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 +- **Package Insights (DNF)**: Query installed packages, available packages, and repositories - **Configurable Log Access**: Control which log files can be accessed via environment variables - **RHEL/systemd Focused**: Optimized for Red Hat Enterprise Linux systems diff --git a/docs/api/index.md b/docs/api/index.md index d891d0ee..472358f4 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -19,7 +19,8 @@ MCP tools organized by category: - **[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 +- **[DNF](tools/dnf.md)** - Package and repository information +- **[Network](tools/network.md)** - Network interfaces, connections, ports, routes - **[Storage](tools/storage.md)** - Block devices, directory and file listing ### Utilities diff --git a/docs/api/tools/dnf.md b/docs/api/tools/dnf.md new file mode 100644 index 00000000..b02d2ade --- /dev/null +++ b/docs/api/tools/dnf.md @@ -0,0 +1,6 @@ +# DNF Tools + +::: linux_mcp_server.tools.dnf + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 69d8f27b..9e0b00c0 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -24,6 +24,15 @@ A quick reference guide for common tasks and the tools to use. | **Service Logs** | `get_service_logs` | "Show recent logs for sshd." | | **Specific Log File** | `read_log_file` | "Read the last 50 lines of /var/log/messages." | +## 📦 Packages (DNF) + +| I want to check... | Use this tool | Example Prompt | +|-------------------|---------------|----------------| +| **Installed Packages** | `list_dnf_installed_packages` | "List all installed packages." | +| **Available Packages** | `list_dnf_available_packages` | "What packages are available in repos?" | +| **Package Details** | `get_dnf_package_info` | "Show details for bash." | +| **Repositories** | `list_dnf_repositories` | "Which repositories are enabled?" | + ## 🌐 Network | I want to check... | Use this tool | Example Prompt | diff --git a/docs/contributing.md b/docs/contributing.md index b8a66518..f477b340 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -79,6 +79,7 @@ pytest linux-mcp-server/ ├── src/linux_mcp_server/ │ ├── tools/ # MCP tool implementations +│ │ ├── dnf.py # DNF package manager tools │ │ ├── logs.py # Log reading tools │ │ ├── network.py # Network diagnostic tools │ │ ├── processes.py # Process management tools diff --git a/docs/usage.md b/docs/usage.md index 907951d6..2a181315 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -185,6 +185,41 @@ Reads a specific log file (must be in the allowed list). **Security Note:** This tool respects the `LINUX_MCP_ALLOWED_LOG_PATHS` environment variable whitelist. +### Package Management (DNF) + +#### `list_dnf_installed_packages` +Lists installed packages via `dnf`. + +**Parameters:** +- `host` (string, optional): Remote host identifier + +**Example use case:** "Show me all installed packages." + +#### `list_dnf_available_packages` +Lists packages available in configured repositories. + +**Parameters:** +- `host` (string, optional): Remote host identifier + +**Example use case:** "Which packages are available from enabled repos?" + +#### `get_dnf_package_info` +Returns detailed information for a specific package. + +**Parameters:** +- `package` (string, required): Package name (e.g., "bash", "openssl") +- `host` (string, optional): Remote host identifier + +**Example use case:** "Get details for the bash package." + +#### `list_dnf_repositories` +Lists configured repositories and their status. + +**Parameters:** +- `host` (string, optional): Remote host identifier + +**Example use case:** "Show me all configured repositories and whether they are enabled." + ### Network Diagnostics #### `get_network_interfaces` diff --git a/src/linux_mcp_server/commands.py b/src/linux_mcp_server/commands.py index 67165b5d..a982d840 100644 --- a/src/linux_mcp_server/commands.py +++ b/src/linux_mcp_server/commands.py @@ -233,6 +233,27 @@ class CommandGroup(BaseModel): "default": CommandSpec(args=("cat", "{path}")), } ), + # === Packages (dnf) === + "dnf_list_installed": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "list", "installed")), + } + ), + "dnf_list_available": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "list", "available")), + } + ), + "dnf_package_info": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "info", "{package}")), + } + ), + "dnf_repolist": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "repolist", "--all")), + } + ), # === System Info === "system_info": CommandGroup( commands={ diff --git a/src/linux_mcp_server/tools/__init__.py b/src/linux_mcp_server/tools/__init__.py index 06a7d834..ced9c552 100644 --- a/src/linux_mcp_server/tools/__init__.py +++ b/src/linux_mcp_server/tools/__init__.py @@ -1,3 +1,9 @@ +# packages (dnf) +from linux_mcp_server.tools.dnf import get_dnf_package_info +from linux_mcp_server.tools.dnf import list_dnf_available_packages +from linux_mcp_server.tools.dnf import list_dnf_installed_packages +from linux_mcp_server.tools.dnf import list_dnf_repositories + # logs from linux_mcp_server.tools.logs import get_journal_logs from linux_mcp_server.tools.logs import read_log_file @@ -43,8 +49,12 @@ "get_service_logs", "get_service_status", "get_system_information", + "get_dnf_package_info", "list_block_devices", "list_directories", + "list_dnf_available_packages", + "list_dnf_installed_packages", + "list_dnf_repositories", "list_files", "list_processes", "list_services", diff --git a/src/linux_mcp_server/tools/dnf.py b/src/linux_mcp_server/tools/dnf.py new file mode 100644 index 00000000..041f8dde --- /dev/null +++ b/src/linux_mcp_server/tools/dnf.py @@ -0,0 +1,110 @@ +"""DNF package manager tools.""" + +import typing as t + +from mcp.types import ToolAnnotations +from pydantic import Field +from pydantic.functional_validators import BeforeValidator + +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 +from linux_mcp_server.utils.validation import is_empty_output +from linux_mcp_server.utils.validation import validate_dnf_package_name + + +def _is_package_not_found(stdout: str, stderr: str) -> bool: + combined = f"{stdout}\n{stderr}".casefold() + return "no matching packages to list" in combined or "no match for argument" in combined + + +async def _run_dnf_command(command_name: str, host: Host | None = None, **kwargs: object) -> str: + cmd = get_command(command_name) + returncode, stdout, stderr = await cmd.run(host=host, **kwargs) + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="List installed packages (dnf)", + description="List installed packages via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def list_dnf_installed_packages( + host: Host = None, +) -> str: + """List installed packages using dnf.""" + return await _run_dnf_command("dnf_list_installed", host=host) + + +@mcp.tool( + title="List available packages (dnf)", + description="List available packages via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def list_dnf_available_packages( + host: Host = None, +) -> str: + """List available packages using dnf.""" + return await _run_dnf_command("dnf_list_available", host=host) + + +@mcp.tool( + title="Package info (dnf)", + description="Get details for a specific package via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def get_dnf_package_info( + package: t.Annotated[ + str, + BeforeValidator(validate_dnf_package_name), + Field(description="Package name", examples=["bash", "openssl", "vim-enhanced", "python3"]), + ], + host: Host = None, +) -> str: + """Get package details using dnf.""" + cmd = get_command("dnf_package_info") + returncode, stdout, stderr = await cmd.run(host=host, package=package) + + if _is_package_not_found(stdout, stderr): + return f"Package '{package}' not found." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="List repositories (dnf)", + description="List configured repositories via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def list_dnf_repositories( + host: Host = None, +) -> str: + """List configured repositories using dnf.""" + return await _run_dnf_command("dnf_repolist", host=host) diff --git a/src/linux_mcp_server/utils/validation.py b/src/linux_mcp_server/utils/validation.py index 9efde7fa..a73b1f92 100644 --- a/src/linux_mcp_server/utils/validation.py +++ b/src/linux_mcp_server/utils/validation.py @@ -1,3 +1,5 @@ +import re + from pathlib import Path @@ -56,6 +58,30 @@ def validate_path(path: str) -> Path: return Path(path) +def validate_dnf_package_name(value: str) -> str: + """Validate a dnf package identifier for safety. + + Allows a conservative RPM token charset (letters, digits, . _ + : -). + Rejects empty values, whitespace/control characters, leading '-' and slashes. + """ + if not value: + raise ValueError("Package name cannot be empty") + + if any(c in value for c in ["\n", "\r", "\x00", "\t", " "]): + raise ValueError("Package name contains invalid characters") + + if value.startswith("-"): + raise ValueError("Package name cannot start with '-'") + + if "/" in value: + raise ValueError("Package name cannot contain '/'") + + if not re.fullmatch(r"[A-Za-z0-9._+:-]+", value): + raise ValueError("Package name contains invalid characters") + + return value + + def is_empty_output(stdout: str | None) -> bool: """Check if command output is empty or whitespace-only. diff --git a/tests/tools/test_dnf.py b/tests/tools/test_dnf.py new file mode 100644 index 00000000..e60b3499 --- /dev/null +++ b/tests/tools/test_dnf.py @@ -0,0 +1,137 @@ +"""Tests for dnf package manager tools.""" + +import pytest + +from linux_mcp_server.utils.validation import validate_dnf_package_name + + +class TestDnfValidation: + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("bash", "bash"), + ("openssl-libs", "openssl-libs"), + ("python3.12", "python3.12"), + ("glibc:2.28", "glibc:2.28"), + ], + ) + def test_validate_dnf_package_name_valid(self, value, expected): + assert validate_dnf_package_name(value) == expected + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "bad name", + "bad\tname", + "bad\nname", + "-bad", + "bad/name", + "bad*name", + "bad?name", + ], + ) + def test_validate_dnf_package_name_invalid(self, value): + with pytest.raises(ValueError): + validate_dnf_package_name(value) + + +class TestDnfToolsRemote: + @pytest.mark.parametrize( + "tool_name", + [ + "list_dnf_installed_packages", + "list_dnf_available_packages", + "list_dnf_repositories", + ], + ) + async def test_dnf_list_tools_success(self, mcp_client, mock_execute_with_fallback, tool_name): + mock_execute_with_fallback.return_value = (0, "Some dnf output", "") + + result = await mcp_client.call_tool( + tool_name, + arguments={"host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "Some dnf output" in result_text + call_kwargs = mock_execute_with_fallback.call_args[1] + assert call_kwargs["host"] == "remote.example.com" + + async def test_dnf_list_tools_empty_output(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, " ", "") + + result = await mcp_client.call_tool( + "list_dnf_installed_packages", + arguments={"host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "no output returned" in result_text + + async def test_dnf_list_tools_error(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "dnf error") + + result = await mcp_client.call_tool( + "list_dnf_available_packages", + arguments={"host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "error running dnf" in result_text + assert "dnf error" in result_text + + async def test_get_dnf_package_info_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "Name : bash", "") + + result = await mcp_client.call_tool( + "get_dnf_package_info", + arguments={"package": "bash", "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "Name : bash" in result_text + + async def test_get_dnf_package_info_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "No match for argument: missing") + + result = await mcp_client.call_tool( + "get_dnf_package_info", + arguments={"package": "missing", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "not found" in result_text + + async def test_get_dnf_package_info_error(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "dnf error") + + result = await mcp_client.call_tool( + "get_dnf_package_info", + arguments={"package": "bash", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "error running dnf" in result_text + + async def test_get_dnf_package_info_empty_output(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, " ", "") + + result = await mcp_client.call_tool( + "get_dnf_package_info", + arguments={"package": "bash", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "no output returned" in result_text + + async def test_get_dnf_package_info_invalid_package_name(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "get_dnf_package_info", + arguments={"package": "bad name", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() From 435f647e9513d18a7270d6b73eb9bc562361d062 Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:01:38 +0100 Subject: [PATCH 06/26] feat(dnf): add repo, group, module, provides tools --- README.md | 2 +- docs/cheatsheet.md | 7 + docs/usage.md | 61 +++++ src/linux_mcp_server/commands.py | 35 +++ src/linux_mcp_server/tools/__init__.py | 14 + src/linux_mcp_server/tools/dnf.py | 199 ++++++++++++++ src/linux_mcp_server/utils/validation.py | 101 +++++++ tests/tools/test_dnf.py | 318 +++++++++++++++++++++++ 8 files changed, 736 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a1674be..9fc74360 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A Model Context Protocol (MCP) server for read-only Linux system administration, - **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 -- **Package Insights (DNF)**: Query installed packages, available packages, and repositories +- **Package Insights (DNF)**: Query packages, repositories, provides, groups, and modules - **Configurable Log Access**: Control which log files can be accessed via environment variables - **RHEL/systemd Focused**: Optimized for Red Hat Enterprise Linux systems diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 9e0b00c0..c90d6f2b 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -32,6 +32,13 @@ A quick reference guide for common tasks and the tools to use. | **Available Packages** | `list_dnf_available_packages` | "What packages are available in repos?" | | **Package Details** | `get_dnf_package_info` | "Show details for bash." | | **Repositories** | `list_dnf_repositories` | "Which repositories are enabled?" | +| **File Provides** | `dnf_provides` | "Which package provides /usr/bin/python3?" | +| **Repository Info** | `get_dnf_repo_info` | "Show details for baseos." | +| **Group List** | `list_dnf_groups` | "List all package groups." | +| **Group Info** | `get_dnf_group_info` | "Show details for Development Tools." | +| **Group Summary** | `get_dnf_group_summary` | "Summarize installed groups." | +| **Module List** | `list_dnf_modules` | "List nodejs module streams." | +| **Module Provides** | `dnf_module_provides` | "Which module provides python3?" | ## 🌐 Network diff --git a/docs/usage.md b/docs/usage.md index 2a181315..8653d7bf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -220,6 +220,67 @@ Lists configured repositories and their status. **Example use case:** "Show me all configured repositories and whether they are enabled." +#### `dnf_provides` +Finds packages that provide a file or binary. + +**Parameters:** +- `query` (string, required): File path or binary name (e.g., "/usr/bin/python3", "libssl.so.3") +- `host` (string, optional): Remote host identifier + +**Example use case:** "Which package provides /usr/bin/python3?" + +#### `get_dnf_repo_info` +Shows detailed information for a specific repository. + +**Parameters:** +- `repo_id` (string, required): Repository id (e.g., "baseos", "appstream") +- `host` (string, optional): Remote host identifier + +**Example use case:** "Show details for the baseos repository." + +#### `list_dnf_groups` +Lists available and installed package groups. + +**Parameters:** +- `host` (string, optional): Remote host identifier + +**Example use case:** "List all package groups." + +#### `get_dnf_group_info` +Shows details for a specific package group. + +**Parameters:** +- `group` (string, required): Group name (e.g., "Development Tools") +- `host` (string, optional): Remote host identifier + +**Example use case:** "Show details for the Development Tools group." + +#### `get_dnf_group_summary` +Shows a summary of installed and available groups. + +**Parameters:** +- `host` (string, optional): Remote host identifier + +**Example use case:** "Summarize installed and available groups." + +#### `list_dnf_modules` +Lists modules (optionally filtered by module name). + +**Parameters:** +- `module` (string, optional): Module name filter (e.g., "nodejs") +- `host` (string, optional): Remote host identifier + +**Example use case:** "List available nodejs module streams." + +#### `dnf_module_provides` +Shows modules that provide a specific package. + +**Parameters:** +- `package` (string, required): Package name (e.g., "python3") +- `host` (string, optional): Remote host identifier + +**Example use case:** "Which module provides python3?" + ### Network Diagnostics #### `get_network_interfaces` diff --git a/src/linux_mcp_server/commands.py b/src/linux_mcp_server/commands.py index a982d840..9cf87a3d 100644 --- a/src/linux_mcp_server/commands.py +++ b/src/linux_mcp_server/commands.py @@ -254,6 +254,41 @@ class CommandGroup(BaseModel): "default": CommandSpec(args=("dnf", "repolist", "--all")), } ), + "dnf_provides": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "provides", "{query}")), + } + ), + "dnf_repo_info": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "repoinfo", "{repo_id}")), + } + ), + "dnf_group_list": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "group", "list")), + } + ), + "dnf_group_info": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "group", "info", "{group}")), + } + ), + "dnf_group_summary": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "group", "summary")), + } + ), + "dnf_module_list": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "module", "list"), optional_flags={"module": ("{module}",)}), + } + ), + "dnf_module_provides": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "module", "provides", "{package}")), + } + ), # === System Info === "system_info": CommandGroup( commands={ diff --git a/src/linux_mcp_server/tools/__init__.py b/src/linux_mcp_server/tools/__init__.py index ced9c552..9a0fa8aa 100644 --- a/src/linux_mcp_server/tools/__init__.py +++ b/src/linux_mcp_server/tools/__init__.py @@ -1,7 +1,14 @@ # packages (dnf) +from linux_mcp_server.tools.dnf import dnf_module_provides +from linux_mcp_server.tools.dnf import dnf_provides +from linux_mcp_server.tools.dnf import get_dnf_group_info +from linux_mcp_server.tools.dnf import get_dnf_group_summary from linux_mcp_server.tools.dnf import get_dnf_package_info +from linux_mcp_server.tools.dnf import get_dnf_repo_info from linux_mcp_server.tools.dnf import list_dnf_available_packages +from linux_mcp_server.tools.dnf import list_dnf_groups from linux_mcp_server.tools.dnf import list_dnf_installed_packages +from linux_mcp_server.tools.dnf import list_dnf_modules from linux_mcp_server.tools.dnf import list_dnf_repositories # logs @@ -50,14 +57,21 @@ "get_service_status", "get_system_information", "get_dnf_package_info", + "get_dnf_group_info", + "get_dnf_group_summary", + "get_dnf_repo_info", "list_block_devices", "list_directories", "list_dnf_available_packages", "list_dnf_installed_packages", + "list_dnf_groups", + "list_dnf_modules", "list_dnf_repositories", "list_files", "list_processes", "list_services", + "dnf_module_provides", + "dnf_provides", "read_file", "read_log_file", ] diff --git a/src/linux_mcp_server/tools/dnf.py b/src/linux_mcp_server/tools/dnf.py index 041f8dde..dcd471a3 100644 --- a/src/linux_mcp_server/tools/dnf.py +++ b/src/linux_mcp_server/tools/dnf.py @@ -12,7 +12,11 @@ from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers from linux_mcp_server.utils.types import Host from linux_mcp_server.utils.validation import is_empty_output +from linux_mcp_server.utils.validation import validate_dnf_group_name from linux_mcp_server.utils.validation import validate_dnf_package_name +from linux_mcp_server.utils.validation import validate_dnf_provides_query +from linux_mcp_server.utils.validation import validate_dnf_repo_id +from linux_mcp_server.utils.validation import validate_optional_dnf_module_name def _is_package_not_found(stdout: str, stderr: str) -> bool: @@ -20,6 +24,11 @@ def _is_package_not_found(stdout: str, stderr: str) -> bool: return "no matching packages to list" in combined or "no match for argument" in combined +def _matches_any_message(stdout: str, stderr: str, patterns: t.Sequence[str]) -> bool: + combined = f"{stdout}\n{stderr}".casefold() + return any(pattern in combined for pattern in patterns) + + async def _run_dnf_command(command_name: str, host: Host | None = None, **kwargs: object) -> str: cmd = get_command(command_name) returncode, stdout, stderr = await cmd.run(host=host, **kwargs) @@ -108,3 +117,193 @@ async def list_dnf_repositories( ) -> str: """List configured repositories using dnf.""" return await _run_dnf_command("dnf_repolist", host=host) + + +@mcp.tool( + title="Find packages providing a file (dnf)", + description="Find packages that provide a specific file or binary via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def dnf_provides( + query: t.Annotated[ + str, + BeforeValidator(validate_dnf_provides_query), + Field(description="File path or binary name", examples=["/usr/bin/python3", "libssl.so.3", "*/libssl.so.*"]), + ], + host: Host = None, +) -> str: + """Find packages providing a file or binary using dnf.""" + cmd = get_command("dnf_provides") + returncode, stdout, stderr = await cmd.run(host=host, query=query) + + if _matches_any_message(stdout, stderr, ("no matches found", "no match for argument")): + return f"No packages provide '{query}'." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="Repository info (dnf)", + description="Get detailed information for a specific repository via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def get_dnf_repo_info( + repo_id: t.Annotated[ + str, + BeforeValidator(validate_dnf_repo_id), + Field(description="Repository id", examples=["baseos", "appstream"]), + ], + host: Host = None, +) -> str: + """Get repository details using dnf.""" + cmd = get_command("dnf_repo_info") + returncode, stdout, stderr = await cmd.run(host=host, repo_id=repo_id) + + if _matches_any_message(stdout, stderr, ("no matching repo", "no repository match", "no such repository")): + return f"Repository '{repo_id}' not found." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="List groups (dnf)", + description="List available and installed groups via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def list_dnf_groups( + host: Host = None, +) -> str: + """List group information using dnf.""" + return await _run_dnf_command("dnf_group_list", host=host) + + +@mcp.tool( + title="Group info (dnf)", + description="Get details for a specific group via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def get_dnf_group_info( + group: t.Annotated[ + str, + BeforeValidator(validate_dnf_group_name), + Field(description="Group name", examples=["Development Tools", "Server with GUI"]), + ], + host: Host = None, +) -> str: + """Get group details using dnf.""" + cmd = get_command("dnf_group_info") + returncode, stdout, stderr = await cmd.run(host=host, group=group) + + if _matches_any_message(stdout, stderr, ("no groups matched", "no match for argument")): + return f"Group '{group}' not found." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="Group summary (dnf)", + description="Show a summary of installed and available groups via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def get_dnf_group_summary( + host: Host = None, +) -> str: + """Get group summary using dnf.""" + return await _run_dnf_command("dnf_group_summary", host=host) + + +@mcp.tool( + title="List modules (dnf)", + description="List modules or filter by module name via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def list_dnf_modules( + module: t.Annotated[ + str | None, + BeforeValidator(validate_optional_dnf_module_name), + Field(description="Optional module name filter", examples=["nodejs", "python39"]), + ] = None, + host: Host = None, +) -> str: + """List modules using dnf.""" + cmd = get_command("dnf_module_list") + returncode, stdout, stderr = await cmd.run(host=host, module=module) + + if module and _matches_any_message(stdout, stderr, ("no matching modules to list", "no match for argument")): + return f"No modules matched '{module}'." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="Module provides (dnf)", + description="Find modules that provide a specific package via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def dnf_module_provides( + package: t.Annotated[ + str, + BeforeValidator(validate_dnf_package_name), + Field(description="Package name", examples=["python3", "nodejs"]), + ], + host: Host = None, +) -> str: + """Find modules providing a package using dnf.""" + cmd = get_command("dnf_module_provides") + returncode, stdout, stderr = await cmd.run(host=host, package=package) + + if _matches_any_message(stdout, stderr, ("no matching modules", "no match for argument")): + return f"No modules provide '{package}'." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout diff --git a/src/linux_mcp_server/utils/validation.py b/src/linux_mcp_server/utils/validation.py index a73b1f92..c72bc5bb 100644 --- a/src/linux_mcp_server/utils/validation.py +++ b/src/linux_mcp_server/utils/validation.py @@ -82,6 +82,107 @@ def validate_dnf_package_name(value: str) -> str: return value +def validate_dnf_repo_id(value: str) -> str: + """Validate a dnf repository identifier for safety. + + Allows a conservative charset (letters, digits, . _ + : -). + Rejects empty values, whitespace/control characters, leading '-' and slashes. + """ + if not value: + raise ValueError("Repository id cannot be empty") + + if any(c in value for c in ["\n", "\r", "\x00", "\t", " "]): + raise ValueError("Repository id contains invalid characters") + + if value.startswith("-"): + raise ValueError("Repository id cannot start with '-'") + + if "/" in value: + raise ValueError("Repository id cannot contain '/'") + + if not re.fullmatch(r"[A-Za-z0-9._+:-]+", value): + raise ValueError("Repository id contains invalid characters") + + return value + + +def validate_dnf_group_name(value: str) -> str: + """Validate a dnf group name for safety. + + Allows letters, digits, spaces and common punctuation used in group names. + Rejects empty values, control characters, and leading '-'. + """ + if not value: + raise ValueError("Group name cannot be empty") + + if any(c in value for c in ["\n", "\r", "\x00", "\t"]): + raise ValueError("Group name contains invalid characters") + + if value.startswith("-"): + raise ValueError("Group name cannot start with '-'") + + if not re.fullmatch(r"[A-Za-z0-9 ._+:'()&,-]+", value): + raise ValueError("Group name contains invalid characters") + + return value + + +def validate_dnf_module_name(value: str) -> str: + """Validate a dnf module name for safety. + + Allows a conservative charset (letters, digits, . _ + : -). + Rejects empty values, whitespace/control characters, leading '-' and slashes. + """ + if not value: + raise ValueError("Module name cannot be empty") + + if any(c in value for c in ["\n", "\r", "\x00", "\t", " "]): + raise ValueError("Module name contains invalid characters") + + if value.startswith("-"): + raise ValueError("Module name cannot start with '-'") + + if "/" in value: + raise ValueError("Module name cannot contain '/'") + + if not re.fullmatch(r"[A-Za-z0-9._+:-]+", value): + raise ValueError("Module name contains invalid characters") + + return value + + +def validate_optional_dnf_module_name(value: str | None) -> str | None: + """Validate an optional dnf module name.""" + if value is None: + return None + + return validate_dnf_module_name(value) + + +def validate_dnf_provides_query(value: str) -> str: + """Validate a dnf provides query for safety. + + Accepts file paths or binary names with optional glob wildcards (*, ?). + Rejects empty values, whitespace/control characters, and leading '-'. + """ + if not value: + raise ValueError("Provides query cannot be empty") + + if any(c in value for c in ["\n", "\r", "\x00", "\t", " "]): + raise ValueError("Provides query contains invalid characters") + + if value.startswith("-"): + raise ValueError("Provides query cannot start with '-'") + + if ".." in value: + raise ValueError("Provides query cannot contain '..'") + + if not re.fullmatch(r"[A-Za-z0-9._+:/@*?-]+", value): + raise ValueError("Provides query contains invalid characters") + + return value + + def is_empty_output(stdout: str | None) -> bool: """Check if command output is empty or whitespace-only. diff --git a/tests/tools/test_dnf.py b/tests/tools/test_dnf.py index e60b3499..50821661 100644 --- a/tests/tools/test_dnf.py +++ b/tests/tools/test_dnf.py @@ -2,7 +2,12 @@ import pytest +from linux_mcp_server.utils.validation import validate_dnf_group_name +from linux_mcp_server.utils.validation import validate_dnf_module_name from linux_mcp_server.utils.validation import validate_dnf_package_name +from linux_mcp_server.utils.validation import validate_dnf_provides_query +from linux_mcp_server.utils.validation import validate_dnf_repo_id +from linux_mcp_server.utils.validation import validate_optional_dnf_module_name class TestDnfValidation: @@ -36,6 +41,121 @@ def test_validate_dnf_package_name_invalid(self, value): with pytest.raises(ValueError): validate_dnf_package_name(value) + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("baseos", "baseos"), + ("appstream", "appstream"), + ("custom-repo", "custom-repo"), + ("repo:1", "repo:1"), + ], + ) + def test_validate_dnf_repo_id_valid(self, value, expected): + assert validate_dnf_repo_id(value) == expected + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "bad repo", + "bad\trepo", + "bad\nrepo", + "-bad", + "bad/repo", + "bad*repo", + ], + ) + def test_validate_dnf_repo_id_invalid(self, value): + with pytest.raises(ValueError): + validate_dnf_repo_id(value) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("Development Tools", "Development Tools"), + ("Server with GUI", "Server with GUI"), + ("Container Management", "Container Management"), + ("Workstation & GUI", "Workstation & GUI"), + ], + ) + def test_validate_dnf_group_name_valid(self, value, expected): + assert validate_dnf_group_name(value) == expected + + @pytest.mark.parametrize( + "value", + [ + "", + "\t", + "bad\tname", + "bad\nname", + "-bad", + "bad/name", + "bad*name", + ], + ) + def test_validate_dnf_group_name_invalid(self, value): + with pytest.raises(ValueError): + validate_dnf_group_name(value) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("nodejs", "nodejs"), + ("python39", "python39"), + ("nodejs:18", "nodejs:18"), + ], + ) + def test_validate_dnf_module_name_valid(self, value, expected): + assert validate_dnf_module_name(value) == expected + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "bad name", + "-bad", + "bad/name", + "bad*name", + ], + ) + def test_validate_dnf_module_name_invalid(self, value): + with pytest.raises(ValueError): + validate_dnf_module_name(value) + + def test_validate_optional_dnf_module_name_none(self): + assert validate_optional_dnf_module_name(None) is None + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("/usr/bin/python3", "/usr/bin/python3"), + ("libssl.so.3", "libssl.so.3"), + ("*/libssl.so.*", "*/libssl.so.*"), + ("usr/lib64/libcrypto.so.3", "usr/lib64/libcrypto.so.3"), + ], + ) + def test_validate_dnf_provides_query_valid(self, value, expected): + assert validate_dnf_provides_query(value) == expected + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "bad name", + "bad\tname", + "bad\nname", + "-bad", + "../usr/bin/bash", + "bad|name", + ], + ) + def test_validate_dnf_provides_query_invalid(self, value): + with pytest.raises(ValueError): + validate_dnf_provides_query(value) + class TestDnfToolsRemote: @pytest.mark.parametrize( @@ -44,6 +164,8 @@ class TestDnfToolsRemote: "list_dnf_installed_packages", "list_dnf_available_packages", "list_dnf_repositories", + "list_dnf_groups", + "get_dnf_group_summary", ], ) async def test_dnf_list_tools_success(self, mcp_client, mock_execute_with_fallback, tool_name): @@ -135,3 +257,199 @@ async def test_get_dnf_package_info_invalid_package_name(self, mcp_client, mock_ assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() mock_execute_with_fallback.assert_not_called() + + async def test_dnf_provides_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "file provided by", "") + + result = await mcp_client.call_tool( + "dnf_provides", + arguments={"query": "/usr/bin/python3", "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "file provided by" in result_text + + async def test_dnf_provides_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "No matches found") + + result = await mcp_client.call_tool( + "dnf_provides", + arguments={"query": "/missing/file", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "no packages provide" in result_text + + async def test_dnf_provides_invalid_query(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "dnf_provides", + arguments={"query": "bad name", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + async def test_get_dnf_repo_info_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "Repo-id : baseos", "") + + result = await mcp_client.call_tool( + "get_dnf_repo_info", + arguments={"repo_id": "baseos", "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "Repo-id" in result_text + + async def test_get_dnf_repo_info_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "No matching repo to modify: missing") + + result = await mcp_client.call_tool( + "get_dnf_repo_info", + arguments={"repo_id": "missing", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "not found" in result_text + + async def test_get_dnf_repo_info_invalid_repo_id(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "get_dnf_repo_info", + arguments={"repo_id": "bad repo", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + async def test_get_dnf_group_info_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "Group: Development Tools", "") + + result = await mcp_client.call_tool( + "get_dnf_group_info", + arguments={"group": "Development Tools", "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "Development Tools" in result_text + + async def test_get_dnf_group_info_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "No groups matched: Missing Group") + + result = await mcp_client.call_tool( + "get_dnf_group_info", + arguments={"group": "Missing Group", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "not found" in result_text + + async def test_get_dnf_group_info_invalid_name(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "get_dnf_group_info", + arguments={"group": "bad/name", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + async def test_list_dnf_modules_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "Name Stream Profiles", "") + + result = await mcp_client.call_tool( + "list_dnf_modules", + arguments={"host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "Name Stream Profiles" in result_text + + async def test_list_dnf_modules_filtered_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "No matching modules to list", "") + + result = await mcp_client.call_tool( + "list_dnf_modules", + arguments={"module": "missing", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "no modules matched" in result_text + + async def test_list_dnf_modules_invalid_name(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "list_dnf_modules", + arguments={"module": "bad name", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + async def test_dnf_module_provides_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "module provides package", "") + + result = await mcp_client.call_tool( + "dnf_module_provides", + arguments={"package": "python3", "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "module provides package" in result_text + + async def test_dnf_module_provides_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "No matching modules") + + result = await mcp_client.call_tool( + "dnf_module_provides", + arguments={"package": "missing", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "no modules provide" in result_text + + async def test_dnf_module_provides_invalid_package(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "dnf_module_provides", + arguments={"package": "bad name", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + @pytest.mark.parametrize( + ("tool_name", "arguments"), + [ + ("dnf_provides", {"query": "/usr/bin/python3", "host": "remote.example.com"}), + ("get_dnf_repo_info", {"repo_id": "baseos", "host": "remote.example.com"}), + ("get_dnf_group_info", {"group": "Development Tools", "host": "remote.example.com"}), + ("list_dnf_modules", {"module": "nodejs", "host": "remote.example.com"}), + ("dnf_module_provides", {"package": "python3", "host": "remote.example.com"}), + ], + ) + async def test_dnf_new_tools_error(self, mcp_client, mock_execute_with_fallback, tool_name, arguments): + mock_execute_with_fallback.return_value = (1, "", "dnf error") + + result = await mcp_client.call_tool(tool_name, arguments=arguments) + result_text = result.content[0].text.casefold() + + assert "error running dnf" in result_text + + @pytest.mark.parametrize( + ("tool_name", "arguments"), + [ + ("dnf_provides", {"query": "/usr/bin/python3", "host": "remote.example.com"}), + ("get_dnf_repo_info", {"repo_id": "baseos", "host": "remote.example.com"}), + ("get_dnf_group_info", {"group": "Development Tools", "host": "remote.example.com"}), + ("list_dnf_modules", {"module": "nodejs", "host": "remote.example.com"}), + ("dnf_module_provides", {"package": "python3", "host": "remote.example.com"}), + ], + ) + async def test_dnf_new_tools_empty_output(self, mcp_client, mock_execute_with_fallback, tool_name, arguments): + mock_execute_with_fallback.return_value = (0, " ", "") + + result = await mcp_client.call_tool(tool_name, arguments=arguments) + result_text = result.content[0].text.casefold() + + assert "no output returned" in result_text From 802031994d1b25b72c05ac9c528de4d085c973bc Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:26:07 +0100 Subject: [PATCH 07/26] feat(dnf): add list output limits --- docs/usage.md | 18 +++ src/linux_mcp_server/tools/dnf.py | 192 ++++++++++++++++++++++++++++-- tests/tools/test_dnf.py | 88 ++++++++++++++ 3 files changed, 290 insertions(+), 8 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 8653d7bf..6372c036 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -192,6 +192,9 @@ Lists installed packages via `dnf`. **Parameters:** - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "Show me all installed packages." @@ -200,6 +203,9 @@ Lists packages available in configured repositories. **Parameters:** - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "Which packages are available from enabled repos?" @@ -217,6 +223,9 @@ Lists configured repositories and their status. **Parameters:** - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "Show me all configured repositories and whether they are enabled." @@ -243,6 +252,9 @@ Lists available and installed package groups. **Parameters:** - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "List all package groups." @@ -260,6 +272,9 @@ Shows a summary of installed and available groups. **Parameters:** - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "Summarize installed and available groups." @@ -269,6 +284,9 @@ Lists modules (optionally filtered by module name). **Parameters:** - `module` (string, optional): Module name filter (e.g., "nodejs") - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "List available nodejs module streams." diff --git a/src/linux_mcp_server/tools/dnf.py b/src/linux_mcp_server/tools/dnf.py index dcd471a3..09ef3023 100644 --- a/src/linux_mcp_server/tools/dnf.py +++ b/src/linux_mcp_server/tools/dnf.py @@ -19,6 +19,9 @@ from linux_mcp_server.utils.validation import validate_optional_dnf_module_name +DEFAULT_DNF_LIMIT = 500 + + def _is_package_not_found(stdout: str, stderr: str) -> bool: combined = f"{stdout}\n{stderr}".casefold() return "no matching packages to list" in combined or "no match for argument" in combined @@ -29,7 +32,42 @@ def _matches_any_message(stdout: str, stderr: str, patterns: t.Sequence[str]) -> return any(pattern in combined for pattern in patterns) -async def _run_dnf_command(command_name: str, host: Host | None = None, **kwargs: object) -> str: +def _apply_output_limits(stdout: str, limit: int | None, offset: int, no_limit: bool) -> str: + lines = stdout.splitlines() + total_lines = len(lines) + + if no_limit or limit is None: + if offset <= 0: + return stdout + + sliced = lines[offset:] + if not sliced: + return "No output after applying limit/offset." + + return "\n".join(sliced) + + start = offset + end = offset + limit + sliced = lines[start:end] + + if not sliced: + return "No output after applying limit/offset." + + result = "\n".join(sliced) + if total_lines > end: + result = f"{result}\n... output truncated: showing {len(sliced)} of {total_lines} lines" + + return result + + +async def _run_dnf_command( + command_name: str, + host: Host | None = None, + limit: int | None = DEFAULT_DNF_LIMIT, + offset: int = 0, + no_limit: bool = False, + **kwargs: object, +) -> str: cmd = get_command(command_name) returncode, stdout, stderr = await cmd.run(host=host, **kwargs) @@ -39,7 +77,7 @@ async def _run_dnf_command(command_name: str, host: Host | None = None, **kwargs if is_empty_output(stdout): return "No output returned by dnf." - return stdout + return _apply_output_limits(stdout, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -51,10 +89,33 @@ async def _run_dnf_command(command_name: str, host: Host | None = None, **kwargs @log_tool_call @disallow_local_execution_in_containers async def list_dnf_installed_packages( + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """List installed packages using dnf.""" - return await _run_dnf_command("dnf_list_installed", host=host) + return await _run_dnf_command("dnf_list_installed", host=host, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -66,10 +127,33 @@ async def list_dnf_installed_packages( @log_tool_call @disallow_local_execution_in_containers async def list_dnf_available_packages( + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """List available packages using dnf.""" - return await _run_dnf_command("dnf_list_available", host=host) + return await _run_dnf_command("dnf_list_available", host=host, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -113,10 +197,33 @@ async def get_dnf_package_info( @log_tool_call @disallow_local_execution_in_containers async def list_dnf_repositories( + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """List configured repositories using dnf.""" - return await _run_dnf_command("dnf_repolist", host=host) + return await _run_dnf_command("dnf_repolist", host=host, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -192,10 +299,33 @@ async def get_dnf_repo_info( @log_tool_call @disallow_local_execution_in_containers async def list_dnf_groups( + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """List group information using dnf.""" - return await _run_dnf_command("dnf_group_list", host=host) + return await _run_dnf_command("dnf_group_list", host=host, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -239,10 +369,33 @@ async def get_dnf_group_info( @log_tool_call @disallow_local_execution_in_containers async def get_dnf_group_summary( + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """Get group summary using dnf.""" - return await _run_dnf_command("dnf_group_summary", host=host) + return await _run_dnf_command("dnf_group_summary", host=host, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -259,6 +412,29 @@ async def list_dnf_modules( BeforeValidator(validate_optional_dnf_module_name), Field(description="Optional module name filter", examples=["nodejs", "python39"]), ] = None, + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """List modules using dnf.""" @@ -274,7 +450,7 @@ async def list_dnf_modules( if is_empty_output(stdout): return "No output returned by dnf." - return stdout + return _apply_output_limits(stdout, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( diff --git a/tests/tools/test_dnf.py b/tests/tools/test_dnf.py index 50821661..3c2004fa 100644 --- a/tests/tools/test_dnf.py +++ b/tests/tools/test_dnf.py @@ -204,6 +204,82 @@ async def test_dnf_list_tools_error(self, mcp_client, mock_execute_with_fallback assert "error running dnf" in result_text assert "dnf error" in result_text + async def test_dnf_list_tools_truncates_output(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2\nline3\nline4", "") + + result = await mcp_client.call_tool( + "list_dnf_installed_packages", + arguments={"host": "remote.example.com", "limit": 2, "offset": 1}, + ) + result_text = result.content[0].text + + assert result_text.startswith("line2\nline3") + assert "output truncated" in result_text + + async def test_dnf_list_tools_no_limit(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2\nline3", "") + + result = await mcp_client.call_tool( + "list_dnf_available_packages", + arguments={"host": "remote.example.com", "limit": 1, "no_limit": True}, + ) + result_text = result.content[0].text + + assert result_text == "line1\nline2\nline3" + + async def test_dnf_list_tools_no_limit_offset_out_of_range(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2", "") + + result = await mcp_client.call_tool( + "list_dnf_installed_packages", + arguments={"host": "remote.example.com", "no_limit": True, "offset": 10}, + ) + result_text = result.content[0].text.casefold() + + assert "no output after applying limit/offset" in result_text + + async def test_dnf_list_tools_no_limit_offset_slices(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2\nline3", "") + + result = await mcp_client.call_tool( + "list_dnf_installed_packages", + arguments={"host": "remote.example.com", "no_limit": True, "offset": 1}, + ) + result_text = result.content[0].text + + assert result_text == "line2\nline3" + + async def test_dnf_list_tools_offset_out_of_range(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2", "") + + result = await mcp_client.call_tool( + "list_dnf_repositories", + arguments={"host": "remote.example.com", "limit": 5, "offset": 10}, + ) + result_text = result.content[0].text.casefold() + + assert "no output after applying limit/offset" in result_text + + async def test_dnf_list_tools_invalid_limit(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "list_dnf_installed_packages", + arguments={"host": "remote.example.com", "limit": 0}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + async def test_dnf_list_tools_invalid_offset(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "list_dnf_available_packages", + arguments={"host": "remote.example.com", "offset": -1}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + async def test_get_dnf_package_info_success(self, mcp_client, mock_execute_with_fallback): mock_execute_with_fallback.return_value = (0, "Name : bash", "") @@ -365,6 +441,18 @@ async def test_list_dnf_modules_success(self, mcp_client, mock_execute_with_fall assert "Name Stream Profiles" in result_text + async def test_list_dnf_modules_truncates_output(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2\nline3\nline4", "") + + result = await mcp_client.call_tool( + "list_dnf_modules", + arguments={"module": "nodejs", "limit": 2, "offset": 1, "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert result_text.startswith("line2\nline3") + assert "output truncated" in result_text + async def test_list_dnf_modules_filtered_not_found(self, mcp_client, mock_execute_with_fallback): mock_execute_with_fallback.return_value = (0, "No matching modules to list", "") From 9a20b1e659892748c7ff1015794eed4a26b9ff4a Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Wed, 4 Feb 2026 15:06:59 -0500 Subject: [PATCH 08/26] Add transport options to documentation --- docs/usage.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 907951d6..b2ce0296 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -34,17 +34,21 @@ Options may be set using environment variables or command line options. Environm | Option | Type | Default | Description | |--------|------|---------|-------------| -| `-h, --help` | flag | - | Show help message and exit | -| `--version` | flag | False | Display version and exit | +| `-h, --help` | - | - | Show help message and exit | +| `--version` | - | - | Display version and exit | | `--user` | string | (empty) | Default username for SSH connections | +| `--transport` | string | stdio | Transport type: `stdio`, `http`, or `streamable_http` | +| `--host` | string | null | Host address for HTTP transport | +| `--port` | integer | null | Port number for HTTP transport | +| `--path` | string | null | Path for HTTP transport | | `--log-dir` | path | `~/.local/share/linux-mcp-server/logs` | Directory for server logs | | `--log-level` | string | `INFO` | Log verbosity level | | `--log-retention-days` | integer | 10 | Days to retain log files | -| `--allowed-log-paths` | string | null | Comma-separated paths to allowed log files | +| `--allowed-log-paths` | string | null | Comma-separated list of allowed log file paths | | `--ssh-key-path` | path | null | Path to SSH private key file | | `--key-passphrase` | string | (empty) | Passphrase for encrypted SSH key | -| `--search-for-ssh-key` | flag | False | Auto-discover SSH keys in `~/.ssh` | -| `--verify-host-keys` | flag | False | Verify remote host identity via known_hosts | +| `--search-for-ssh-key` | bool | False | Auto-discover SSH keys in `~/.ssh` | +| `--verify-host-keys` | bool | False | Verify remote host identity via known_hosts | | `--known-hosts-path` | path | null | Path to known_hosts file | | `--command-timeout` | integer | 30 | SSH command timeout in seconds | From b227e241505abad7d4203775e48cfd64539c6dba Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Wed, 4 Feb 2026 15:12:44 -0500 Subject: [PATCH 09/26] Add transport options --- src/linux_mcp_server/config.py | 41 ++++++++++++++++++++++++++-------- src/linux_mcp_server/server.py | 4 +++- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/linux_mcp_server/config.py b/src/linux_mcp_server/config.py index 75211923..0cd2e2a0 100644 --- a/src/linux_mcp_server/config.py +++ b/src/linux_mcp_server/config.py @@ -6,28 +6,40 @@ from pydantic_settings import BaseSettings from pydantic_settings import SettingsConfigDict +from linux_mcp_server.utils.enum import StrEnum from linux_mcp_server.utils.types import UpperCase -class Config( - BaseSettings, - cli_parse_args=True, - cli_implicit_flags=True, - cli_kebab_case=True, - cli_exit_on_error=False, -): - # The `_`` is required in the env_prefix, otherwise, pydantic would - # interpret the prefix as `LINUX_MCPLOG_DIR`, instead of `LINUX_MCP_LOG_DIR` +class Transport(StrEnum): + stdio = "stdio" + http = "http" + streamable_http = "streamable-http" + + +class Config(BaseSettings): + # The '_' is required in the env_prefix, otherwise, pydantic would + # interpret the prefix as LINUX_MCPLOG_DIR, instead of LINUX_MCP_LOG_DIR model_config = SettingsConfigDict( env_prefix="LINUX_MCP_", env_ignore_empty=True, + cli_exit_on_error=False, # Ignore errors for incorrect/extra parameters cli_hide_none_type=True, cli_ignore_unknown_args=True, + cli_implicit_flags=True, + cli_kebab_case=True, + cli_parse_args=True, ) + # FIXME: When the next version of pydantic-settings is released, change this + # to CliToggleFlag in order to remove the '--no-' option. + # https://github.com/pydantic/pydantic-settings/pull/717/changes version: bool = False user: str = "" + transport: Transport = Transport.stdio + host: str | None = None + port: int | None = None + path: str | None = None # Logging configuration log_dir: Path = Path.home() / ".local" / "share" / "linux-mcp-server" / "logs" @@ -54,5 +66,16 @@ def effective_known_hosts_path(self) -> Path: """Return the known_hosts path, using default ~/.ssh/known_hosts if not configured.""" return self.known_hosts_path or Path.home() / ".ssh" / "known_hosts" + @property + def transport_kwargs(self): + result = {} + if self.transport in {Transport.http, Transport.streamable_http}: + result["host"] = self.host + result["port"] = self.port + result["path"] = self.path + result["log_level"] = self.log_level + + return result + CONFIG = Config() diff --git a/src/linux_mcp_server/server.py b/src/linux_mcp_server/server.py index 11b0028c..711b9e83 100644 --- a/src/linux_mcp_server/server.py +++ b/src/linux_mcp_server/server.py @@ -4,6 +4,8 @@ from fastmcp import FastMCP +from linux_mcp_server.config import CONFIG + logger = logging.getLogger("linux-mcp-server") @@ -40,4 +42,4 @@ def main(): - mcp.run(show_banner=False) + mcp.run(show_banner=False, transport=CONFIG.transport.value, **CONFIG.transport_kwargs) From abc02922b40a891be70e2bf9958288831c6aceb2 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Wed, 4 Feb 2026 16:03:10 -0500 Subject: [PATCH 10/26] Update default values in docs --- docs/usage.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index b2ce0296..1c1a3218 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -38,9 +38,9 @@ Options may be set using environment variables or command line options. Environm | `--version` | - | - | Display version and exit | | `--user` | string | (empty) | Default username for SSH connections | | `--transport` | string | stdio | Transport type: `stdio`, `http`, or `streamable_http` | -| `--host` | string | null | Host address for HTTP transport | -| `--port` | integer | null | Port number for HTTP transport | -| `--path` | string | null | Path for HTTP transport | +| `--host` | string | `127.0.0.1` | Host address for HTTP transport | +| `--port` | integer | 8000 | Port number for HTTP transport | +| `--path` | string | /mcp | Path for HTTP transport | | `--log-dir` | path | `~/.local/share/linux-mcp-server/logs` | Directory for server logs | | `--log-level` | string | `INFO` | Log verbosity level | | `--log-retention-days` | integer | 10 | Days to retain log files | From f8f293cb3aab8b3b6f8f22ddc1409d74d306507f Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Wed, 4 Feb 2026 16:16:25 -0500 Subject: [PATCH 11/26] Add tests --- tests/cli/__init__.py | 0 tests/cli/test_cli.py | 48 +++++++++++++++++++++++++++++++++++++++++++ tests/test__main__.py | 14 ------------- 3 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/test_cli.py diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py new file mode 100644 index 00000000..ff5dad1c --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,48 @@ +import re + +import pytest + +from pydantic_core import ValidationError + +from linux_mcp_server.__main__ import cli +from linux_mcp_server.config import Config + + +def test_cli_version(mocker, capsys): + mocker.patch("linux_mcp_server.__main__.CONFIG.version", True) + regexp = re.compile(r"(\d+\.?)+") + with pytest.raises(SystemExit): + cli() + + out, err = capsys.readouterr() + + assert regexp.match(out) + assert not err + + +@pytest.mark.parametrize( + "args, expected", + ( + ( + ["--transport", "streamable-http"], + {"host": None, "port": None, "path": None, "log_level": "INFO"}, + ), + ( + ["--transport", "http", "--host", "7.7.7.7", "--port", "8308", "--path", "/culdesac"], + {"host": "7.7.7.7", "port": 8308, "path": "/culdesac", "log_level": "INFO"}, + ), + ), + ids=["streamable", "http-host"], +) +def test_cli_transport(mocker, args, expected): + mocker.patch("sys.argv", ["linux-mcp-server", *args]) + + config = Config() + + assert config.transport_kwargs == expected + + +def test_cli_transport_invalid(mocker): + mocker.patch("sys.argv", ["linux-mcp-server", "--transport", "nope"]) + with pytest.raises(ValidationError, match="Input should be"): + Config() diff --git a/tests/test__main__.py b/tests/test__main__.py index faf71d8a..c3f30b6b 100644 --- a/tests/test__main__.py +++ b/tests/test__main__.py @@ -1,5 +1,3 @@ -import re - import pytest from linux_mcp_server.__main__ import cli @@ -14,15 +12,3 @@ def test_cli_keyboard_interrupt(mocker): mocker.patch("linux_mcp_server.__main__.main", side_effect=KeyboardInterrupt) with pytest.raises(SystemExit): cli() - - -def test_cli_version(mocker, capsys): - mocker.patch("linux_mcp_server.__main__.CONFIG.version", True) - regexp = re.compile(r"(\d+\.?)+") - with pytest.raises(SystemExit): - cli() - - out, err = capsys.readouterr() - - assert regexp.match(out) - assert not err From 553416bc5108f679f9e4e38db09532f59e93111a Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Wed, 4 Feb 2026 16:33:13 -0500 Subject: [PATCH 12/26] Update environment variable documentation --- docs/clients.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/clients.md b/docs/clients.md index 711777ad..d94f1e56 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -574,6 +574,18 @@ Edit `~/.codeium/windsurf/mcp_config.json`: Configure these environment variables in the `env` section of your client configuration. +### Transport Settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `LINUX_MCP_TRANSPORT` | `stdio` | Transport type: `stdio`, `http`, or `streamable-http` | +| `LINUX_MCP_HOST` | `127.0.0.1` | Host address for HTTP transport | +| `LINUX_MCP_PORT` | 8000 | Port number for HTTP transport | +| `LINUX_MCP_PATH` | /mcp | Path for HTTP transport | + +!!! note "When to use HTTP transports" + Some clients, like Claude Desktop, require `stdio`. + ### SSH Connection Settings | Variable | Description | Example | From eff4842a09bf78e0b84bb61ca1601841c624d856 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Wed, 4 Feb 2026 16:47:41 -0500 Subject: [PATCH 13/26] Correct formatting of JSON example --- docs/clients.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/clients.md b/docs/clients.md index d94f1e56..aa1263e6 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -468,17 +468,17 @@ Add to your VS Code `mcp.json`: === "pip/uv (Recommended)" ```json -{ - "servers": { - "linux-mcp-server": { - "command": "/home/YOUR_USER/.local/bin/linux-mcp-server", - "args": [], - "env": { - "LINUX_MCP_USER": "your-ssh-username" + { + "servers": { + "linux-mcp-server": { + "command": "/home/YOUR_USER/.local/bin/linux-mcp-server", + "args": [], + "env": { + "LINUX_MCP_USER": "your-ssh-username" + } + } } } - } -} ``` === "Container (Podman)" From 39dff15f424fda78a3d4f951868d41d06d8916e7 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Wed, 4 Feb 2026 17:02:54 -0500 Subject: [PATCH 14/26] Add documentation about how to run using HTTP in a container --- docs/clients.md | 38 +++++++++++++++++++++++++++++--------- docs/install.md | 18 +++++++++++++++++- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/docs/clients.md b/docs/clients.md index aa1263e6..0b569528 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -43,7 +43,7 @@ Edit `~/.claude.json`: } ``` -=== "Container (Podman)" +=== "Container (stdio transport)" ```json { @@ -104,7 +104,7 @@ The value for `command` will vary depending on how `linux-mcp-server` was instal } ``` -=== "Container (Podman)" +=== "Container (stdio transport)" ```json { @@ -164,7 +164,7 @@ Edit `~/.codex/config.toml`: LINUX_MCP_USER = "your-ssh-username" ``` -=== "Container (Podman)" +=== "Container (stdio transport)" ```toml [mcp_servers.linux-mcp-server] @@ -214,7 +214,7 @@ Edit `~/.cursor/mcp.json`: } ``` -=== "Container (Podman)" +=== "Container (stdio transport)" ```json { @@ -272,7 +272,7 @@ Edit `~/.gemini/settings.json`: !!! note "Merging with Existing Settings" If you have other settings in your `settings.json`, add the `mcpServers` object alongside them. -=== "Container (Podman)" +=== "Container (stdio transport)" ```json { @@ -357,7 +357,7 @@ If you prefer editing config files directly, add to `~/.config/goose/config.yaml args: [] ``` -=== "Container (Podman)" +=== "Container (stdio transport)" ```yaml extensions: @@ -392,6 +392,26 @@ If you prefer editing config files directly, add to `~/.config/goose/config.yaml bundled: null available_tools: [] ``` +=== "HTTP transport" + + !!! note Start the thing + `linux-mcp-server` must be started separately when using HTTP transport. + + ```yaml + extensions: + linux-tools-http: + enabled: true + type: streamable_http + name: linux-tools-http + description: Linux Tools HTTP + uri: http://localhost:8000/mcp + envs: {} + env_keys: [] + headers: {} + timeout: 30 + bundled: null + available_tools: [] + ``` !!! warning "Replace Paths" Replace `YOUR_USER` with your actual username and adjust paths as needed. @@ -424,7 +444,7 @@ Edit `~/.config/opencode/opencode.json`: } ``` -=== "Container (Podman)" +=== "Container (stdio transport)" ```json { @@ -481,7 +501,7 @@ Add to your VS Code `mcp.json`: } ``` -=== "Container (Podman)" +=== "Container (stdio transport)" ```json { @@ -539,7 +559,7 @@ Edit `~/.codeium/windsurf/mcp_config.json`: } ``` -=== "Container (Podman)" +=== "Container (stdio transport)" ```json { diff --git a/docs/install.md b/docs/install.md index 301d1c73..d8c8fe64 100644 --- a/docs/install.md +++ b/docs/install.md @@ -89,7 +89,23 @@ A container runtime such as [Podman](https://podman-desktop.io) is required. quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest ``` -See [client configuration](clients.md) for examples of how to run the container. +See [client configuration](clients.md) for examples of how to run the container using stdio transport. + +When using an HTTP transport, `http` or `streamable-http`, the container must be started before launching the LLM client. + +```bash +podman run --rm --interactive \ + --userns "keep-id:uid=1001,gid=0" + --port 8000:8000 \ + -e LINUX_MCP_KEY_PASSPHRASE # Only needed if the SSH key is protected by a passphrase + -e LINUX_MCP_TRANSPORT=streamable-http \ + -e LINUX_MCP_HOST=0.0.0.0 \ # bind to all interfaces inside the container + -v /home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro \ + -v /home/YOUR_USER/.ssh/config:/var/lib/mcp/.ssh/config:ro,Z \ + -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 + +``` #### Container Setup for SSH Keys From 49174d40a840f2438fe06c7d67a92f117febe2f6 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Thu, 5 Feb 2026 16:29:03 -0500 Subject: [PATCH 15/26] Set transport default values Rather than passing None and letting FastMCP apply default, explicitly set default valuse and pass those to FastMCP. --- src/linux_mcp_server/config.py | 6 +++--- tests/cli/test_cli.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/linux_mcp_server/config.py b/src/linux_mcp_server/config.py index 0cd2e2a0..a97018a7 100644 --- a/src/linux_mcp_server/config.py +++ b/src/linux_mcp_server/config.py @@ -37,9 +37,9 @@ class Config(BaseSettings): user: str = "" transport: Transport = Transport.stdio - host: str | None = None - port: int | None = None - path: str | None = None + host: str = "127.0.0.1" + port: int = 8000 + path: str = "/mcp" # Logging configuration log_dir: Path = Path.home() / ".local" / "share" / "linux-mcp-server" / "logs" diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index ff5dad1c..fbbc6e6d 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -25,7 +25,7 @@ def test_cli_version(mocker, capsys): ( ( ["--transport", "streamable-http"], - {"host": None, "port": None, "path": None, "log_level": "INFO"}, + {"host": "127.0.0.1", "port": 8000, "path": "/mcp", "log_level": "INFO"}, ), ( ["--transport", "http", "--host", "7.7.7.7", "--port", "8308", "--path", "/culdesac"], From c2f4b8c17d8aadace828aebbee3ea573b8caeea3 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Thu, 5 Feb 2026 16:29:31 -0500 Subject: [PATCH 16/26] Always pass the log_level All transport types accetp a log_level parameter. --- src/linux_mcp_server/config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/linux_mcp_server/config.py b/src/linux_mcp_server/config.py index a97018a7..bf83c040 100644 --- a/src/linux_mcp_server/config.py +++ b/src/linux_mcp_server/config.py @@ -68,12 +68,11 @@ def effective_known_hosts_path(self) -> Path: @property def transport_kwargs(self): - result = {} + result: dict[str, str | int] = {"log_level": self.log_level} if self.transport in {Transport.http, Transport.streamable_http}: result["host"] = self.host result["port"] = self.port result["path"] = self.path - result["log_level"] = self.log_level return result From 161f2faaf41f682fd695715e2a99f618c1c2c7c3 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Thu, 5 Feb 2026 17:13:17 -0500 Subject: [PATCH 17/26] Only ignore errors under test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove cli_exit_on_error parameter since that does something different and is not related to fixing the issue of pytest args getting picked up by Pydantic Settings and causing tests to not run. Use an expression to set the value of cli_ignore_unknown_args seems reasonable. It may be an issue if someone has the string “pytest” in the executable path: /home/pytest/.venv/bin/linux-mcp-server That might be an edge case we don’t need to worry about at this point. --- src/linux_mcp_server/config.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/linux_mcp_server/config.py b/src/linux_mcp_server/config.py index bf83c040..60b08b47 100644 --- a/src/linux_mcp_server/config.py +++ b/src/linux_mcp_server/config.py @@ -1,5 +1,7 @@ """Settings for linux-mcp-server""" +import sys + from pathlib import Path from pydantic import SecretStr @@ -22,9 +24,10 @@ class Config(BaseSettings): model_config = SettingsConfigDict( env_prefix="LINUX_MCP_", env_ignore_empty=True, - cli_exit_on_error=False, # Ignore errors for incorrect/extra parameters cli_hide_none_type=True, - cli_ignore_unknown_args=True, + # Only ignore errors for incorrect/extra parameters when testing + # https://github.com/pydantic/pydantic-settings/issues/391 + cli_ignore_unknown_args="pytest" in sys.argv[0], cli_implicit_flags=True, cli_kebab_case=True, cli_parse_args=True, From a9c9c255165cbb5a446a5d76788daad39fc9f837 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Thu, 5 Feb 2026 17:13:59 -0500 Subject: [PATCH 18/26] Be more explicit about pytest being the called executable --- src/linux_mcp_server/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/linux_mcp_server/config.py b/src/linux_mcp_server/config.py index 60b08b47..07269d11 100644 --- a/src/linux_mcp_server/config.py +++ b/src/linux_mcp_server/config.py @@ -27,7 +27,7 @@ class Config(BaseSettings): cli_hide_none_type=True, # Only ignore errors for incorrect/extra parameters when testing # https://github.com/pydantic/pydantic-settings/issues/391 - cli_ignore_unknown_args="pytest" in sys.argv[0], + cli_ignore_unknown_args=sys.argv[0].endswith("pytest"), cli_implicit_flags=True, cli_kebab_case=True, cli_parse_args=True, From c49aa7858828d46d966af2da752838896bbf7a47 Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:27:05 +0100 Subject: [PATCH 19/26] feat(network): add ip route tool --- src/linux_mcp_server/commands.py | 6 +++ src/linux_mcp_server/tools/__init__.py | 2 + src/linux_mcp_server/tools/network.py | 51 ++++++++++++++++++++ tests/tools/test_network.py | 65 ++++++++++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/src/linux_mcp_server/commands.py b/src/linux_mcp_server/commands.py index 67165b5d..f6755a9b 100644 --- a/src/linux_mcp_server/commands.py +++ b/src/linux_mcp_server/commands.py @@ -135,6 +135,12 @@ class CommandGroup(BaseModel): "stats": CommandSpec(args=("cat", "/proc/net/dev")), } ), + "ip_route": CommandGroup( + commands={ + "ipv4": CommandSpec(args=("ip", "route", "show")), + "ipv6": CommandSpec(args=("ip", "-6", "route", "show")), + } + ), # === Logs === "journal_logs": CommandGroup( commands={ diff --git a/src/linux_mcp_server/tools/__init__.py b/src/linux_mcp_server/tools/__init__.py index 06a7d834..972c15cc 100644 --- a/src/linux_mcp_server/tools/__init__.py +++ b/src/linux_mcp_server/tools/__init__.py @@ -3,6 +3,7 @@ from linux_mcp_server.tools.logs import read_log_file # network +from linux_mcp_server.tools.network import get_ip_route_table from linux_mcp_server.tools.network import get_listening_ports from linux_mcp_server.tools.network import get_network_connections from linux_mcp_server.tools.network import get_network_interfaces @@ -34,6 +35,7 @@ "get_cpu_information", "get_disk_usage", "get_hardware_information", + "get_ip_route_table", "get_journal_logs", "get_listening_ports", "get_memory_information", diff --git a/src/linux_mcp_server/tools/network.py b/src/linux_mcp_server/tools/network.py index fee3f424..9285cce7 100644 --- a/src/linux_mcp_server/tools/network.py +++ b/src/linux_mcp_server/tools/network.py @@ -12,11 +12,24 @@ from linux_mcp_server.parsers import parse_ss_connections from linux_mcp_server.parsers import parse_ss_listening from linux_mcp_server.server import mcp +from linux_mcp_server.utils import StrEnum from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers from linux_mcp_server.utils.types import Host from linux_mcp_server.utils.validation import is_successful_output +class RouteFamily(StrEnum): + IPV4 = "ipv4" + IPV6 = "ipv6" + ALL = "all" + + +def _format_route_output(stdout: str, label: str) -> str: + lines = [f"=== IP Route Table ({label}) ===\n"] + lines.append(stdout.strip()) + return "\n".join(lines) + + @mcp.tool( title="Get network interfaces", description="Get detailed information about network interfaces including address and traffic statistics.", @@ -103,3 +116,41 @@ async def get_listening_ports( ports = parse_ss_listening(stdout) return format_listening_ports(ports) return f"Error getting listening ports: return code {returncode}, stderr: {stderr}" + + +@mcp.tool( + title="Get IP route table", + description="Get IPv4/IPv6 route entries using ip route.", + tags={"network", "routing"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def get_ip_route_table( + family: RouteFamily = RouteFamily.IPV4, + host: Host = None, +) -> str: + """Get routing table entries. + + Retrieves routing table entries via the ip command for IPv4, IPv6, or both. + """ + if family == RouteFamily.ALL: + outputs: list[str] = [] + for subcommand, label in (("ipv4", "IPv4"), ("ipv6", "IPv6")): + cmd = get_command("ip_route", subcommand) + returncode, stdout, stderr = await cmd.run(host=host) + if is_successful_output(returncode, stdout): + outputs.append(_format_route_output(stdout, label)) + else: + outputs.append( + f"Error getting {label} routes: return code {returncode}, stderr: {stderr}", + ) + return "\n\n".join(outputs) + + cmd = get_command("ip_route", family.value) + returncode, stdout, stderr = await cmd.run(host=host) + + if is_successful_output(returncode, stdout): + label = "IPv4" if family == RouteFamily.IPV4 else "IPv6" + return _format_route_output(stdout, label) + return f"Error getting IP routes: return code {returncode}, stderr: {stderr}" diff --git a/tests/tools/test_network.py b/tests/tools/test_network.py index 504fe8a2..f2733d2a 100644 --- a/tests/tools/test_network.py +++ b/tests/tools/test_network.py @@ -211,3 +211,68 @@ async def test_get_listening_ports_error(self, mcp_client, mock_execute): match = re.compile(r"error calling tool.*raised intentionally", flags=re.I) with pytest.raises(ToolError, match=match): await mcp_client.call_tool("get_listening_ports") + + +class TestGetIpRouteTable: + """Test get_ip_route_table function.""" + + @pytest.mark.parametrize( + ("family", "mock_output", "expected_content"), + [ + pytest.param( + "ipv4", + "default via 192.168.1.1 dev eth0\n192.168.1.0/24 dev eth0 proto kernel scope link", + ["ip route table (ipv4)", "default via 192.168.1.1"], + id="ipv4", + ), + pytest.param( + "ipv6", + "default via fe80::1 dev eth0\n2001:db8::/64 dev eth0 proto kernel metric 256", + ["ip route table (ipv6)", "2001:db8::/64"], + id="ipv6", + ), + ], + ) + async def test_get_ip_route_table_success(self, mcp_client, mock_execute, family, mock_output, expected_content): + """Test getting ip route table for IPv4/IPv6.""" + mock_execute.return_value = (0, mock_output, "") + result = await mcp_client.call_tool("get_ip_route_table", arguments={"family": family}) + result_text = result.content[0].text.casefold() + + assert all(content.casefold() in result_text for content in expected_content) + + async def test_get_ip_route_table_all(self, mcp_client, mock_execute): + """Test getting both IPv4 and IPv6 route tables.""" + mock_execute.side_effect = [ + (0, "default via 192.168.1.1 dev eth0", ""), + (0, "default via fe80::1 dev eth0", ""), + ] + + result = await mcp_client.call_tool("get_ip_route_table", arguments={"family": "all"}) + result_text = result.content[0].text.casefold() + + assert "ip route table (ipv4)" in result_text + assert "ip route table (ipv6)" in result_text + assert mock_execute.call_count == 2 + + @pytest.mark.parametrize( + ("return_value",), + [ + pytest.param((1, "", "Command not found"), id="command_fails"), + pytest.param((0, "", ""), id="empty_output"), + ], + ) + async def test_get_ip_route_table_failure(self, mcp_client, mock_execute, return_value): + """Test getting ip route table when command fails or returns empty.""" + mock_execute.return_value = return_value + result = await mcp_client.call_tool("get_ip_route_table") + result_text = result.content[0].text.casefold() + + assert "error getting ip routes" in result_text + + async def test_get_ip_route_table_error(self, mcp_client, mock_execute): + """Test getting ip route table with general error.""" + mock_execute.side_effect = ValueError("Raised intentionally") + match = re.compile(r"error calling tool.*raised intentionally", flags=re.I) + with pytest.raises(ToolError, match=match): + await mcp_client.call_tool("get_ip_route_table") From 598271837949fd1c2c8485ef4e191d916529b61a Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:27:08 +0100 Subject: [PATCH 20/26] docs(network): document ip route tool --- docs/api/index.md | 2 +- docs/cheatsheet.md | 1 + docs/usage.md | 11 ++++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index d891d0ee..f4c4c7f8 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -19,7 +19,7 @@ MCP tools organized by category: - **[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 +- **[Network](tools/network.md)** - Network interfaces, connections, ports, routes - **[Storage](tools/storage.md)** - Block devices, directory and file listing ### Utilities diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 69d8f27b..06b1fdd2 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -31,6 +31,7 @@ A quick reference guide for common tasks and the tools to use. | **IP Addresses** | `get_network_interfaces` | "What is my IP address?" | | **Open Ports** | `get_listening_ports` | "What ports are open?" | | **Connections** | `get_network_connections` | "Who is connected to port 22?" | +| **Routes** | `get_ip_route_table` | "Show me the routing table" | ## 📂 Files & Storage diff --git a/docs/usage.md b/docs/usage.md index 1c1a3218..f87ce3a7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -212,6 +212,15 @@ Returns ports that are listening on the system. **Example use case:** "What services are listening on network ports?" +#### `get_ip_route_table` +Returns IPv4/IPv6 routing table entries using `ip route`. + +**Parameters:** +- `family` (string, optional): `ipv4`, `ipv6`, or `all` (default: `ipv4`) +- `host` (string, optional): Remote host identifier + +**Example use case:** "Show me the IPv4 routing table." + ### Storage & Disk Analysis #### `list_block_devices` @@ -266,6 +275,7 @@ See [Client Configuration](clients.md) for environment variables and AI agent in 1. "Show me all network interfaces and their status" → `get_network_interfaces` 2. "What ports are listening on this system?" → `get_listening_ports` 3. "Show me active network connections" → `get_network_connections` +4. "Show me the routing table" → `get_ip_route_table` ### Disk Space Problems 1. "Show me disk usage for all filesystems" → `get_disk_usage` @@ -318,4 +328,3 @@ See the [Troubleshooting Guide](troubleshooting.md) for detailed solutions, debu 4. **Security First**: Only whitelist log files that are necessary for diagnostics. 5. **Regular Updates**: Keep the MCP server and its dependencies updated for security and compatibility. - From 0c6a3417ab862e139b13d33bd68d931c92d1f636 Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:27:10 +0100 Subject: [PATCH 21/26] test(storage): skip restricted path test when root --- tests/tools/storage/test_list_directories.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/tools/storage/test_list_directories.py b/tests/tools/storage/test_list_directories.py index dc139a53..9f6d894d 100644 --- a/tests/tools/storage/test_list_directories.py +++ b/tests/tools/storage/test_list_directories.py @@ -1,3 +1,4 @@ +import os import sys import pytest @@ -110,6 +111,7 @@ async def test_list_directories_nonexistent_path(tmp_path, mcp_client): @pytest.mark.skipif(sys.platform != "linux", reason="requires GNU version of coreutils/findutils") +@pytest.mark.skipif(os.geteuid() == 0, reason="root can access restricted paths") async def test_list_directories_restricted_path(restricted_path, mcp_client): with pytest.raises(ToolError) as exc_info: await mcp_client.call_tool("list_directories", arguments={"path": str(restricted_path)}) From c20af9b62ddaea35a4506e885db53ead2e826c6f Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:00:11 +0100 Subject: [PATCH 22/26] test(network): cover partial failure for ip route table --- tests/tools/test_network.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/tools/test_network.py b/tests/tools/test_network.py index f2733d2a..82157d40 100644 --- a/tests/tools/test_network.py +++ b/tests/tools/test_network.py @@ -255,6 +255,20 @@ async def test_get_ip_route_table_all(self, mcp_client, mock_execute): assert "ip route table (ipv6)" in result_text assert mock_execute.call_count == 2 + async def test_get_ip_route_table_all_partial_failure(self, mcp_client, mock_execute): + """Test getting both IPv4 and IPv6 route tables with a failure.""" + mock_execute.side_effect = [ + (0, "default via 192.168.1.1 dev eth0", ""), + (1, "", "Command not found"), + ] + + result = await mcp_client.call_tool("get_ip_route_table", arguments={"family": "all"}) + result_text = result.content[0].text.casefold() + + assert "ip route table (ipv4)" in result_text + assert "error getting ipv6 routes" in result_text + assert mock_execute.call_count == 2 + @pytest.mark.parametrize( ("return_value",), [ From 366729f2ca74e253f37df0498a895a5fb322365c Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:56:39 +0100 Subject: [PATCH 23/26] feat(dnf): add read-only dnf tools --- README.md | 1 + docs/api/index.md | 1 + docs/api/tools/dnf.md | 6 + docs/cheatsheet.md | 9 ++ docs/contributing.md | 1 + docs/usage.md | 35 ++++++ src/linux_mcp_server/commands.py | 21 ++++ src/linux_mcp_server/tools/__init__.py | 10 ++ src/linux_mcp_server/tools/dnf.py | 110 ++++++++++++++++++ src/linux_mcp_server/utils/validation.py | 26 +++++ tests/tools/test_dnf.py | 137 +++++++++++++++++++++++ 11 files changed, 357 insertions(+) create mode 100644 docs/api/tools/dnf.md create mode 100644 src/linux_mcp_server/tools/dnf.py create mode 100644 tests/tools/test_dnf.py diff --git a/README.md b/README.md index 6ba5d3b1..8a1674be 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A Model Context Protocol (MCP) server for read-only Linux system administration, - **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 +- **Package Insights (DNF)**: Query installed packages, available packages, and repositories - **Configurable Log Access**: Control which log files can be accessed via environment variables - **RHEL/systemd Focused**: Optimized for Red Hat Enterprise Linux systems diff --git a/docs/api/index.md b/docs/api/index.md index f4c4c7f8..472358f4 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -19,6 +19,7 @@ MCP tools organized by category: - **[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 +- **[DNF](tools/dnf.md)** - Package and repository information - **[Network](tools/network.md)** - Network interfaces, connections, ports, routes - **[Storage](tools/storage.md)** - Block devices, directory and file listing diff --git a/docs/api/tools/dnf.md b/docs/api/tools/dnf.md new file mode 100644 index 00000000..b02d2ade --- /dev/null +++ b/docs/api/tools/dnf.md @@ -0,0 +1,6 @@ +# DNF Tools + +::: linux_mcp_server.tools.dnf + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 06b1fdd2..b5a9b846 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -24,6 +24,15 @@ A quick reference guide for common tasks and the tools to use. | **Service Logs** | `get_service_logs` | "Show recent logs for sshd." | | **Specific Log File** | `read_log_file` | "Read the last 50 lines of /var/log/messages." | +## 📦 Packages (DNF) + +| I want to check... | Use this tool | Example Prompt | +|-------------------|---------------|----------------| +| **Installed Packages** | `list_dnf_installed_packages` | "List all installed packages." | +| **Available Packages** | `list_dnf_available_packages` | "What packages are available in repos?" | +| **Package Details** | `get_dnf_package_info` | "Show details for bash." | +| **Repositories** | `list_dnf_repositories` | "Which repositories are enabled?" | + ## 🌐 Network | I want to check... | Use this tool | Example Prompt | diff --git a/docs/contributing.md b/docs/contributing.md index b8a66518..f477b340 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -79,6 +79,7 @@ pytest linux-mcp-server/ ├── src/linux_mcp_server/ │ ├── tools/ # MCP tool implementations +│ │ ├── dnf.py # DNF package manager tools │ │ ├── logs.py # Log reading tools │ │ ├── network.py # Network diagnostic tools │ │ ├── processes.py # Process management tools diff --git a/docs/usage.md b/docs/usage.md index f87ce3a7..1bcfc9b9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -189,6 +189,41 @@ Reads a specific log file (must be in the allowed list). **Security Note:** This tool respects the `LINUX_MCP_ALLOWED_LOG_PATHS` environment variable whitelist. +### Package Management (DNF) + +#### `list_dnf_installed_packages` +Lists installed packages via `dnf`. + +**Parameters:** +- `host` (string, optional): Remote host identifier + +**Example use case:** "Show me all installed packages." + +#### `list_dnf_available_packages` +Lists packages available in configured repositories. + +**Parameters:** +- `host` (string, optional): Remote host identifier + +**Example use case:** "Which packages are available from enabled repos?" + +#### `get_dnf_package_info` +Returns detailed information for a specific package. + +**Parameters:** +- `package` (string, required): Package name (e.g., "bash", "openssl") +- `host` (string, optional): Remote host identifier + +**Example use case:** "Get details for the bash package." + +#### `list_dnf_repositories` +Lists configured repositories and their status. + +**Parameters:** +- `host` (string, optional): Remote host identifier + +**Example use case:** "Show me all configured repositories and whether they are enabled." + ### Network Diagnostics #### `get_network_interfaces` diff --git a/src/linux_mcp_server/commands.py b/src/linux_mcp_server/commands.py index f6755a9b..ce2f6cec 100644 --- a/src/linux_mcp_server/commands.py +++ b/src/linux_mcp_server/commands.py @@ -239,6 +239,27 @@ class CommandGroup(BaseModel): "default": CommandSpec(args=("cat", "{path}")), } ), + # === Packages (dnf) === + "dnf_list_installed": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "list", "installed")), + } + ), + "dnf_list_available": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "list", "available")), + } + ), + "dnf_package_info": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "info", "{package}")), + } + ), + "dnf_repolist": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "repolist", "--all")), + } + ), # === System Info === "system_info": CommandGroup( commands={ diff --git a/src/linux_mcp_server/tools/__init__.py b/src/linux_mcp_server/tools/__init__.py index 972c15cc..1fec6316 100644 --- a/src/linux_mcp_server/tools/__init__.py +++ b/src/linux_mcp_server/tools/__init__.py @@ -1,3 +1,9 @@ +# packages (dnf) +from linux_mcp_server.tools.dnf import get_dnf_package_info +from linux_mcp_server.tools.dnf import list_dnf_available_packages +from linux_mcp_server.tools.dnf import list_dnf_installed_packages +from linux_mcp_server.tools.dnf import list_dnf_repositories + # logs from linux_mcp_server.tools.logs import get_journal_logs from linux_mcp_server.tools.logs import read_log_file @@ -45,8 +51,12 @@ "get_service_logs", "get_service_status", "get_system_information", + "get_dnf_package_info", "list_block_devices", "list_directories", + "list_dnf_available_packages", + "list_dnf_installed_packages", + "list_dnf_repositories", "list_files", "list_processes", "list_services", diff --git a/src/linux_mcp_server/tools/dnf.py b/src/linux_mcp_server/tools/dnf.py new file mode 100644 index 00000000..041f8dde --- /dev/null +++ b/src/linux_mcp_server/tools/dnf.py @@ -0,0 +1,110 @@ +"""DNF package manager tools.""" + +import typing as t + +from mcp.types import ToolAnnotations +from pydantic import Field +from pydantic.functional_validators import BeforeValidator + +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 +from linux_mcp_server.utils.validation import is_empty_output +from linux_mcp_server.utils.validation import validate_dnf_package_name + + +def _is_package_not_found(stdout: str, stderr: str) -> bool: + combined = f"{stdout}\n{stderr}".casefold() + return "no matching packages to list" in combined or "no match for argument" in combined + + +async def _run_dnf_command(command_name: str, host: Host | None = None, **kwargs: object) -> str: + cmd = get_command(command_name) + returncode, stdout, stderr = await cmd.run(host=host, **kwargs) + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="List installed packages (dnf)", + description="List installed packages via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def list_dnf_installed_packages( + host: Host = None, +) -> str: + """List installed packages using dnf.""" + return await _run_dnf_command("dnf_list_installed", host=host) + + +@mcp.tool( + title="List available packages (dnf)", + description="List available packages via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def list_dnf_available_packages( + host: Host = None, +) -> str: + """List available packages using dnf.""" + return await _run_dnf_command("dnf_list_available", host=host) + + +@mcp.tool( + title="Package info (dnf)", + description="Get details for a specific package via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def get_dnf_package_info( + package: t.Annotated[ + str, + BeforeValidator(validate_dnf_package_name), + Field(description="Package name", examples=["bash", "openssl", "vim-enhanced", "python3"]), + ], + host: Host = None, +) -> str: + """Get package details using dnf.""" + cmd = get_command("dnf_package_info") + returncode, stdout, stderr = await cmd.run(host=host, package=package) + + if _is_package_not_found(stdout, stderr): + return f"Package '{package}' not found." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="List repositories (dnf)", + description="List configured repositories via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def list_dnf_repositories( + host: Host = None, +) -> str: + """List configured repositories using dnf.""" + return await _run_dnf_command("dnf_repolist", host=host) diff --git a/src/linux_mcp_server/utils/validation.py b/src/linux_mcp_server/utils/validation.py index 9efde7fa..a73b1f92 100644 --- a/src/linux_mcp_server/utils/validation.py +++ b/src/linux_mcp_server/utils/validation.py @@ -1,3 +1,5 @@ +import re + from pathlib import Path @@ -56,6 +58,30 @@ def validate_path(path: str) -> Path: return Path(path) +def validate_dnf_package_name(value: str) -> str: + """Validate a dnf package identifier for safety. + + Allows a conservative RPM token charset (letters, digits, . _ + : -). + Rejects empty values, whitespace/control characters, leading '-' and slashes. + """ + if not value: + raise ValueError("Package name cannot be empty") + + if any(c in value for c in ["\n", "\r", "\x00", "\t", " "]): + raise ValueError("Package name contains invalid characters") + + if value.startswith("-"): + raise ValueError("Package name cannot start with '-'") + + if "/" in value: + raise ValueError("Package name cannot contain '/'") + + if not re.fullmatch(r"[A-Za-z0-9._+:-]+", value): + raise ValueError("Package name contains invalid characters") + + return value + + def is_empty_output(stdout: str | None) -> bool: """Check if command output is empty or whitespace-only. diff --git a/tests/tools/test_dnf.py b/tests/tools/test_dnf.py new file mode 100644 index 00000000..e60b3499 --- /dev/null +++ b/tests/tools/test_dnf.py @@ -0,0 +1,137 @@ +"""Tests for dnf package manager tools.""" + +import pytest + +from linux_mcp_server.utils.validation import validate_dnf_package_name + + +class TestDnfValidation: + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("bash", "bash"), + ("openssl-libs", "openssl-libs"), + ("python3.12", "python3.12"), + ("glibc:2.28", "glibc:2.28"), + ], + ) + def test_validate_dnf_package_name_valid(self, value, expected): + assert validate_dnf_package_name(value) == expected + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "bad name", + "bad\tname", + "bad\nname", + "-bad", + "bad/name", + "bad*name", + "bad?name", + ], + ) + def test_validate_dnf_package_name_invalid(self, value): + with pytest.raises(ValueError): + validate_dnf_package_name(value) + + +class TestDnfToolsRemote: + @pytest.mark.parametrize( + "tool_name", + [ + "list_dnf_installed_packages", + "list_dnf_available_packages", + "list_dnf_repositories", + ], + ) + async def test_dnf_list_tools_success(self, mcp_client, mock_execute_with_fallback, tool_name): + mock_execute_with_fallback.return_value = (0, "Some dnf output", "") + + result = await mcp_client.call_tool( + tool_name, + arguments={"host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "Some dnf output" in result_text + call_kwargs = mock_execute_with_fallback.call_args[1] + assert call_kwargs["host"] == "remote.example.com" + + async def test_dnf_list_tools_empty_output(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, " ", "") + + result = await mcp_client.call_tool( + "list_dnf_installed_packages", + arguments={"host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "no output returned" in result_text + + async def test_dnf_list_tools_error(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "dnf error") + + result = await mcp_client.call_tool( + "list_dnf_available_packages", + arguments={"host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "error running dnf" in result_text + assert "dnf error" in result_text + + async def test_get_dnf_package_info_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "Name : bash", "") + + result = await mcp_client.call_tool( + "get_dnf_package_info", + arguments={"package": "bash", "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "Name : bash" in result_text + + async def test_get_dnf_package_info_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "No match for argument: missing") + + result = await mcp_client.call_tool( + "get_dnf_package_info", + arguments={"package": "missing", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "not found" in result_text + + async def test_get_dnf_package_info_error(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "dnf error") + + result = await mcp_client.call_tool( + "get_dnf_package_info", + arguments={"package": "bash", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "error running dnf" in result_text + + async def test_get_dnf_package_info_empty_output(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, " ", "") + + result = await mcp_client.call_tool( + "get_dnf_package_info", + arguments={"package": "bash", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "no output returned" in result_text + + async def test_get_dnf_package_info_invalid_package_name(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "get_dnf_package_info", + arguments={"package": "bad name", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() From 9c77ff531bf0a731b4664ed1adf4974f89b91277 Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:01:38 +0100 Subject: [PATCH 24/26] feat(dnf): add repo, group, module, provides tools --- README.md | 2 +- docs/cheatsheet.md | 7 + docs/usage.md | 61 +++++ src/linux_mcp_server/commands.py | 35 +++ src/linux_mcp_server/tools/__init__.py | 14 + src/linux_mcp_server/tools/dnf.py | 199 ++++++++++++++ src/linux_mcp_server/utils/validation.py | 101 +++++++ tests/tools/test_dnf.py | 318 +++++++++++++++++++++++ 8 files changed, 736 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a1674be..9fc74360 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A Model Context Protocol (MCP) server for read-only Linux system administration, - **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 -- **Package Insights (DNF)**: Query installed packages, available packages, and repositories +- **Package Insights (DNF)**: Query packages, repositories, provides, groups, and modules - **Configurable Log Access**: Control which log files can be accessed via environment variables - **RHEL/systemd Focused**: Optimized for Red Hat Enterprise Linux systems diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index b5a9b846..825d510a 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -32,6 +32,13 @@ A quick reference guide for common tasks and the tools to use. | **Available Packages** | `list_dnf_available_packages` | "What packages are available in repos?" | | **Package Details** | `get_dnf_package_info` | "Show details for bash." | | **Repositories** | `list_dnf_repositories` | "Which repositories are enabled?" | +| **File Provides** | `dnf_provides` | "Which package provides /usr/bin/python3?" | +| **Repository Info** | `get_dnf_repo_info` | "Show details for baseos." | +| **Group List** | `list_dnf_groups` | "List all package groups." | +| **Group Info** | `get_dnf_group_info` | "Show details for Development Tools." | +| **Group Summary** | `get_dnf_group_summary` | "Summarize installed groups." | +| **Module List** | `list_dnf_modules` | "List nodejs module streams." | +| **Module Provides** | `dnf_module_provides` | "Which module provides python3?" | ## 🌐 Network diff --git a/docs/usage.md b/docs/usage.md index 1bcfc9b9..113b27dc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -224,6 +224,67 @@ Lists configured repositories and their status. **Example use case:** "Show me all configured repositories and whether they are enabled." +#### `dnf_provides` +Finds packages that provide a file or binary. + +**Parameters:** +- `query` (string, required): File path or binary name (e.g., "/usr/bin/python3", "libssl.so.3") +- `host` (string, optional): Remote host identifier + +**Example use case:** "Which package provides /usr/bin/python3?" + +#### `get_dnf_repo_info` +Shows detailed information for a specific repository. + +**Parameters:** +- `repo_id` (string, required): Repository id (e.g., "baseos", "appstream") +- `host` (string, optional): Remote host identifier + +**Example use case:** "Show details for the baseos repository." + +#### `list_dnf_groups` +Lists available and installed package groups. + +**Parameters:** +- `host` (string, optional): Remote host identifier + +**Example use case:** "List all package groups." + +#### `get_dnf_group_info` +Shows details for a specific package group. + +**Parameters:** +- `group` (string, required): Group name (e.g., "Development Tools") +- `host` (string, optional): Remote host identifier + +**Example use case:** "Show details for the Development Tools group." + +#### `get_dnf_group_summary` +Shows a summary of installed and available groups. + +**Parameters:** +- `host` (string, optional): Remote host identifier + +**Example use case:** "Summarize installed and available groups." + +#### `list_dnf_modules` +Lists modules (optionally filtered by module name). + +**Parameters:** +- `module` (string, optional): Module name filter (e.g., "nodejs") +- `host` (string, optional): Remote host identifier + +**Example use case:** "List available nodejs module streams." + +#### `dnf_module_provides` +Shows modules that provide a specific package. + +**Parameters:** +- `package` (string, required): Package name (e.g., "python3") +- `host` (string, optional): Remote host identifier + +**Example use case:** "Which module provides python3?" + ### Network Diagnostics #### `get_network_interfaces` diff --git a/src/linux_mcp_server/commands.py b/src/linux_mcp_server/commands.py index ce2f6cec..0b2048a8 100644 --- a/src/linux_mcp_server/commands.py +++ b/src/linux_mcp_server/commands.py @@ -260,6 +260,41 @@ class CommandGroup(BaseModel): "default": CommandSpec(args=("dnf", "repolist", "--all")), } ), + "dnf_provides": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "provides", "{query}")), + } + ), + "dnf_repo_info": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "repoinfo", "{repo_id}")), + } + ), + "dnf_group_list": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "group", "list")), + } + ), + "dnf_group_info": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "group", "info", "{group}")), + } + ), + "dnf_group_summary": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "group", "summary")), + } + ), + "dnf_module_list": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "module", "list"), optional_flags={"module": ("{module}",)}), + } + ), + "dnf_module_provides": CommandGroup( + commands={ + "default": CommandSpec(args=("dnf", "module", "provides", "{package}")), + } + ), # === System Info === "system_info": CommandGroup( commands={ diff --git a/src/linux_mcp_server/tools/__init__.py b/src/linux_mcp_server/tools/__init__.py index 1fec6316..952b5a24 100644 --- a/src/linux_mcp_server/tools/__init__.py +++ b/src/linux_mcp_server/tools/__init__.py @@ -1,7 +1,14 @@ # packages (dnf) +from linux_mcp_server.tools.dnf import dnf_module_provides +from linux_mcp_server.tools.dnf import dnf_provides +from linux_mcp_server.tools.dnf import get_dnf_group_info +from linux_mcp_server.tools.dnf import get_dnf_group_summary from linux_mcp_server.tools.dnf import get_dnf_package_info +from linux_mcp_server.tools.dnf import get_dnf_repo_info from linux_mcp_server.tools.dnf import list_dnf_available_packages +from linux_mcp_server.tools.dnf import list_dnf_groups from linux_mcp_server.tools.dnf import list_dnf_installed_packages +from linux_mcp_server.tools.dnf import list_dnf_modules from linux_mcp_server.tools.dnf import list_dnf_repositories # logs @@ -52,14 +59,21 @@ "get_service_status", "get_system_information", "get_dnf_package_info", + "get_dnf_group_info", + "get_dnf_group_summary", + "get_dnf_repo_info", "list_block_devices", "list_directories", "list_dnf_available_packages", "list_dnf_installed_packages", + "list_dnf_groups", + "list_dnf_modules", "list_dnf_repositories", "list_files", "list_processes", "list_services", + "dnf_module_provides", + "dnf_provides", "read_file", "read_log_file", ] diff --git a/src/linux_mcp_server/tools/dnf.py b/src/linux_mcp_server/tools/dnf.py index 041f8dde..dcd471a3 100644 --- a/src/linux_mcp_server/tools/dnf.py +++ b/src/linux_mcp_server/tools/dnf.py @@ -12,7 +12,11 @@ from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers from linux_mcp_server.utils.types import Host from linux_mcp_server.utils.validation import is_empty_output +from linux_mcp_server.utils.validation import validate_dnf_group_name from linux_mcp_server.utils.validation import validate_dnf_package_name +from linux_mcp_server.utils.validation import validate_dnf_provides_query +from linux_mcp_server.utils.validation import validate_dnf_repo_id +from linux_mcp_server.utils.validation import validate_optional_dnf_module_name def _is_package_not_found(stdout: str, stderr: str) -> bool: @@ -20,6 +24,11 @@ def _is_package_not_found(stdout: str, stderr: str) -> bool: return "no matching packages to list" in combined or "no match for argument" in combined +def _matches_any_message(stdout: str, stderr: str, patterns: t.Sequence[str]) -> bool: + combined = f"{stdout}\n{stderr}".casefold() + return any(pattern in combined for pattern in patterns) + + async def _run_dnf_command(command_name: str, host: Host | None = None, **kwargs: object) -> str: cmd = get_command(command_name) returncode, stdout, stderr = await cmd.run(host=host, **kwargs) @@ -108,3 +117,193 @@ async def list_dnf_repositories( ) -> str: """List configured repositories using dnf.""" return await _run_dnf_command("dnf_repolist", host=host) + + +@mcp.tool( + title="Find packages providing a file (dnf)", + description="Find packages that provide a specific file or binary via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def dnf_provides( + query: t.Annotated[ + str, + BeforeValidator(validate_dnf_provides_query), + Field(description="File path or binary name", examples=["/usr/bin/python3", "libssl.so.3", "*/libssl.so.*"]), + ], + host: Host = None, +) -> str: + """Find packages providing a file or binary using dnf.""" + cmd = get_command("dnf_provides") + returncode, stdout, stderr = await cmd.run(host=host, query=query) + + if _matches_any_message(stdout, stderr, ("no matches found", "no match for argument")): + return f"No packages provide '{query}'." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="Repository info (dnf)", + description="Get detailed information for a specific repository via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def get_dnf_repo_info( + repo_id: t.Annotated[ + str, + BeforeValidator(validate_dnf_repo_id), + Field(description="Repository id", examples=["baseos", "appstream"]), + ], + host: Host = None, +) -> str: + """Get repository details using dnf.""" + cmd = get_command("dnf_repo_info") + returncode, stdout, stderr = await cmd.run(host=host, repo_id=repo_id) + + if _matches_any_message(stdout, stderr, ("no matching repo", "no repository match", "no such repository")): + return f"Repository '{repo_id}' not found." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="List groups (dnf)", + description="List available and installed groups via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def list_dnf_groups( + host: Host = None, +) -> str: + """List group information using dnf.""" + return await _run_dnf_command("dnf_group_list", host=host) + + +@mcp.tool( + title="Group info (dnf)", + description="Get details for a specific group via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def get_dnf_group_info( + group: t.Annotated[ + str, + BeforeValidator(validate_dnf_group_name), + Field(description="Group name", examples=["Development Tools", "Server with GUI"]), + ], + host: Host = None, +) -> str: + """Get group details using dnf.""" + cmd = get_command("dnf_group_info") + returncode, stdout, stderr = await cmd.run(host=host, group=group) + + if _matches_any_message(stdout, stderr, ("no groups matched", "no match for argument")): + return f"Group '{group}' not found." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="Group summary (dnf)", + description="Show a summary of installed and available groups via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def get_dnf_group_summary( + host: Host = None, +) -> str: + """Get group summary using dnf.""" + return await _run_dnf_command("dnf_group_summary", host=host) + + +@mcp.tool( + title="List modules (dnf)", + description="List modules or filter by module name via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def list_dnf_modules( + module: t.Annotated[ + str | None, + BeforeValidator(validate_optional_dnf_module_name), + Field(description="Optional module name filter", examples=["nodejs", "python39"]), + ] = None, + host: Host = None, +) -> str: + """List modules using dnf.""" + cmd = get_command("dnf_module_list") + returncode, stdout, stderr = await cmd.run(host=host, module=module) + + if module and _matches_any_message(stdout, stderr, ("no matching modules to list", "no match for argument")): + return f"No modules matched '{module}'." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout + + +@mcp.tool( + title="Module provides (dnf)", + description="Find modules that provide a specific package via dnf.", + tags={"packages", "dnf", "troubleshooting"}, + annotations=ToolAnnotations(readOnlyHint=True), +) +@log_tool_call +@disallow_local_execution_in_containers +async def dnf_module_provides( + package: t.Annotated[ + str, + BeforeValidator(validate_dnf_package_name), + Field(description="Package name", examples=["python3", "nodejs"]), + ], + host: Host = None, +) -> str: + """Find modules providing a package using dnf.""" + cmd = get_command("dnf_module_provides") + returncode, stdout, stderr = await cmd.run(host=host, package=package) + + if _matches_any_message(stdout, stderr, ("no matching modules", "no match for argument")): + return f"No modules provide '{package}'." + + if returncode != 0: + return f"Error running dnf: {stderr}" + + if is_empty_output(stdout): + return "No output returned by dnf." + + return stdout diff --git a/src/linux_mcp_server/utils/validation.py b/src/linux_mcp_server/utils/validation.py index a73b1f92..c72bc5bb 100644 --- a/src/linux_mcp_server/utils/validation.py +++ b/src/linux_mcp_server/utils/validation.py @@ -82,6 +82,107 @@ def validate_dnf_package_name(value: str) -> str: return value +def validate_dnf_repo_id(value: str) -> str: + """Validate a dnf repository identifier for safety. + + Allows a conservative charset (letters, digits, . _ + : -). + Rejects empty values, whitespace/control characters, leading '-' and slashes. + """ + if not value: + raise ValueError("Repository id cannot be empty") + + if any(c in value for c in ["\n", "\r", "\x00", "\t", " "]): + raise ValueError("Repository id contains invalid characters") + + if value.startswith("-"): + raise ValueError("Repository id cannot start with '-'") + + if "/" in value: + raise ValueError("Repository id cannot contain '/'") + + if not re.fullmatch(r"[A-Za-z0-9._+:-]+", value): + raise ValueError("Repository id contains invalid characters") + + return value + + +def validate_dnf_group_name(value: str) -> str: + """Validate a dnf group name for safety. + + Allows letters, digits, spaces and common punctuation used in group names. + Rejects empty values, control characters, and leading '-'. + """ + if not value: + raise ValueError("Group name cannot be empty") + + if any(c in value for c in ["\n", "\r", "\x00", "\t"]): + raise ValueError("Group name contains invalid characters") + + if value.startswith("-"): + raise ValueError("Group name cannot start with '-'") + + if not re.fullmatch(r"[A-Za-z0-9 ._+:'()&,-]+", value): + raise ValueError("Group name contains invalid characters") + + return value + + +def validate_dnf_module_name(value: str) -> str: + """Validate a dnf module name for safety. + + Allows a conservative charset (letters, digits, . _ + : -). + Rejects empty values, whitespace/control characters, leading '-' and slashes. + """ + if not value: + raise ValueError("Module name cannot be empty") + + if any(c in value for c in ["\n", "\r", "\x00", "\t", " "]): + raise ValueError("Module name contains invalid characters") + + if value.startswith("-"): + raise ValueError("Module name cannot start with '-'") + + if "/" in value: + raise ValueError("Module name cannot contain '/'") + + if not re.fullmatch(r"[A-Za-z0-9._+:-]+", value): + raise ValueError("Module name contains invalid characters") + + return value + + +def validate_optional_dnf_module_name(value: str | None) -> str | None: + """Validate an optional dnf module name.""" + if value is None: + return None + + return validate_dnf_module_name(value) + + +def validate_dnf_provides_query(value: str) -> str: + """Validate a dnf provides query for safety. + + Accepts file paths or binary names with optional glob wildcards (*, ?). + Rejects empty values, whitespace/control characters, and leading '-'. + """ + if not value: + raise ValueError("Provides query cannot be empty") + + if any(c in value for c in ["\n", "\r", "\x00", "\t", " "]): + raise ValueError("Provides query contains invalid characters") + + if value.startswith("-"): + raise ValueError("Provides query cannot start with '-'") + + if ".." in value: + raise ValueError("Provides query cannot contain '..'") + + if not re.fullmatch(r"[A-Za-z0-9._+:/@*?-]+", value): + raise ValueError("Provides query contains invalid characters") + + return value + + def is_empty_output(stdout: str | None) -> bool: """Check if command output is empty or whitespace-only. diff --git a/tests/tools/test_dnf.py b/tests/tools/test_dnf.py index e60b3499..50821661 100644 --- a/tests/tools/test_dnf.py +++ b/tests/tools/test_dnf.py @@ -2,7 +2,12 @@ import pytest +from linux_mcp_server.utils.validation import validate_dnf_group_name +from linux_mcp_server.utils.validation import validate_dnf_module_name from linux_mcp_server.utils.validation import validate_dnf_package_name +from linux_mcp_server.utils.validation import validate_dnf_provides_query +from linux_mcp_server.utils.validation import validate_dnf_repo_id +from linux_mcp_server.utils.validation import validate_optional_dnf_module_name class TestDnfValidation: @@ -36,6 +41,121 @@ def test_validate_dnf_package_name_invalid(self, value): with pytest.raises(ValueError): validate_dnf_package_name(value) + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("baseos", "baseos"), + ("appstream", "appstream"), + ("custom-repo", "custom-repo"), + ("repo:1", "repo:1"), + ], + ) + def test_validate_dnf_repo_id_valid(self, value, expected): + assert validate_dnf_repo_id(value) == expected + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "bad repo", + "bad\trepo", + "bad\nrepo", + "-bad", + "bad/repo", + "bad*repo", + ], + ) + def test_validate_dnf_repo_id_invalid(self, value): + with pytest.raises(ValueError): + validate_dnf_repo_id(value) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("Development Tools", "Development Tools"), + ("Server with GUI", "Server with GUI"), + ("Container Management", "Container Management"), + ("Workstation & GUI", "Workstation & GUI"), + ], + ) + def test_validate_dnf_group_name_valid(self, value, expected): + assert validate_dnf_group_name(value) == expected + + @pytest.mark.parametrize( + "value", + [ + "", + "\t", + "bad\tname", + "bad\nname", + "-bad", + "bad/name", + "bad*name", + ], + ) + def test_validate_dnf_group_name_invalid(self, value): + with pytest.raises(ValueError): + validate_dnf_group_name(value) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("nodejs", "nodejs"), + ("python39", "python39"), + ("nodejs:18", "nodejs:18"), + ], + ) + def test_validate_dnf_module_name_valid(self, value, expected): + assert validate_dnf_module_name(value) == expected + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "bad name", + "-bad", + "bad/name", + "bad*name", + ], + ) + def test_validate_dnf_module_name_invalid(self, value): + with pytest.raises(ValueError): + validate_dnf_module_name(value) + + def test_validate_optional_dnf_module_name_none(self): + assert validate_optional_dnf_module_name(None) is None + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("/usr/bin/python3", "/usr/bin/python3"), + ("libssl.so.3", "libssl.so.3"), + ("*/libssl.so.*", "*/libssl.so.*"), + ("usr/lib64/libcrypto.so.3", "usr/lib64/libcrypto.so.3"), + ], + ) + def test_validate_dnf_provides_query_valid(self, value, expected): + assert validate_dnf_provides_query(value) == expected + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "bad name", + "bad\tname", + "bad\nname", + "-bad", + "../usr/bin/bash", + "bad|name", + ], + ) + def test_validate_dnf_provides_query_invalid(self, value): + with pytest.raises(ValueError): + validate_dnf_provides_query(value) + class TestDnfToolsRemote: @pytest.mark.parametrize( @@ -44,6 +164,8 @@ class TestDnfToolsRemote: "list_dnf_installed_packages", "list_dnf_available_packages", "list_dnf_repositories", + "list_dnf_groups", + "get_dnf_group_summary", ], ) async def test_dnf_list_tools_success(self, mcp_client, mock_execute_with_fallback, tool_name): @@ -135,3 +257,199 @@ async def test_get_dnf_package_info_invalid_package_name(self, mcp_client, mock_ assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() mock_execute_with_fallback.assert_not_called() + + async def test_dnf_provides_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "file provided by", "") + + result = await mcp_client.call_tool( + "dnf_provides", + arguments={"query": "/usr/bin/python3", "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "file provided by" in result_text + + async def test_dnf_provides_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "No matches found") + + result = await mcp_client.call_tool( + "dnf_provides", + arguments={"query": "/missing/file", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "no packages provide" in result_text + + async def test_dnf_provides_invalid_query(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "dnf_provides", + arguments={"query": "bad name", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + async def test_get_dnf_repo_info_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "Repo-id : baseos", "") + + result = await mcp_client.call_tool( + "get_dnf_repo_info", + arguments={"repo_id": "baseos", "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "Repo-id" in result_text + + async def test_get_dnf_repo_info_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "No matching repo to modify: missing") + + result = await mcp_client.call_tool( + "get_dnf_repo_info", + arguments={"repo_id": "missing", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "not found" in result_text + + async def test_get_dnf_repo_info_invalid_repo_id(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "get_dnf_repo_info", + arguments={"repo_id": "bad repo", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + async def test_get_dnf_group_info_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "Group: Development Tools", "") + + result = await mcp_client.call_tool( + "get_dnf_group_info", + arguments={"group": "Development Tools", "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "Development Tools" in result_text + + async def test_get_dnf_group_info_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "No groups matched: Missing Group") + + result = await mcp_client.call_tool( + "get_dnf_group_info", + arguments={"group": "Missing Group", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "not found" in result_text + + async def test_get_dnf_group_info_invalid_name(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "get_dnf_group_info", + arguments={"group": "bad/name", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + async def test_list_dnf_modules_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "Name Stream Profiles", "") + + result = await mcp_client.call_tool( + "list_dnf_modules", + arguments={"host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "Name Stream Profiles" in result_text + + async def test_list_dnf_modules_filtered_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "No matching modules to list", "") + + result = await mcp_client.call_tool( + "list_dnf_modules", + arguments={"module": "missing", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "no modules matched" in result_text + + async def test_list_dnf_modules_invalid_name(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "list_dnf_modules", + arguments={"module": "bad name", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + async def test_dnf_module_provides_success(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "module provides package", "") + + result = await mcp_client.call_tool( + "dnf_module_provides", + arguments={"package": "python3", "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert "module provides package" in result_text + + async def test_dnf_module_provides_not_found(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (1, "", "No matching modules") + + result = await mcp_client.call_tool( + "dnf_module_provides", + arguments={"package": "missing", "host": "remote.example.com"}, + ) + result_text = result.content[0].text.casefold() + + assert "no modules provide" in result_text + + async def test_dnf_module_provides_invalid_package(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "dnf_module_provides", + arguments={"package": "bad name", "host": "remote.example.com"}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + @pytest.mark.parametrize( + ("tool_name", "arguments"), + [ + ("dnf_provides", {"query": "/usr/bin/python3", "host": "remote.example.com"}), + ("get_dnf_repo_info", {"repo_id": "baseos", "host": "remote.example.com"}), + ("get_dnf_group_info", {"group": "Development Tools", "host": "remote.example.com"}), + ("list_dnf_modules", {"module": "nodejs", "host": "remote.example.com"}), + ("dnf_module_provides", {"package": "python3", "host": "remote.example.com"}), + ], + ) + async def test_dnf_new_tools_error(self, mcp_client, mock_execute_with_fallback, tool_name, arguments): + mock_execute_with_fallback.return_value = (1, "", "dnf error") + + result = await mcp_client.call_tool(tool_name, arguments=arguments) + result_text = result.content[0].text.casefold() + + assert "error running dnf" in result_text + + @pytest.mark.parametrize( + ("tool_name", "arguments"), + [ + ("dnf_provides", {"query": "/usr/bin/python3", "host": "remote.example.com"}), + ("get_dnf_repo_info", {"repo_id": "baseos", "host": "remote.example.com"}), + ("get_dnf_group_info", {"group": "Development Tools", "host": "remote.example.com"}), + ("list_dnf_modules", {"module": "nodejs", "host": "remote.example.com"}), + ("dnf_module_provides", {"package": "python3", "host": "remote.example.com"}), + ], + ) + async def test_dnf_new_tools_empty_output(self, mcp_client, mock_execute_with_fallback, tool_name, arguments): + mock_execute_with_fallback.return_value = (0, " ", "") + + result = await mcp_client.call_tool(tool_name, arguments=arguments) + result_text = result.content[0].text.casefold() + + assert "no output returned" in result_text From af22451c97d5f2fc0d2b83b88c9260bae43ed571 Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:26:07 +0100 Subject: [PATCH 25/26] feat(dnf): add list output limits --- docs/usage.md | 18 +++ src/linux_mcp_server/tools/dnf.py | 192 ++++++++++++++++++++++++++++-- tests/tools/test_dnf.py | 88 ++++++++++++++ 3 files changed, 290 insertions(+), 8 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 113b27dc..3f7e64bb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -196,6 +196,9 @@ Lists installed packages via `dnf`. **Parameters:** - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "Show me all installed packages." @@ -204,6 +207,9 @@ Lists packages available in configured repositories. **Parameters:** - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "Which packages are available from enabled repos?" @@ -221,6 +227,9 @@ Lists configured repositories and their status. **Parameters:** - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "Show me all configured repositories and whether they are enabled." @@ -247,6 +256,9 @@ Lists available and installed package groups. **Parameters:** - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "List all package groups." @@ -264,6 +276,9 @@ Shows a summary of installed and available groups. **Parameters:** - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "Summarize installed and available groups." @@ -273,6 +288,9 @@ Lists modules (optionally filtered by module name). **Parameters:** - `module` (string, optional): Module name filter (e.g., "nodejs") - `host` (string, optional): Remote host identifier +- `limit` (number, optional): Maximum number of output lines to return (default: 500) +- `offset` (number, optional): Number of output lines to skip (default: 0) +- `no_limit` (boolean, optional): Disable output truncation (default: false) **Example use case:** "List available nodejs module streams." diff --git a/src/linux_mcp_server/tools/dnf.py b/src/linux_mcp_server/tools/dnf.py index dcd471a3..09ef3023 100644 --- a/src/linux_mcp_server/tools/dnf.py +++ b/src/linux_mcp_server/tools/dnf.py @@ -19,6 +19,9 @@ from linux_mcp_server.utils.validation import validate_optional_dnf_module_name +DEFAULT_DNF_LIMIT = 500 + + def _is_package_not_found(stdout: str, stderr: str) -> bool: combined = f"{stdout}\n{stderr}".casefold() return "no matching packages to list" in combined or "no match for argument" in combined @@ -29,7 +32,42 @@ def _matches_any_message(stdout: str, stderr: str, patterns: t.Sequence[str]) -> return any(pattern in combined for pattern in patterns) -async def _run_dnf_command(command_name: str, host: Host | None = None, **kwargs: object) -> str: +def _apply_output_limits(stdout: str, limit: int | None, offset: int, no_limit: bool) -> str: + lines = stdout.splitlines() + total_lines = len(lines) + + if no_limit or limit is None: + if offset <= 0: + return stdout + + sliced = lines[offset:] + if not sliced: + return "No output after applying limit/offset." + + return "\n".join(sliced) + + start = offset + end = offset + limit + sliced = lines[start:end] + + if not sliced: + return "No output after applying limit/offset." + + result = "\n".join(sliced) + if total_lines > end: + result = f"{result}\n... output truncated: showing {len(sliced)} of {total_lines} lines" + + return result + + +async def _run_dnf_command( + command_name: str, + host: Host | None = None, + limit: int | None = DEFAULT_DNF_LIMIT, + offset: int = 0, + no_limit: bool = False, + **kwargs: object, +) -> str: cmd = get_command(command_name) returncode, stdout, stderr = await cmd.run(host=host, **kwargs) @@ -39,7 +77,7 @@ async def _run_dnf_command(command_name: str, host: Host | None = None, **kwargs if is_empty_output(stdout): return "No output returned by dnf." - return stdout + return _apply_output_limits(stdout, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -51,10 +89,33 @@ async def _run_dnf_command(command_name: str, host: Host | None = None, **kwargs @log_tool_call @disallow_local_execution_in_containers async def list_dnf_installed_packages( + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """List installed packages using dnf.""" - return await _run_dnf_command("dnf_list_installed", host=host) + return await _run_dnf_command("dnf_list_installed", host=host, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -66,10 +127,33 @@ async def list_dnf_installed_packages( @log_tool_call @disallow_local_execution_in_containers async def list_dnf_available_packages( + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """List available packages using dnf.""" - return await _run_dnf_command("dnf_list_available", host=host) + return await _run_dnf_command("dnf_list_available", host=host, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -113,10 +197,33 @@ async def get_dnf_package_info( @log_tool_call @disallow_local_execution_in_containers async def list_dnf_repositories( + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """List configured repositories using dnf.""" - return await _run_dnf_command("dnf_repolist", host=host) + return await _run_dnf_command("dnf_repolist", host=host, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -192,10 +299,33 @@ async def get_dnf_repo_info( @log_tool_call @disallow_local_execution_in_containers async def list_dnf_groups( + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """List group information using dnf.""" - return await _run_dnf_command("dnf_group_list", host=host) + return await _run_dnf_command("dnf_group_list", host=host, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -239,10 +369,33 @@ async def get_dnf_group_info( @log_tool_call @disallow_local_execution_in_containers async def get_dnf_group_summary( + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """Get group summary using dnf.""" - return await _run_dnf_command("dnf_group_summary", host=host) + return await _run_dnf_command("dnf_group_summary", host=host, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( @@ -259,6 +412,29 @@ async def list_dnf_modules( BeforeValidator(validate_optional_dnf_module_name), Field(description="Optional module name filter", examples=["nodejs", "python39"]), ] = None, + limit: t.Annotated[ + int, + Field( + description="Maximum number of output lines to return", + gt=0, + examples=[DEFAULT_DNF_LIMIT], + ), + ] = DEFAULT_DNF_LIMIT, + offset: t.Annotated[ + int, + Field( + description="Number of output lines to skip", + ge=0, + examples=[0], + ), + ] = 0, + no_limit: t.Annotated[ + bool, + Field( + description="Disable output truncation", + examples=[False], + ), + ] = False, host: Host = None, ) -> str: """List modules using dnf.""" @@ -274,7 +450,7 @@ async def list_dnf_modules( if is_empty_output(stdout): return "No output returned by dnf." - return stdout + return _apply_output_limits(stdout, limit=limit, offset=offset, no_limit=no_limit) @mcp.tool( diff --git a/tests/tools/test_dnf.py b/tests/tools/test_dnf.py index 50821661..3c2004fa 100644 --- a/tests/tools/test_dnf.py +++ b/tests/tools/test_dnf.py @@ -204,6 +204,82 @@ async def test_dnf_list_tools_error(self, mcp_client, mock_execute_with_fallback assert "error running dnf" in result_text assert "dnf error" in result_text + async def test_dnf_list_tools_truncates_output(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2\nline3\nline4", "") + + result = await mcp_client.call_tool( + "list_dnf_installed_packages", + arguments={"host": "remote.example.com", "limit": 2, "offset": 1}, + ) + result_text = result.content[0].text + + assert result_text.startswith("line2\nline3") + assert "output truncated" in result_text + + async def test_dnf_list_tools_no_limit(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2\nline3", "") + + result = await mcp_client.call_tool( + "list_dnf_available_packages", + arguments={"host": "remote.example.com", "limit": 1, "no_limit": True}, + ) + result_text = result.content[0].text + + assert result_text == "line1\nline2\nline3" + + async def test_dnf_list_tools_no_limit_offset_out_of_range(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2", "") + + result = await mcp_client.call_tool( + "list_dnf_installed_packages", + arguments={"host": "remote.example.com", "no_limit": True, "offset": 10}, + ) + result_text = result.content[0].text.casefold() + + assert "no output after applying limit/offset" in result_text + + async def test_dnf_list_tools_no_limit_offset_slices(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2\nline3", "") + + result = await mcp_client.call_tool( + "list_dnf_installed_packages", + arguments={"host": "remote.example.com", "no_limit": True, "offset": 1}, + ) + result_text = result.content[0].text + + assert result_text == "line2\nline3" + + async def test_dnf_list_tools_offset_out_of_range(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2", "") + + result = await mcp_client.call_tool( + "list_dnf_repositories", + arguments={"host": "remote.example.com", "limit": 5, "offset": 10}, + ) + result_text = result.content[0].text.casefold() + + assert "no output after applying limit/offset" in result_text + + async def test_dnf_list_tools_invalid_limit(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "list_dnf_installed_packages", + arguments={"host": "remote.example.com", "limit": 0}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + + async def test_dnf_list_tools_invalid_offset(self, mcp_client, mock_execute_with_fallback): + with pytest.raises(Exception) as exc_info: + await mcp_client.call_tool( + "list_dnf_available_packages", + arguments={"host": "remote.example.com", "offset": -1}, + ) + + assert "validation" in str(exc_info.value).casefold() or "invalid" in str(exc_info.value).casefold() + mock_execute_with_fallback.assert_not_called() + async def test_get_dnf_package_info_success(self, mcp_client, mock_execute_with_fallback): mock_execute_with_fallback.return_value = (0, "Name : bash", "") @@ -365,6 +441,18 @@ async def test_list_dnf_modules_success(self, mcp_client, mock_execute_with_fall assert "Name Stream Profiles" in result_text + async def test_list_dnf_modules_truncates_output(self, mcp_client, mock_execute_with_fallback): + mock_execute_with_fallback.return_value = (0, "line1\nline2\nline3\nline4", "") + + result = await mcp_client.call_tool( + "list_dnf_modules", + arguments={"module": "nodejs", "limit": 2, "offset": 1, "host": "remote.example.com"}, + ) + result_text = result.content[0].text + + assert result_text.startswith("line2\nline3") + assert "output truncated" in result_text + async def test_list_dnf_modules_filtered_not_found(self, mcp_client, mock_execute_with_fallback): mock_execute_with_fallback.return_value = (0, "No matching modules to list", "") From 46327b598f35adefea72782dc1916362ed8b22a2 Mon Sep 17 00:00:00 2001 From: Albe83 <70150664+Albe83@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:12:42 +0100 Subject: [PATCH 26/26] fix(containerfile): align wheelhouse source path with hatch package layout --- Containerfile.wheelhouse | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Containerfile.wheelhouse diff --git a/Containerfile.wheelhouse b/Containerfile.wheelhouse new file mode 100644 index 00000000..98b09d44 --- /dev/null +++ b/Containerfile.wheelhouse @@ -0,0 +1,38 @@ +ARG APP_VERSION=0.0.0 + +ARG BASE_IMAGE=python:3.12-slim +# ARG BASE_IMAGE=python:3.12-alpine + +ARG PIP_OPTS="--prefer-binary" +ARG UV_OPTS="--no-dev --no-editable --frozen --no-install-project" +ARG PIP_EXTRA_OPTS="" +ARG UV_EXTRA_OPTS="" +ARG UV_EXTRA_OPTS="--allow-insecure-host pypi.org --allow-insecure-host files.pythonhosted.org" +ARG PIP_EXTRA_OPTS="--trusted-host pypi.org --trusted-host files.pythonhosted.org" + +FROM ${BASE_IMAGE} as builder +ARG APP_VERSION +ARG PIP_OPTS +ARG PIP_EXTRA_OPTS +ARG UV_OPTS +ARG UV_EXTRA_OPTS + +ENV SETUPTOOLS_SCM_PRETEND_VERSION=${APP_VERSION} + +RUN --mount=type=cache,id=linux-mcp-server-wheelhouse-pip,target=/root/.cache/pip\ + pip install ${PIP_OPTS} ${PIP_EXTRA_OPTS} uv build hatchling hatch-vcs +WORKDIR /src +COPY pyproject.toml uv.lock README.md /src/ +RUN uv export ${UV_OPTS} ${UV_EXTRA_OPTS} -o requirements.lock.txt +RUN --mount=type=cache,id=linux-mcp-server-wheelhouse-pip,target=/root/.cache/pip\ + mkdir -p /wheelhouse && \ + pip wheel ${PIP_OPTS} ${PIP_EXTRA_OPTS} \ + --wheel-dir /wheelhouse \ + -r requirements.lock.txt +COPY src/ /src/src/ +RUN test -d /src/src/linux_mcp_server +RUN --mount=type=cache,id=linux-mcp-server-wheelhouse-pip,target=/root/.cache/pip\ + python -m build --wheel --outdir /wheelhouse --no-isolation + +FROM scratch AS wheelhouse +COPY --from=builder --chmod=444 /wheelhouse /