chore: enhance project metadata, tooling, and documentation

**Project Configuration:**
- Enhance pyproject.toml with comprehensive metadata, keywords, and classifiers
- Add LICENSE file (MIT) for proper open-source distribution
- Add PUBLISHING.md with comprehensive publishing guidelines
- Update .gitignore to exclude tool artifacts (.cache, .pytest_cache, .ruff_cache, .ty_cache)
- Ignore documentation working directories (.docs, .full-review, docs/plans, docs/sessions)

**Documentation:**
- Add extensive Unraid API research documentation
  - API source code analysis and resolver mapping
  - Competitive analysis and feature gap assessment
  - Release notes analysis (7.0.0, 7.1.0, 7.2.0)
  - Connect platform overview and remote access documentation
- Document known API patterns, limitations, and edge cases

**Testing & Code Quality:**
- Expand test coverage across all tool modules
- Add destructive action confirmation tests
- Improve test assertions and error case validation
- Refine type annotations for better static analysis

**Tool Improvements:**
- Enhance error handling consistency across all tools
- Improve type safety with explicit type annotations
- Refine GraphQL query construction patterns
- Better handling of optional parameters and edge cases

This commit prepares the project for v0.2.0 release with improved
metadata, comprehensive documentation, and enhanced code quality.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jacob Magar
2026-02-15 15:32:09 -05:00
parent eb9b01d044
commit 2697c269a3
39 changed files with 8978 additions and 155 deletions

View File

@@ -70,6 +70,32 @@ class TestArrayActions:
assert result["success"] is True
assert result["action"] == "shutdown"
async def test_stop_array(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"setState": {"state": "STOPPED"}}
tool_fn = _make_tool()
result = await tool_fn(action="stop", confirm=True)
assert result["success"] is True
assert result["action"] == "stop"
async def test_reboot(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"reboot": True}
tool_fn = _make_tool()
result = await tool_fn(action="reboot", confirm=True)
assert result["success"] is True
assert result["action"] == "reboot"
async def test_parity_pause(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"parityCheck": {"pause": True}}
tool_fn = _make_tool()
result = await tool_fn(action="parity_pause")
assert result["success"] is True
async def test_unmount_disk(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"unmountArrayDisk": True}
tool_fn = _make_tool()
result = await tool_fn(action="unmount_disk", disk_id="disk:1")
assert result["success"] is True
async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.side_effect = RuntimeError("disk error")
tool_fn = _make_tool()

View File

