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.
Files changed (120) hide show
  1. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/PKG-INFO +2 -2
  2. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/clients.md +20 -21
  3. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/pyproject.toml +1 -1
  4. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/commands.py +1 -1
  5. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/formatters.py +4 -3
  6. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/logs.py +40 -30
  7. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/processes.py +4 -1
  8. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/services.py +14 -2
  9. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/storage.py +30 -23
  10. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/validation.py +2 -2
  11. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_formatters.py +3 -1
  12. linux_mcp_server-1.2.1/tests/test_tool_schemas.py +50 -0
  13. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_logs.py +29 -49
  14. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_storage.py +1 -3
  15. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/utils/test_validation.py +7 -5
  16. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/uv.lock +7 -7
  17. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.codecov.yml +0 -0
  18. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.coderabbit.yaml +0 -0
  19. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.devcontainer/Containerfile +0 -0
  20. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.devcontainer/devcontainer.json +0 -0
  21. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.github/workflows/build-publish.yml +0 -0
  22. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.github/workflows/ci.yml +0 -0
  23. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.github/workflows/ci_installation.yaml +0 -0
  24. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.github/workflows/docs.yml +0 -0
  25. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.github/workflows/functional-tests.yml +0 -0
  26. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.gitignore +0 -0
  27. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.tekton/pipeline-build-multiarch.yaml +0 -0
  28. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.tekton/pull_request.yaml +0 -0
  29. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.tekton/push.yaml +0 -0
  30. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/.tekton/task-get-version.yaml +0 -0
  31. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/AGENTS.md +0 -0
  32. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/Containerfile +0 -0
  33. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/LICENSE +0 -0
  34. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/Makefile +0 -0
  35. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/README.md +0 -0
  36. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/audit.md +0 -0
  37. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/commands.md +0 -0
  38. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/config.md +0 -0
  39. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/connection.md +0 -0
  40. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/formatters.md +0 -0
  41. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/index.md +0 -0
  42. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/parsers.md +0 -0
  43. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/server.md +0 -0
  44. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/logs.md +0 -0
  45. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/network.md +0 -0
  46. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/processes.md +0 -0
  47. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/services.md +0 -0
  48. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/storage.md +0 -0
  49. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/api/tools/system_info.md +0 -0
  50. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/architecture.md +0 -0
  51. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/cheatsheet.md +0 -0
  52. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/contributing.md +0 -0
  53. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/debugging.md +0 -0
  54. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/examples/claude_desktop_config.example.json +0 -0
  55. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/examples/example_config.sh +0 -0
  56. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/getting-started.md +0 -0
  57. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/index.md +0 -0
  58. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/install.md +0 -0
  59. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/troubleshooting.md +0 -0
  60. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/docs/usage.md +0 -0
  61. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/licenses/GPL-3.0.txt +0 -0
  62. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/mkdocs.yml +0 -0
  63. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/renovate.json +0 -0
  64. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/scripts/verify_installation.sh +0 -0
  65. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/__init__.py +0 -0
  66. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/__main__.py +0 -0
  67. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/_vendor/__init__.py +0 -0
  68. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/audit.py +0 -0
  69. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/config.py +0 -0
  70. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/connection/__init__.py +0 -0
  71. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/connection/ssh.py +0 -0
  72. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/logging_config.py +0 -0
  73. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/parsers.py +0 -0
  74. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/server.py +0 -0
  75. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/__init__.py +0 -0
  76. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/network.py +0 -0
  77. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/tools/system_info.py +0 -0
  78. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/__init__.py +0 -0
  79. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/decorators.py +0 -0
  80. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/enum.py +0 -0
  81. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/format.py +0 -0
  82. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/src/linux_mcp_server/utils/types.py +0 -0
  83. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/__init__.py +0 -0
  84. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/_vendor/__init__.py +0 -0
  85. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/_vendor/test_vendor.py +0 -0
  86. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/conftest.py +0 -0
  87. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/__init__.py +0 -0
  88. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/__init__.py +0 -0
  89. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_connection_manager.py +0 -0
  90. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_discover_ssh_key.py +0 -0
  91. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_execute_command.py +0 -0
  92. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_get_bin_path.py +0 -0
  93. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_host_key_verification.py +0 -0
  94. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/connection/ssh/test_timeout.py +0 -0
  95. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/__init__.py +0 -0
  96. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_cpu_info.py +0 -0
  97. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_free_output.py +0 -0
  98. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_ip_brief.py +0 -0
  99. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_listing.py +0 -0
  100. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_os_release.py +0 -0
  101. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_proc.py +0 -0
  102. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_ps_output.py +0 -0
  103. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_service_count.py +0 -0
  104. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_ss.py +0 -0
  105. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/parsers/test_parse_system_info.py +0 -0
  106. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test__main__.py +0 -0
  107. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_audit.py +0 -0
  108. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_commands.py +0 -0
  109. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_config.py +0 -0
  110. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_logging_config.py +0 -0
  111. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/test_server.py +0 -0
  112. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/__init__.py +0 -0
  113. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_network.py +0 -0
  114. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_processes.py +0 -0
  115. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_services.py +0 -0
  116. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/tools/test_system_info.py +0 -0
  117. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/utils/__init__.py +0 -0
  118. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/utils/test_decorators.py +0 -0
  119. {linux_mcp_server-1.2.0 → linux_mcp_server-1.2.1}/tests/utils/test_enum.py +0 -0
  120. {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.0
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/$USER/.local/bin/linux-mcp-server",
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
- "mcp": {
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"
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
  }
@@ -12,7 +12,7 @@ readme = "README.md"
12
12
  requires-python = ">=3.10"
13
13
  dependencies = [
14
14
  "asyncssh[bcrypt] >= 2.22.0",
15
- "fastmcp >=2, <3",
15
+ "fastmcp >=2.14.3, <3",
16
16
  "pydantic-settings >= 2.12.0",
17
17
  "pydantic >= 2.12.5",
18
18
  ]
@@ -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: str, lines_count: int) -> str:
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[str | None, "Filter by systemd unit name or pattern (e.g., 'nginx.service', 'ssh*')"] = None,
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
- "Filter by priority. Possible values: priority level (0-7), syslog level name ('emerg' to 'debug'), or range (e.g., 'err..info')",
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
- "Filter entries since specified time. Date/time filter (format: 'YYYY-MM-DD HH:MM:SS', 'today', 'yesterday', 'now', or relative like '-1h')",
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( # noqa: C901
135
- log_path: t.Annotated[str, "Path to the log file"],
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
- return (
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 = Path(validated_path).resolve()
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
- return (
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
- return f"Log file not found: {log_path}"
190
+ raise ToolError(f"Log file not found: {log_path}")
180
191
 
181
192
  if not requested_path.is_file():
182
- return f"Path is not a file: {log_path}"
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 validated_path not in allowed_paths:
188
- return (
189
- f"Access to log file '{log_path}' is not allowed.\n"
190
- f"Allowed log files: {', '.join(allowed_paths)}"
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
- return f"Permission denied reading log file: {log_path}"
200
- return f"Error reading log file: {stderr}"
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
- return f"Log file is empty: {log_path}"
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[int, Field(description="Process ID", ge=1)],
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[str, "Name of the service"],
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[str, "Name of the service"],
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[str, "The directory path to analyze"],
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 returncode != 0:
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[str, "The path to analyze"],
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[str, "The file path to read"],
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) -> 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).as_posix()
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": str(log_file)}
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
- result = await mcp_client.call_tool("read_log_file", {"log_path": "/var/log/test.log"})
221
- result_text = result.content[0].text.casefold()
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,path_resolver,expected_error",
224
+ "test_scenario,log_path,match",
228
225
  [
229
- # Invalid path
230
- ("invalid_path", lambda tp: "\x00invalid", "invalid log file path"),
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, path_resolver, expected_error
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
- # Create restricted file for not_allowed scenario
245
- if test_scenario == "not_allowed":
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
- assert expected_error in result_text
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
- result = await mcp_client.call_tool("read_log_file", {"log_path": str(nonexistent_file)})
261
- result_text = result.content[0].text.casefold()
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
- result = await mcp_client.call_tool("read_log_file", {"log_path": str(log_dir)})
272
- result_text = result.content[0].text.casefold()
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,expected_error",
265
+ "returncode,stderr,match",
278
266
  [
279
267
  # Permission denied
280
- (1, "tail: cannot open: Permission denied", "permission denied"),
268
+ (1, "tail: cannot open: Permission denied", "Permission denied"),
281
269
  # general error
282
- (1, "tail: error reading file", "error reading log 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, expected_error
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
- result = await mcp_client.call_tool("read_log_file", {"log_path": str(log_file)})
293
- result_text = result.content[0].text.casefold()
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
- result = await mcp_client.call_tool("read_log_file", {"log_path": str(log_file)})
303
- result_text = result.content[0].text.casefold()
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": str(log_file)})
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": str(log_file2)})
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
- result = await mcp_client.call_tool(
372
- "read_log_file", {"log_path": str(restricted_path), "host": "remote.server.com"}
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) as exc_info:
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.1"
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/9e/50/d38e4371bdc34e709f4731b1e882cb7bc50e51c1a224859d4cd381b3a79b/fastmcp-2.14.1.tar.gz", hash = "sha256:132725cbf77b68fa3c3d165eff0cfa47e40c1479457419e6a2cfda65bd84c8d6", size = 8263331, upload-time = "2025-12-15T02:26:27.102Z" }
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/1d/82/72401d09dc27c27fdf72ad6c2fe331e553e3c3646e01b5ff16473191033d/fastmcp-2.14.1-py3-none-any.whl", hash = "sha256:fb3e365cc1d52573ab89caeba9944dd4b056149097be169bce428e011f0a57e5", size = 412176, upload-time = "2025-12-15T02:26:25.356Z" },
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.15.6"
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/c4/24/bf749309644aa7254261410fd50ad885d90d36b29c89bebf5cbc4ad53183/pydocket-0.15.6.tar.gz", hash = "sha256:3cf7030941f175f073cf05fc6fdf0ceede0af640f786de7dd22556d08025799f", size = 277896, upload-time = "2025-12-16T19:48:29.93Z" }
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/7c/13/6a3d6222bde9029304f91e2289d0994d8bdac6a14ae7a50bd967c03a8332/pydocket-0.15.6-py3-none-any.whl", hash = "sha256:3192dfdb9ad772cc1481dc39194f474863f073266399795584dc7262e7510f94", size = 58814, upload-time = "2025-12-16T19:48:27.878Z" },
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]]