linux-mcp-server 1.2.0__tar.gz → 1.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/PKG-INFO +2 -2
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/clients.md +20 -21
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/pyproject.toml +1 -1
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/commands.py +1 -1
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/formatters.py +4 -3
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/logs.py +40 -30
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/processes.py +4 -1
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/services.py +14 -2
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/storage.py +30 -23
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/validation.py +2 -2
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_formatters.py +3 -1
- linux_mcp_server-1.2.1/tests/test_tool_schemas.py +50 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_logs.py +29 -49
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_storage.py +1 -3
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/utils/test_validation.py +7 -5
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/uv.lock +7 -7
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.codecov.yml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.coderabbit.yaml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.devcontainer/Containerfile +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.devcontainer/devcontainer.json +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.github/workflows/build-publish.yml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.github/workflows/ci.yml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.github/workflows/ci_installation.yaml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.github/workflows/docs.yml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.github/workflows/functional-tests.yml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.gitignore +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.tekton/pipeline-build-multiarch.yaml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.tekton/pull_request.yaml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.tekton/push.yaml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.tekton/task-get-version.yaml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/AGENTS.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/Containerfile +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/LICENSE +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/Makefile +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/README.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/audit.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/commands.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/config.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/connection.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/formatters.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/index.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/parsers.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/server.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/logs.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/network.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/processes.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/services.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/storage.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/system_info.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/architecture.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/cheatsheet.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/contributing.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/debugging.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/examples/claude_desktop_config.example.json +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/examples/example_config.sh +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/getting-started.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/index.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/install.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/troubleshooting.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/usage.md +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/licenses/GPL-3.0.txt +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/mkdocs.yml +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/renovate.json +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/scripts/verify_installation.sh +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/__main__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/_vendor/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/audit.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/config.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/connection/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/connection/ssh.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/logging_config.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/parsers.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/server.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/network.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/system_info.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/decorators.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/enum.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/format.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/types.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/_vendor/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/_vendor/test_vendor.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/conftest.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_connection_manager.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_discover_ssh_key.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_execute_command.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_get_bin_path.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_host_key_verification.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_timeout.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_cpu_info.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_free_output.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_ip_brief.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_listing.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_os_release.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_proc.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_ps_output.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_service_count.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_ss.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_system_info.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test__main__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_audit.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_commands.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_config.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_logging_config.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_server.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_network.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_processes.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_services.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_system_info.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/utils/__init__.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/utils/test_decorators.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/utils/test_enum.py +0 -0
- {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/utils/test_format.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: linux-mcp-server
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: MCP server for read-only Linux system administration, diagnostics, and troubleshooting
|
|
5
5
|
Project-URL: Source code, https://github.com/rhel-lightspeed/linux-mcp-server
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/rhel-lightspeed/linux-mcp-server/issues
|
|
@@ -26,7 +26,7 @@ Classifier: Topic :: System :: Systems Administration
|
|
|
26
26
|
Classifier: Topic :: Utilities
|
|
27
27
|
Requires-Python: >=3.10
|
|
28
28
|
Requires-Dist: asyncssh[bcrypt]>=2.22.0
|
|
29
|
-
Requires-Dist: fastmcp<3,>=2
|
|
29
|
+
Requires-Dist: fastmcp<3,>=2.14.3
|
|
30
30
|
Requires-Dist: pydantic-settings>=2.12.0
|
|
31
31
|
Requires-Dist: pydantic>=2.12.5
|
|
32
32
|
Provides-Extra: gssapi
|
|
@@ -273,11 +273,9 @@ Edit `~/.gemini/settings.json`:
|
|
|
273
273
|
|
|
274
274
|
```json
|
|
275
275
|
{
|
|
276
|
-
// your other settings
|
|
277
|
-
,
|
|
278
276
|
"mcpServers": {
|
|
279
277
|
"linux-mcp-server": {
|
|
280
|
-
"command": "/home
|
|
278
|
+
"command": "/home/YOUR_USER/.local/bin/linux-mcp-server",
|
|
281
279
|
"args": [],
|
|
282
280
|
"env": {
|
|
283
281
|
"LINUX_MCP_USER": "your-ssh-username"
|
|
@@ -287,6 +285,9 @@ Edit `~/.gemini/settings.json`:
|
|
|
287
285
|
}
|
|
288
286
|
```
|
|
289
287
|
|
|
288
|
+
!!! note "Merging with Existing Settings"
|
|
289
|
+
If you have other settings in your `settings.json`, add the `mcpServers` object alongside them.
|
|
290
|
+
|
|
290
291
|
=== "Container (Podman)"
|
|
291
292
|
|
|
292
293
|
```json
|
|
@@ -500,24 +501,22 @@ Add to your VS Code `mcp.json`:
|
|
|
500
501
|
|
|
501
502
|
```json
|
|
502
503
|
{
|
|
503
|
-
"
|
|
504
|
-
"
|
|
505
|
-
"
|
|
506
|
-
|
|
507
|
-
"
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
"
|
|
518
|
-
|
|
519
|
-
"LINUX_MCP_USER": "YOUR_USER"
|
|
520
|
-
}
|
|
504
|
+
"servers": {
|
|
505
|
+
"linux-mcp-server": {
|
|
506
|
+
"command": "podman",
|
|
507
|
+
"args": [
|
|
508
|
+
"run", "--rm", "--interactive",
|
|
509
|
+
"--userns", "keep-id:uid=1001,gid=0",
|
|
510
|
+
"-e", "LINUX_MCP_KEY_PASSPHRASE",
|
|
511
|
+
"-e", "LINUX_MCP_USER",
|
|
512
|
+
"-v", "/home/YOUR_USER/.ssh/id_ed25519:/var/lib/mcp/.ssh/id_ed25519:ro,Z",
|
|
513
|
+
"-v", "/home/YOUR_USER/.ssh/config:/var/lib/mcp/.ssh/config:ro,Z",
|
|
514
|
+
"-v", "/home/YOUR_USER/.local/share/linux-mcp-server/logs:/var/lib/mcp/.local/share/linux-mcp-server/logs:rw,Z",
|
|
515
|
+
"quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest"
|
|
516
|
+
],
|
|
517
|
+
"env": {
|
|
518
|
+
"LINUX_MCP_KEY_PASSPHRASE": "<secret>",
|
|
519
|
+
"LINUX_MCP_USER": "YOUR_USER"
|
|
521
520
|
}
|
|
522
521
|
}
|
|
523
522
|
}
|
|
@@ -184,7 +184,7 @@ COMMANDS: Mapping[str, CommandGroup] = MappingProxyType(
|
|
|
184
184
|
),
|
|
185
185
|
"list_directories_size": CommandGroup(
|
|
186
186
|
commands={
|
|
187
|
-
"default": CommandSpec(args=("du", "-b", "--max-depth=1", "{path}")),
|
|
187
|
+
"default": CommandSpec(args=("du", "-b", "--one-file-system", "--max-depth=1", "{path}")),
|
|
188
188
|
}
|
|
189
189
|
),
|
|
190
190
|
"list_directories_name": CommandGroup(
|
|
@@ -5,6 +5,7 @@ human-readable strings for tool output.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from linux_mcp_server.utils import format_bytes
|
|
10
11
|
from linux_mcp_server.utils.types import CpuInfo
|
|
@@ -364,7 +365,7 @@ def format_journal_logs(
|
|
|
364
365
|
return "\n".join(lines)
|
|
365
366
|
|
|
366
367
|
|
|
367
|
-
def format_log_file(stdout: str, log_path:
|
|
368
|
+
def format_log_file(stdout: str, log_path: Path, lines_count: int) -> str:
|
|
368
369
|
"""Format log file output.
|
|
369
370
|
|
|
370
371
|
Args:
|
|
@@ -456,7 +457,7 @@ def format_hardware_info(results: dict[str, str]) -> str:
|
|
|
456
457
|
|
|
457
458
|
def format_directory_listing(
|
|
458
459
|
entries: list[NodeEntry],
|
|
459
|
-
path: str,
|
|
460
|
+
path: str | Path,
|
|
460
461
|
sort_by: str,
|
|
461
462
|
reverse: bool = False,
|
|
462
463
|
) -> str:
|
|
@@ -496,7 +497,7 @@ def format_directory_listing(
|
|
|
496
497
|
|
|
497
498
|
def format_file_listing(
|
|
498
499
|
entries: list[NodeEntry],
|
|
499
|
-
path: str,
|
|
500
|
+
path: str | Path,
|
|
500
501
|
sort_by: str,
|
|
501
502
|
reverse: bool = False,
|
|
502
503
|
) -> str:
|
|
@@ -4,8 +4,10 @@ import typing as t
|
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
from fastmcp.exceptions import ToolError
|
|
7
8
|
from mcp.types import ToolAnnotations
|
|
8
9
|
from pydantic import Field
|
|
10
|
+
from pydantic.functional_validators import BeforeValidator
|
|
9
11
|
|
|
10
12
|
from linux_mcp_server.audit import log_tool_call
|
|
11
13
|
from linux_mcp_server.commands import get_command
|
|
@@ -17,7 +19,6 @@ from linux_mcp_server.utils import StrEnum
|
|
|
17
19
|
from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers
|
|
18
20
|
from linux_mcp_server.utils.types import Host
|
|
19
21
|
from linux_mcp_server.utils.validation import is_empty_output
|
|
20
|
-
from linux_mcp_server.utils.validation import PathValidationError
|
|
21
22
|
from linux_mcp_server.utils.validation import validate_path
|
|
22
23
|
|
|
23
24
|
|
|
@@ -87,14 +88,26 @@ async def _get_journal_logs(
|
|
|
87
88
|
@log_tool_call
|
|
88
89
|
@disallow_local_execution_in_containers
|
|
89
90
|
async def get_journal_logs(
|
|
90
|
-
unit: t.Annotated[
|
|
91
|
+
unit: t.Annotated[
|
|
92
|
+
str | None,
|
|
93
|
+
Field(
|
|
94
|
+
description="Filter by systemd unit name or pattern",
|
|
95
|
+
examples=["sshd.service", "nginx", "httpd", "systemd-*", "audit*"],
|
|
96
|
+
),
|
|
97
|
+
] = None,
|
|
91
98
|
priority: t.Annotated[
|
|
92
99
|
str | None,
|
|
93
|
-
|
|
100
|
+
Field(
|
|
101
|
+
description="Filter by syslog priority level (0-7), name, or range",
|
|
102
|
+
examples=["err", "warning", "info", "debug", "3", "err..warning"],
|
|
103
|
+
),
|
|
94
104
|
] = None,
|
|
95
105
|
since: t.Annotated[
|
|
96
106
|
str | None,
|
|
97
|
-
|
|
107
|
+
Field(
|
|
108
|
+
description="Filter entries since specified time (absolute or relative)",
|
|
109
|
+
examples=["today", "yesterday", "-1h", "-30m", "-7d", "2025-01-15 10:00:00"],
|
|
110
|
+
),
|
|
98
111
|
] = None,
|
|
99
112
|
transport: t.Annotated[
|
|
100
113
|
Transport | None,
|
|
@@ -131,8 +144,15 @@ async def get_journal_logs(
|
|
|
131
144
|
)
|
|
132
145
|
@log_tool_call
|
|
133
146
|
@disallow_local_execution_in_containers
|
|
134
|
-
async def read_log_file(
|
|
135
|
-
log_path: t.Annotated[
|
|
147
|
+
async def read_log_file(
|
|
148
|
+
log_path: t.Annotated[
|
|
149
|
+
Path,
|
|
150
|
+
BeforeValidator(validate_path),
|
|
151
|
+
Field(
|
|
152
|
+
description="Absolute path to the log file (must be in allowed list)",
|
|
153
|
+
examples=["/var/log/messages", "/var/log/secure", "/var/log/audit/audit.log", "/var/log/dnf.log"],
|
|
154
|
+
),
|
|
155
|
+
],
|
|
136
156
|
lines: t.Annotated[int, Field(description="Number of lines to retrieve from the end.", ge=1, le=10_000)] = 100,
|
|
137
157
|
host: Host = None,
|
|
138
158
|
) -> str:
|
|
@@ -145,22 +165,16 @@ async def read_log_file( # noqa: C901
|
|
|
145
165
|
allowed_paths_env = CONFIG.allowed_log_paths
|
|
146
166
|
|
|
147
167
|
if not allowed_paths_env:
|
|
148
|
-
|
|
168
|
+
raise ToolError(
|
|
149
169
|
"No log files are allowed. Set LINUX_MCP_ALLOWED_LOG_PATHS environment variable "
|
|
150
170
|
"with comma-separated list of allowed log file paths."
|
|
151
171
|
)
|
|
152
172
|
|
|
153
|
-
allowed_paths = [p.strip() for p in allowed_paths_env.split(",") if p.strip()]
|
|
154
|
-
|
|
155
|
-
# Validate path for injection attacks (applies to both local and remote)
|
|
156
|
-
try:
|
|
157
|
-
validated_path = validate_path(log_path)
|
|
158
|
-
except PathValidationError as e:
|
|
159
|
-
return f"Invalid log file path: {e}"
|
|
173
|
+
allowed_paths = [Path(p.strip()) for p in allowed_paths_env.split(",") if p.strip()]
|
|
160
174
|
|
|
161
175
|
if not host:
|
|
162
176
|
# For local execution, resolve and check against allowlist
|
|
163
|
-
requested_path =
|
|
177
|
+
requested_path = log_path.resolve()
|
|
164
178
|
|
|
165
179
|
is_allowed = False
|
|
166
180
|
for allowed_path in allowed_paths:
|
|
@@ -170,36 +184,32 @@ async def read_log_file( # noqa: C901
|
|
|
170
184
|
break
|
|
171
185
|
|
|
172
186
|
if not is_allowed:
|
|
173
|
-
|
|
174
|
-
f"Access to log file '{log_path}' is not allowed.\n"
|
|
175
|
-
f"Allowed log files: {', '.join(allowed_paths)}"
|
|
176
|
-
) # nofmt
|
|
187
|
+
raise ToolError(f"Access to log file '{log_path}' is not allowed.")
|
|
177
188
|
|
|
178
189
|
if not requested_path.exists():
|
|
179
|
-
|
|
190
|
+
raise ToolError(f"Log file not found: {log_path}")
|
|
180
191
|
|
|
181
192
|
if not requested_path.is_file():
|
|
182
|
-
|
|
193
|
+
raise ToolError(f"Path is not a file: {log_path}")
|
|
183
194
|
|
|
184
195
|
log_path_str = str(requested_path)
|
|
185
196
|
else:
|
|
186
197
|
# For remote execution, check against allowlist without resolving
|
|
187
|
-
if
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
) # nofmt
|
|
192
|
-
log_path_str = validated_path
|
|
198
|
+
if log_path not in allowed_paths:
|
|
199
|
+
raise ToolError(f"Access to log file '{log_path}' is not allowed.")
|
|
200
|
+
|
|
201
|
+
log_path_str = str(log_path)
|
|
193
202
|
|
|
194
203
|
cmd = get_command("read_log_file")
|
|
195
204
|
returncode, stdout, stderr = await cmd.run(host=host, lines=lines, log_path=log_path_str)
|
|
196
205
|
|
|
197
206
|
if returncode != 0:
|
|
198
207
|
if "Permission denied" in stderr:
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
raise ToolError(f"Permission denied reading log file: {log_path}")
|
|
209
|
+
|
|
210
|
+
raise ToolError(f"Error reading log file: {stderr}")
|
|
201
211
|
|
|
202
212
|
if is_empty_output(stdout):
|
|
203
|
-
|
|
213
|
+
raise ToolError(f"Log file is empty: {log_path}")
|
|
204
214
|
|
|
205
215
|
return format_log_file(stdout, log_path, lines)
|
|
@@ -51,7 +51,10 @@ async def list_processes(
|
|
|
51
51
|
@log_tool_call
|
|
52
52
|
@disallow_local_execution_in_containers
|
|
53
53
|
async def get_process_info(
|
|
54
|
-
pid: t.Annotated[
|
|
54
|
+
pid: t.Annotated[
|
|
55
|
+
int,
|
|
56
|
+
Field(description="Process ID", ge=1, examples=[1, 1234, 65535]),
|
|
57
|
+
],
|
|
55
58
|
host: Host = None,
|
|
56
59
|
) -> str:
|
|
57
60
|
"""Get detailed information about a specific process.
|
|
@@ -59,7 +59,13 @@ async def list_services(
|
|
|
59
59
|
@log_tool_call
|
|
60
60
|
@disallow_local_execution_in_containers
|
|
61
61
|
async def get_service_status(
|
|
62
|
-
service_name: t.Annotated[
|
|
62
|
+
service_name: t.Annotated[
|
|
63
|
+
str,
|
|
64
|
+
Field(
|
|
65
|
+
description="Name of the systemd service",
|
|
66
|
+
examples=["sshd", "NetworkManager", "auditd", "rsyslog", "crond", "firewalld"],
|
|
67
|
+
),
|
|
68
|
+
],
|
|
63
69
|
host: Host = None,
|
|
64
70
|
) -> str:
|
|
65
71
|
"""Get status of a specific systemd service.
|
|
@@ -93,7 +99,13 @@ async def get_service_status(
|
|
|
93
99
|
@log_tool_call
|
|
94
100
|
@disallow_local_execution_in_containers
|
|
95
101
|
async def get_service_logs(
|
|
96
|
-
service_name: t.Annotated[
|
|
102
|
+
service_name: t.Annotated[
|
|
103
|
+
str,
|
|
104
|
+
Field(
|
|
105
|
+
description="Name of the systemd service",
|
|
106
|
+
examples=["sshd", "NetworkManager", "auditd", "rsyslog", "crond", "firewalld"],
|
|
107
|
+
),
|
|
108
|
+
],
|
|
97
109
|
lines: t.Annotated[int, Field(description="Number of log lines to retrieve.", ge=1, le=10_000)] = 50,
|
|
98
110
|
host: Host = None,
|
|
99
111
|
) -> str:
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
import os
|
|
4
4
|
import typing as t
|
|
5
5
|
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
6
8
|
from fastmcp.exceptions import ToolError
|
|
7
9
|
from mcp.types import ToolAnnotations
|
|
8
10
|
from pydantic import Field
|
|
11
|
+
from pydantic.functional_validators import BeforeValidator
|
|
9
12
|
|
|
10
13
|
from linux_mcp_server.audit import log_tool_call
|
|
11
14
|
from linux_mcp_server.commands import get_command
|
|
@@ -19,7 +22,6 @@ from linux_mcp_server.utils import StrEnum
|
|
|
19
22
|
from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers
|
|
20
23
|
from linux_mcp_server.utils.types import Host
|
|
21
24
|
from linux_mcp_server.utils.validation import is_successful_output
|
|
22
|
-
from linux_mcp_server.utils.validation import PathValidationError
|
|
23
25
|
from linux_mcp_server.utils.validation import validate_path
|
|
24
26
|
|
|
25
27
|
|
|
@@ -47,14 +49,6 @@ FILE_COMMANDS: dict[OrderBy, str] = {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
|
|
50
|
-
def _validate_path(path: str) -> str:
|
|
51
|
-
"""Validate path, converting PathValidationError to ToolError for MCP compatibility."""
|
|
52
|
-
try:
|
|
53
|
-
return validate_path(path)
|
|
54
|
-
except PathValidationError as e:
|
|
55
|
-
raise ToolError(str(e)) from e
|
|
56
|
-
|
|
57
|
-
|
|
58
52
|
@mcp.tool(
|
|
59
53
|
title="List block devices",
|
|
60
54
|
description="List block devices on the system",
|
|
@@ -89,7 +83,14 @@ async def list_block_devices(
|
|
|
89
83
|
@log_tool_call
|
|
90
84
|
@disallow_local_execution_in_containers
|
|
91
85
|
async def list_directories(
|
|
92
|
-
path: t.Annotated[
|
|
86
|
+
path: t.Annotated[
|
|
87
|
+
Path,
|
|
88
|
+
BeforeValidator(validate_path),
|
|
89
|
+
Field(
|
|
90
|
+
description="Absolute path to the directory to analyze",
|
|
91
|
+
examples=["/var/log", "/etc", "/home", "/opt", "/tmp"],
|
|
92
|
+
),
|
|
93
|
+
],
|
|
93
94
|
order_by: t.Annotated[OrderBy, "Sort order - 'size', 'name', or 'modified' (default: 'name')"] = OrderBy.NAME,
|
|
94
95
|
sort: t.Annotated[SortBy, "Sort direction - 'ascending' or 'descending' (default: 'ascending')"] = SortBy.ASCENDING,
|
|
95
96
|
top_n: t.Annotated[
|
|
@@ -107,15 +108,15 @@ async def list_directories(
|
|
|
107
108
|
Retrieves subdirectories with their size (when ordered by size) or
|
|
108
109
|
modification time, supporting flexible sorting and result limiting.
|
|
109
110
|
"""
|
|
110
|
-
path = _validate_path(path)
|
|
111
|
-
|
|
112
111
|
# Get the appropriate command for the order_by field
|
|
113
112
|
cmd_name = DIRECTORY_COMMANDS[order_by]
|
|
114
113
|
cmd = get_command(cmd_name)
|
|
115
114
|
|
|
116
115
|
returncode, stdout, stderr = await cmd.run(host=host, path=path)
|
|
117
116
|
|
|
118
|
-
if
|
|
117
|
+
# The du command will exit with code 1 even if it gets some valid results.
|
|
118
|
+
# Only error in the case where we got non-zero exit code and no data in stdout.
|
|
119
|
+
if returncode != 0 and not stdout:
|
|
119
120
|
raise ToolError(f"Error running command: command failed with return code {returncode}: {stderr}")
|
|
120
121
|
|
|
121
122
|
# Parse the output
|
|
@@ -145,7 +146,14 @@ async def list_directories(
|
|
|
145
146
|
@log_tool_call
|
|
146
147
|
@disallow_local_execution_in_containers
|
|
147
148
|
async def list_files(
|
|
148
|
-
path: t.Annotated[
|
|
149
|
+
path: t.Annotated[
|
|
150
|
+
Path,
|
|
151
|
+
BeforeValidator(validate_path),
|
|
152
|
+
Field(
|
|
153
|
+
description="Absolute path to the directory to analyze",
|
|
154
|
+
examples=["/var/log", "/etc", "/home", "/opt", "/tmp"],
|
|
155
|
+
),
|
|
156
|
+
],
|
|
149
157
|
order_by: t.Annotated[OrderBy, "Sort order - 'size', 'name', or 'modified' (default: 'name')"] = OrderBy.NAME,
|
|
150
158
|
sort: t.Annotated[SortBy, "Sort direction - 'ascending' or 'descending' (default: 'ascending')"] = SortBy.ASCENDING,
|
|
151
159
|
top_n: t.Annotated[
|
|
@@ -163,10 +171,6 @@ async def list_files(
|
|
|
163
171
|
Retrieves files with their size or modification time, supporting flexible
|
|
164
172
|
sorting and result limiting. Useful for finding large or recently modified files.
|
|
165
173
|
"""
|
|
166
|
-
# For local execution, validate path
|
|
167
|
-
if not host:
|
|
168
|
-
path = _validate_path(path)
|
|
169
|
-
|
|
170
174
|
# Get the appropriate command for the order_by field
|
|
171
175
|
cmd_name = FILE_COMMANDS[order_by]
|
|
172
176
|
cmd = get_command(cmd_name)
|
|
@@ -203,7 +207,14 @@ async def list_files(
|
|
|
203
207
|
@log_tool_call
|
|
204
208
|
@disallow_local_execution_in_containers
|
|
205
209
|
async def read_file(
|
|
206
|
-
path: t.Annotated[
|
|
210
|
+
path: t.Annotated[
|
|
211
|
+
Path,
|
|
212
|
+
BeforeValidator(validate_path),
|
|
213
|
+
Field(
|
|
214
|
+
description="Absolute path to the file to read",
|
|
215
|
+
examples=["/etc/hosts", "/etc/resolv.conf", "/etc/os-release", "/proc/cpuinfo"],
|
|
216
|
+
),
|
|
217
|
+
],
|
|
207
218
|
host: Host = None,
|
|
208
219
|
) -> str:
|
|
209
220
|
"""Read the contents of a file.
|
|
@@ -211,10 +222,6 @@ async def read_file(
|
|
|
211
222
|
Retrieves the full contents of a text file. The path must be absolute
|
|
212
223
|
and the file must exist. Binary files may not display correctly.
|
|
213
224
|
"""
|
|
214
|
-
|
|
215
|
-
# Validate path
|
|
216
|
-
path = _validate_path(path)
|
|
217
|
-
|
|
218
225
|
if not host:
|
|
219
226
|
# For local execution, check early if file exists
|
|
220
227
|
if not os.path.isfile(path):
|
|
@@ -11,7 +11,7 @@ class PathValidationError(ValueError):
|
|
|
11
11
|
pass
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def validate_path(path: str) ->
|
|
14
|
+
def validate_path(path: str) -> Path:
|
|
15
15
|
"""Validate a filesystem path for security and correctness.
|
|
16
16
|
|
|
17
17
|
Performs security checks to prevent command injection and path traversal attacks:
|
|
@@ -53,7 +53,7 @@ def validate_path(path: str) -> str:
|
|
|
53
53
|
if not Path(path).is_absolute():
|
|
54
54
|
raise PathValidationError(f"Path must be absolute: {path}")
|
|
55
55
|
|
|
56
|
-
return Path(path)
|
|
56
|
+
return Path(path)
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def is_empty_output(stdout: str | None) -> bool:
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Tests for formatters module."""
|
|
2
2
|
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
3
5
|
from linux_mcp_server.formatters import format_block_devices
|
|
4
6
|
from linux_mcp_server.formatters import format_cpu_info
|
|
5
7
|
from linux_mcp_server.formatters import format_directory_listing
|
|
@@ -416,7 +418,7 @@ class TestFormatLogFile:
|
|
|
416
418
|
def test_format_log_file(self):
|
|
417
419
|
"""Test formatting log file."""
|
|
418
420
|
stdout = "2024-01-01 10:00:00 INFO Application started"
|
|
419
|
-
result = format_log_file(stdout, "/var/log/app.log", 100)
|
|
421
|
+
result = format_log_file(stdout, Path("/var/log/app.log"), 100)
|
|
420
422
|
assert "=== Log File: /var/log/app.log (last 100 lines) ===" in result
|
|
421
423
|
assert "Application started" in result
|
|
422
424
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Verify tool schemas contain expected metadata for LLM guidance."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from linux_mcp_server.server import mcp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(scope="module")
|
|
9
|
+
async def mcp_tools() -> dict:
|
|
10
|
+
"""Fetch all MCP tools using the public API."""
|
|
11
|
+
return await mcp.get_tools()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def tool_properties(mcp_tools: dict):
|
|
16
|
+
"""Get the properties dict for a tool's parameters schema."""
|
|
17
|
+
|
|
18
|
+
def _tool_properties(tool_name: str) -> dict:
|
|
19
|
+
tool = mcp_tools.get(tool_name)
|
|
20
|
+
if tool is None:
|
|
21
|
+
raise ValueError(f"Tool '{tool_name}' not found") # pragma: no cover
|
|
22
|
+
return tool.parameters.get("properties", {})
|
|
23
|
+
|
|
24
|
+
return _tool_properties
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestToolSchemaExamples:
|
|
28
|
+
"""Verify parameters have examples for LLM guidance."""
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize(
|
|
31
|
+
("tool_name", "param_name"),
|
|
32
|
+
[
|
|
33
|
+
("get_journal_logs", "unit"),
|
|
34
|
+
("get_journal_logs", "priority"),
|
|
35
|
+
("get_journal_logs", "since"),
|
|
36
|
+
("read_log_file", "log_path"),
|
|
37
|
+
("get_service_status", "service_name"),
|
|
38
|
+
("get_service_logs", "service_name"),
|
|
39
|
+
("get_process_info", "pid"),
|
|
40
|
+
("list_directories", "path"),
|
|
41
|
+
("list_files", "path"),
|
|
42
|
+
("read_file", "path"),
|
|
43
|
+
],
|
|
44
|
+
)
|
|
45
|
+
def test_parameter_has_examples(self, tool_name: str, param_name: str, tool_properties) -> None:
|
|
46
|
+
props = tool_properties(tool_name)
|
|
47
|
+
|
|
48
|
+
assert param_name in props, f"Parameter '{param_name}' not found in {tool_name}"
|
|
49
|
+
assert "examples" in props[param_name], f"Parameter '{param_name}' in {tool_name} missing examples"
|
|
50
|
+
assert len(props[param_name]["examples"]) > 0, f"Parameter '{param_name}' in {tool_name} has empty examples"
|
|
@@ -198,7 +198,7 @@ class TestReadLogFile:
|
|
|
198
198
|
log_file = setup_log_file()
|
|
199
199
|
mock_execute_with_fallback.return_value = (0, "Test log content\nLine 2", "")
|
|
200
200
|
|
|
201
|
-
params = {"log_path":
|
|
201
|
+
params = {"log_path": log_file}
|
|
202
202
|
if lines != 100:
|
|
203
203
|
params["lines"] = lines
|
|
204
204
|
|
|
@@ -217,23 +217,19 @@ class TestReadLogFile:
|
|
|
217
217
|
"""Test read_log_file when no allowed paths are configured."""
|
|
218
218
|
mock_allowed_log_paths("")
|
|
219
219
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
assert "no log files are allowed" in result_text
|
|
224
|
-
assert "linux_mcp_allowed_log_paths" in result_text
|
|
220
|
+
with pytest.raises(ToolError, match="No log files are allowed"):
|
|
221
|
+
await mcp_client.call_tool("read_log_file", {"log_path": "/var/log/test.log"})
|
|
225
222
|
|
|
226
223
|
@pytest.mark.parametrize(
|
|
227
|
-
"test_scenario,
|
|
224
|
+
"test_scenario,log_path,match",
|
|
228
225
|
[
|
|
229
|
-
|
|
230
|
-
("
|
|
231
|
-
# Path not in allowed list
|
|
232
|
-
("not_allowed", lambda tp: str(tp / "restricted.log"), "not allowed"),
|
|
226
|
+
("invalid_path", "\x00invalid", "Path contains invalid characters"),
|
|
227
|
+
("not_allowed", "restricted.log", "not allowed"),
|
|
233
228
|
],
|
|
229
|
+
ids=["invalid-path", "path-not-in-list"],
|
|
234
230
|
)
|
|
235
231
|
async def test_read_log_file_path_validation(
|
|
236
|
-
self, mcp_client, mock_allowed_log_paths, tmp_path, test_scenario,
|
|
232
|
+
self, mcp_client, mock_allowed_log_paths, tmp_path, test_scenario, log_path, match
|
|
237
233
|
):
|
|
238
234
|
"""Test read_log_file path validation scenarios."""
|
|
239
235
|
# Setup allowed path
|
|
@@ -241,26 +237,20 @@ class TestReadLogFile:
|
|
|
241
237
|
allowed_file.write_text("allowed")
|
|
242
238
|
mock_allowed_log_paths(str(allowed_file))
|
|
243
239
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
restricted_file = tmp_path / "restricted.log"
|
|
247
|
-
restricted_file.write_text("restricted")
|
|
248
|
-
|
|
249
|
-
test_path = path_resolver(tmp_path)
|
|
250
|
-
result = await mcp_client.call_tool("read_log_file", {"log_path": test_path})
|
|
251
|
-
result_text = result.content[0].text.casefold()
|
|
240
|
+
restricted_file = tmp_path / "restricted.log"
|
|
241
|
+
restricted_file.write_text("restricted")
|
|
252
242
|
|
|
253
|
-
|
|
243
|
+
test_path = tmp_path / log_path
|
|
244
|
+
with pytest.raises(ToolError, match=match):
|
|
245
|
+
await mcp_client.call_tool("read_log_file", {"log_path": test_path})
|
|
254
246
|
|
|
255
247
|
async def test_read_log_file_nonexistent_but_allowed(self, mcp_client, mock_allowed_log_paths, tmp_path):
|
|
256
248
|
"""Test read_log_file when path is allowed but file doesn't exist."""
|
|
257
249
|
nonexistent_file = tmp_path / "nonexistent.log"
|
|
258
250
|
mock_allowed_log_paths(str(nonexistent_file))
|
|
259
251
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
assert "log file not found" in result_text
|
|
252
|
+
with pytest.raises(ToolError, match="Log file not found"):
|
|
253
|
+
await mcp_client.call_tool("read_log_file", {"log_path": nonexistent_file})
|
|
264
254
|
|
|
265
255
|
async def test_read_log_file_path_is_directory(self, mcp_client, mock_allowed_log_paths, tmp_path):
|
|
266
256
|
"""Test read_log_file when path is a directory, not a file."""
|
|
@@ -268,41 +258,35 @@ class TestReadLogFile:
|
|
|
268
258
|
log_dir.mkdir()
|
|
269
259
|
mock_allowed_log_paths(str(log_dir))
|
|
270
260
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
assert "path is not a file" in result_text
|
|
261
|
+
with pytest.raises(ToolError, match="Path is not a file"):
|
|
262
|
+
await mcp_client.call_tool("read_log_file", {"log_path": log_dir})
|
|
275
263
|
|
|
276
264
|
@pytest.mark.parametrize(
|
|
277
|
-
"returncode,stderr,
|
|
265
|
+
"returncode,stderr,match",
|
|
278
266
|
[
|
|
279
267
|
# Permission denied
|
|
280
|
-
(1, "tail: cannot open: Permission denied", "
|
|
268
|
+
(1, "tail: cannot open: Permission denied", "Permission denied"),
|
|
281
269
|
# general error
|
|
282
|
-
(1, "tail: error reading file", "
|
|
270
|
+
(1, "tail: error reading file", "Error reading log file"),
|
|
283
271
|
],
|
|
284
272
|
)
|
|
285
273
|
async def test_read_log_file_command_errors(
|
|
286
|
-
self, mcp_client, mock_execute_with_fallback, setup_log_file, returncode, stderr,
|
|
274
|
+
self, mcp_client, mock_execute_with_fallback, setup_log_file, returncode, stderr, match
|
|
287
275
|
):
|
|
288
276
|
"""Test read_log_file command error handling."""
|
|
289
277
|
log_file = setup_log_file()
|
|
290
278
|
mock_execute_with_fallback.return_value = (returncode, "", stderr)
|
|
291
279
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
assert expected_error in result_text
|
|
280
|
+
with pytest.raises(ToolError, match=match):
|
|
281
|
+
await mcp_client.call_tool("read_log_file", {"log_path": log_file})
|
|
296
282
|
|
|
297
283
|
async def test_read_log_file_empty(self, mcp_client, mock_execute_with_fallback, setup_log_file):
|
|
298
284
|
"""Test read_log_file with empty log file."""
|
|
299
285
|
log_file = setup_log_file(content="")
|
|
300
286
|
mock_execute_with_fallback.return_value = (0, "", "")
|
|
301
287
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
assert "log file is empty" in result_text
|
|
288
|
+
with pytest.raises(ToolError, match="Log file is empty"):
|
|
289
|
+
await mcp_client.call_tool("read_log_file", {"log_path": log_file})
|
|
306
290
|
|
|
307
291
|
async def test_read_log_file_tail_not_found(self, mcp_client, mock_execute_with_fallback, setup_log_file):
|
|
308
292
|
"""Test read_log_file when tail command is not available."""
|
|
@@ -310,7 +294,7 @@ class TestReadLogFile:
|
|
|
310
294
|
mock_execute_with_fallback.side_effect = FileNotFoundError("tail not found")
|
|
311
295
|
|
|
312
296
|
with pytest.raises(ToolError, match="tail not found"):
|
|
313
|
-
await mcp_client.call_tool("read_log_file", {"log_path":
|
|
297
|
+
await mcp_client.call_tool("read_log_file", {"log_path": log_file})
|
|
314
298
|
|
|
315
299
|
async def test_read_log_file_multiple_allowed_paths(
|
|
316
300
|
self, mcp_client, mock_execute_with_fallback, mock_allowed_log_paths, tmp_path
|
|
@@ -325,7 +309,7 @@ class TestReadLogFile:
|
|
|
325
309
|
mock_allowed_log_paths(f"{log_file1},{log_file2}")
|
|
326
310
|
mock_execute_with_fallback.return_value = (0, "content2", "")
|
|
327
311
|
|
|
328
|
-
result = await mcp_client.call_tool("read_log_file", {"log_path":
|
|
312
|
+
result = await mcp_client.call_tool("read_log_file", {"log_path": log_file2})
|
|
329
313
|
result_text = result.content[0].text.casefold()
|
|
330
314
|
|
|
331
315
|
assert "content2" in result_text
|
|
@@ -368,9 +352,5 @@ class TestReadLogFile:
|
|
|
368
352
|
|
|
369
353
|
mock_allowed_log_paths(str(allowed_path))
|
|
370
354
|
|
|
371
|
-
|
|
372
|
-
"read_log_file", {"log_path":
|
|
373
|
-
)
|
|
374
|
-
result_text = result.content[0].text.casefold()
|
|
375
|
-
|
|
376
|
-
assert "not allowed" in result_text
|
|
355
|
+
with pytest.raises(ToolError, match="not allowed"):
|
|
356
|
+
await mcp_client.call_tool("read_log_file", {"log_path": restricted_path, "host": "remote.server.com"})
|
|
@@ -282,11 +282,9 @@ class TestListDirectories:
|
|
|
282
282
|
"""Test list_directories with nonexistent path raises ToolError."""
|
|
283
283
|
nonexistent = tmp_path / "nonexistent"
|
|
284
284
|
|
|
285
|
-
with pytest.raises(ToolError
|
|
285
|
+
with pytest.raises(ToolError, match="Error running command: command failed with return code 1"):
|
|
286
286
|
await mcp_client.call_tool("list_directories", arguments={"path": str(nonexistent)})
|
|
287
287
|
|
|
288
|
-
assert "Error running command: command failed with return code 1" in str(exc_info.value)
|
|
289
|
-
|
|
290
288
|
async def test_list_directories_restricted_path(self, restricted_path, mcp_client):
|
|
291
289
|
"""Test list_directories with restricted path raises ToolError."""
|
|
292
290
|
with pytest.raises(ToolError) as exc_info:
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Tests for input validation utilities."""
|
|
2
2
|
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
3
5
|
import pytest
|
|
4
6
|
|
|
5
7
|
from linux_mcp_server.utils.validation import is_empty_output
|
|
@@ -77,11 +79,11 @@ class TestValidatePath:
|
|
|
77
79
|
@pytest.mark.parametrize(
|
|
78
80
|
"path,expected",
|
|
79
81
|
[
|
|
80
|
-
("/var/log/messages", "/var/log/messages"),
|
|
81
|
-
("/home/user/file.txt", "/home/user/file.txt"),
|
|
82
|
-
("/", "/"),
|
|
83
|
-
("/tmp", "/tmp"),
|
|
84
|
-
("/path/with spaces/file.txt", "/path/with spaces/file.txt"),
|
|
82
|
+
("/var/log/messages", Path("/var/log/messages")),
|
|
83
|
+
("/home/user/file.txt", Path("/home/user/file.txt")),
|
|
84
|
+
("/", Path("/")),
|
|
85
|
+
("/tmp", Path("/tmp")),
|
|
86
|
+
("/path/with spaces/file.txt", Path("/path/with spaces/file.txt")),
|
|
85
87
|
],
|
|
86
88
|
)
|
|
87
89
|
def test_valid_absolute_paths(self, path, expected):
|
|
@@ -711,7 +711,7 @@ lua = [
|
|
|
711
711
|
|
|
712
712
|
[[package]]
|
|
713
713
|
name = "fastmcp"
|
|
714
|
-
version = "2.14.
|
|
714
|
+
version = "2.14.3"
|
|
715
715
|
source = { registry = "https://pypi.org/simple" }
|
|
716
716
|
dependencies = [
|
|
717
717
|
{ name = "authlib" },
|
|
@@ -731,9 +731,9 @@ dependencies = [
|
|
|
731
731
|
{ name = "uvicorn" },
|
|
732
732
|
{ name = "websockets" },
|
|
733
733
|
]
|
|
734
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
734
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/b5/7c4744dc41390ed2c17fd462ef2d42f4448a1ec53dda8fe3a01ff2872313/fastmcp-2.14.3.tar.gz", hash = "sha256:abc9113d5fcf79dfb4c060a1e1c55fccb0d4bce4a2e3eab15ca352341eec8dd6", size = 8279206, upload-time = "2026-01-12T20:00:40.789Z" }
|
|
735
735
|
wheels = [
|
|
736
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
736
|
+
{ url = "https://files.pythonhosted.org/packages/fc/dc/f7dd14213bf511690dccaa5094d436947c253b418c86c86211d1c76e6e44/fastmcp-2.14.3-py3-none-any.whl", hash = "sha256:103c6b4c6e97a9acc251c81d303f110fe4f2bdba31353df515d66272bf1b9414", size = 416220, upload-time = "2026-01-12T20:00:42.543Z" },
|
|
737
737
|
]
|
|
738
738
|
|
|
739
739
|
[[package]]
|
|
@@ -1121,7 +1121,7 @@ test = [
|
|
|
1121
1121
|
[package.metadata]
|
|
1122
1122
|
requires-dist = [
|
|
1123
1123
|
{ name = "asyncssh", extras = ["bcrypt"], specifier = ">=2.22.0" },
|
|
1124
|
-
{ name = "fastmcp", specifier = ">=2,<3" },
|
|
1124
|
+
{ name = "fastmcp", specifier = ">=2.14.3,<3" },
|
|
1125
1125
|
{ name = "gssapi", marker = "extra == 'gssapi'", specifier = ">=1.2.0" },
|
|
1126
1126
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
|
1127
1127
|
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
|
@@ -1938,7 +1938,7 @@ wheels = [
|
|
|
1938
1938
|
|
|
1939
1939
|
[[package]]
|
|
1940
1940
|
name = "pydocket"
|
|
1941
|
-
version = "0.
|
|
1941
|
+
version = "0.16.6"
|
|
1942
1942
|
source = { registry = "https://pypi.org/simple" }
|
|
1943
1943
|
dependencies = [
|
|
1944
1944
|
{ name = "cloudpickle" },
|
|
@@ -1955,9 +1955,9 @@ dependencies = [
|
|
|
1955
1955
|
{ name = "typer" },
|
|
1956
1956
|
{ name = "typing-extensions" },
|
|
1957
1957
|
]
|
|
1958
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
1958
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/00/26befe5f58df7cd1aeda4a8d10bc7d1908ffd86b80fd995e57a2a7b3f7bd/pydocket-0.16.6.tar.gz", hash = "sha256:b96c96ad7692827214ed4ff25fcf941ec38371314db5dcc1ae792b3e9d3a0294", size = 299054, upload-time = "2026-01-09T22:09:15.405Z" }
|
|
1959
1959
|
wheels = [
|
|
1960
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1960
|
+
{ url = "https://files.pythonhosted.org/packages/0a/3f/7483e5a6dc6326b6e0c640619b5c5bd1d6e3c20e54d58f5fb86267cef00e/pydocket-0.16.6-py3-none-any.whl", hash = "sha256:683d21e2e846aa5106274e7d59210331b242d7fb0dce5b08d3b82065663ed183", size = 67697, upload-time = "2026-01-09T22:09:13.436Z" },
|
|
1961
1961
|
]
|
|
1962
1962
|
|
|
1963
1963
|
[[package]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/examples/claude_desktop_config.example.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/connection/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_connection_manager.py
RENAMED
|
File without changes
|
{linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_discover_ssh_key.py
RENAMED
|
File without changes
|
{linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_execute_command.py
RENAMED
|
File without changes
|
|
File without changes
|
{linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_host_key_verification.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|