@@ -171,6 +171,34 @@ class TestDockerActions:
result = await tool_fn(action="remove", container_id="old-app", confirm=True)
assert result["success"] is True
async def test_details_found(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {
"docker": {"containers": [{"id": "c1", "names": ["plex"], "state": "running", "image": "plexinc/pms"}]}
}
tool_fn = _make_tool()
result = await tool_fn(action="details", container_id="plex")
assert result["names"] == ["plex"]
async def test_logs(self, _mock_graphql: AsyncMock) -> None:
cid = "a" * 64 + ":local"
_mock_graphql.side_effect = [
{"docker": {"containers": [{"id": cid, "names": ["plex"]}]}},
{"docker": {"logs": "2026-02-08 log line here"}},
]
tool_fn = _make_tool()
result = await tool_fn(action="logs", container_id="plex")
assert "log line" in result["logs"]
async def test_pause_container(self, _mock_graphql: AsyncMock) -> None:
cid = "a" * 64 + ":local"
_mock_graphql.side_effect = [
{"docker": {"containers": [{"id": cid, "names": ["plex"]}]}},
{"docker": {"pause": {"id": cid, "state": "paused"}}},
]
tool_fn = _make_tool()
result = await tool_fn(action="pause", container_id="plex")
assert result["success"] is True
async def test_generic_exception_wraps_in_tool_error(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.side_effect = RuntimeError("unexpected failure")
tool_fn = _make_tool()

View File

@@ -108,6 +108,16 @@ class TestHealthActions:
with pytest.raises(ToolError, match="broken"):
await tool_fn(action="diagnose")
async def test_diagnose_success(self, _mock_graphql: AsyncMock) -> None:
"""Diagnose returns subscription status when modules are available."""
tool_fn = _make_tool()
mock_status = {
"cpu_sub": {"runtime": {"connection_state": "connected", "last_error": None}},
}
with patch("unraid_mcp.tools.health._diagnose_subscriptions", return_value=mock_status):
result = await tool_fn(action="diagnose")
assert "cpu_sub" in result
async def test_diagnose_import_error_internal(self) -> None:
"""_diagnose_subscriptions catches ImportError and returns error dict."""
import builtins

View File

@@ -3,6 +3,7 @@
from unittest.mock import AsyncMock, patch
import pytest
from conftest import make_tool_fn
from unraid_mcp.core.exceptions import ToolError
from unraid_mcp.tools.info import (
@@ -89,13 +90,17 @@ class TestProcessArrayStatus:
# --- Integration tests for the tool function ---
class TestUnraidInfoTool:
@pytest.fixture
def _mock_graphql(self) -> AsyncMock:
with patch("unraid_mcp.tools.info.make_graphql_request", new_callable=AsyncMock) as mock:
yield mock
@pytest.fixture
def _mock_graphql() -> AsyncMock:
with patch("unraid_mcp.tools.info.make_graphql_request", new_callable=AsyncMock) as mock:
yield mock
@pytest.mark.asyncio
def _make_tool():
return make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
class TestUnraidInfoTool:
async def test_overview_action(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {
"info": {
@@ -103,57 +108,78 @@ class TestUnraidInfoTool:
"cpu": {"manufacturer": "Intel", "brand": "i7", "cores": 4, "threads": 8},
}
}
# Import and call the inner function by simulating registration
from fastmcp import FastMCP
test_mcp = FastMCP("test")
from unraid_mcp.tools.info import register_info_tool
register_info_tool(test_mcp)
tool_fn = test_mcp._tool_manager._tools["unraid_info"].fn
tool_fn = _make_tool()
result = await tool_fn(action="overview")
assert "summary" in result
_mock_graphql.assert_called_once()
@pytest.mark.asyncio
async def test_ups_device_requires_device_id(self, _mock_graphql: AsyncMock) -> None:
from fastmcp import FastMCP
test_mcp = FastMCP("test")
from unraid_mcp.tools.info import register_info_tool
register_info_tool(test_mcp)
tool_fn = test_mcp._tool_manager._tools["unraid_info"].fn
tool_fn = _make_tool()
with pytest.raises(ToolError, match="device_id is required"):
await tool_fn(action="ups_device")
@pytest.mark.asyncio
async def test_network_action(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"network": {"id": "net:1", "accessUrls": []}}
from fastmcp import FastMCP
test_mcp = FastMCP("test")
from unraid_mcp.tools.info import register_info_tool
register_info_tool(test_mcp)
tool_fn = test_mcp._tool_manager._tools["unraid_info"].fn
tool_fn = _make_tool()
result = await tool_fn(action="network")
assert result["id"] == "net:1"
@pytest.mark.asyncio
async def test_connect_action(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {
"connect": {"status": "connected", "sandbox": False, "flashGuid": "abc123"}
}
from fastmcp import FastMCP
test_mcp = FastMCP("test")
from unraid_mcp.tools.info import register_info_tool
register_info_tool(test_mcp)
tool_fn = test_mcp._tool_manager._tools["unraid_info"].fn
tool_fn = _make_tool()
result = await tool_fn(action="connect")
assert result["status"] == "connected"
@pytest.mark.asyncio
async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.side_effect = RuntimeError("unexpected")
from fastmcp import FastMCP
test_mcp = FastMCP("test")
from unraid_mcp.tools.info import register_info_tool
register_info_tool(test_mcp)
tool_fn = test_mcp._tool_manager._tools["unraid_info"].fn
tool_fn = _make_tool()
with pytest.raises(ToolError, match="unexpected"):
await tool_fn(action="online")
async def test_metrics(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"metrics": {"cpu": {"used": 25.5}, "memory": {"used": 8192, "total": 32768}}}
tool_fn = _make_tool()
result = await tool_fn(action="metrics")
assert result["cpu"]["used"] == 25.5
async def test_services(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"services": [{"name": "docker", "state": "running"}]}
tool_fn = _make_tool()
result = await tool_fn(action="services")
assert len(result["services"]) == 1
assert result["services"][0]["name"] == "docker"
async def test_settings(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"settings": {"unified": {"values": {"timezone": "US/Eastern"}}}}
tool_fn = _make_tool()
result = await tool_fn(action="settings")
assert result["timezone"] == "US/Eastern"
async def test_settings_non_dict_values(self, _mock_graphql: AsyncMock) -> None:
"""Settings values that are not a dict should be wrapped in {'raw': ...}."""
_mock_graphql.return_value = {"settings": {"unified": {"values": "raw_string"}}}
tool_fn = _make_tool()
result = await tool_fn(action="settings")
assert result == {"raw": "raw_string"}
async def test_servers(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"servers": [{"id": "s:1", "name": "tower", "status": "online"}]}
tool_fn = _make_tool()
result = await tool_fn(action="servers")
assert len(result["servers"]) == 1
assert result["servers"][0]["name"] == "tower"
async def test_flash(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"flash": {"id": "f:1", "guid": "abc", "product": "SanDisk", "vendor": "SanDisk", "size": 32000000000}}
tool_fn = _make_tool()
result = await tool_fn(action="flash")
assert result["product"] == "SanDisk"
async def test_ups_devices(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"upsDevices": [{"id": "ups:1", "model": "APC", "status": "online", "charge": 100}]}
tool_fn = _make_tool()
result = await tool_fn(action="ups_devices")
assert len(result["ups_devices"]) == 1
assert result["ups_devices"][0]["model"] == "APC"

View File

@@ -138,6 +138,13 @@ class TestNotificationsActions:
assert filter_var["limit"] == 10
assert filter_var["offset"] == 5
async def test_delete_archived(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"notifications": {"deleteArchivedNotifications": True}}
tool_fn = _make_tool()
result = await tool_fn(action="delete_archived", confirm=True)
assert result["success"] is True
assert result["action"] == "delete_archived"
async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.side_effect = RuntimeError("boom")
tool_fn = _make_tool()

View File

@@ -55,6 +55,17 @@ class TestStorageValidation:
with pytest.raises(ToolError, match="log_path"):
await tool_fn(action="logs")
async def test_logs_rejects_invalid_path(self, _mock_graphql: AsyncMock) -> None:
tool_fn = _make_tool()
with pytest.raises(ToolError, match="log_path must start with"):
await tool_fn(action="logs", log_path="/etc/shadow")
async def test_logs_allows_valid_paths(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"logFile": {"path": "/var/log/syslog", "content": "ok"}}
tool_fn = _make_tool()
result = await tool_fn(action="logs", log_path="/var/log/syslog")
assert result["content"] == "ok"
class TestStorageActions:
async def test_shares(self, _mock_graphql: AsyncMock) -> None:

View File

@@ -32,9 +32,9 @@ class TestVmValidation:
await tool_fn(action=action, vm_id="uuid-1")
async def test_destructive_vm_id_check_before_confirm(self, _mock_graphql: AsyncMock) -> None:
"""Destructive actions without vm_id should fail on confirm first."""
"""Destructive actions without vm_id should fail on vm_id first (validated before confirm)."""
tool_fn = _make_tool()
with pytest.raises(ToolError, match="destructive"):
with pytest.raises(ToolError, match="vm_id"):
await tool_fn(action="force_stop")
@@ -102,6 +102,27 @@ class TestVmActions:
assert result["success"] is True
assert result["action"] == "force_stop"
async def test_stop_vm(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"vm": {"stop": True}}
tool_fn = _make_tool()
result = await tool_fn(action="stop", vm_id="uuid-1")
assert result["success"] is True
assert result["action"] == "stop"
async def test_pause_vm(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"vm": {"pause": True}}
tool_fn = _make_tool()
result = await tool_fn(action="pause", vm_id="uuid-1")
assert result["success"] is True
assert result["action"] == "pause"
async def test_resume_vm(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"vm": {"resume": True}}
tool_fn = _make_tool()
result = await tool_fn(action="resume", vm_id="uuid-1")
assert result["success"] is True
assert result["action"] == "resume"
async def test_mutation_unexpected_response(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"vm": {}}
tool_fn = _make_tool()