diff --git a/CLAUDE.md b/CLAUDE.md index 1a145f4..525aaf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,21 +79,27 @@ docker-compose down - **Transport Layer**: Supports streamable-http (recommended), SSE (deprecated), and stdio ### Key Design Patterns +- **Consolidated Action Pattern**: Each tool uses `action: Literal[...]` parameter to expose multiple operations via a single MCP tool, reducing context window usage +- **Pre-built Query Dicts**: `QUERIES` and `MUTATIONS` dicts prevent GraphQL injection and organize operations +- **Destructive Action Safety**: `DESTRUCTIVE_ACTIONS` sets require `confirm=True` for dangerous operations - **Modular Architecture**: Clean separation of concerns across focused modules - **Error Handling**: Uses ToolError for user-facing errors, detailed logging for debugging -- **Timeout Management**: Custom timeout configurations for different query types +- **Timeout Management**: Custom timeout configurations for different query types (90s for disk ops) - **Data Processing**: Tools return both human-readable summaries and detailed raw data - **Health Monitoring**: Comprehensive health check tool for system monitoring - **Real-time Subscriptions**: WebSocket-based live data streaming -### Tool Categories (26 Tools Total) -1. **System Information** (6 tools): `get_system_info()`, `get_array_status()`, `get_network_config()`, `get_registration_info()`, `get_connect_settings()`, `get_unraid_variables()` -2. **Storage Management** (7 tools): `get_shares_info()`, `list_physical_disks()`, `get_disk_details()`, `list_available_log_files()`, `get_logs()`, `get_notifications_overview()`, `list_notifications()` -3. **Docker Management** (3 tools): `list_docker_containers()`, `manage_docker_container()`, `get_docker_container_details()` -4. **VM Management** (3 tools): `list_vms()`, `manage_vm()`, `get_vm_details()` -5. **Cloud Storage (RClone)** (4 tools): `list_rclone_remotes()`, `get_rclone_config_form()`, `create_rclone_remote()`, `delete_rclone_remote()` -6. **Health Monitoring** (1 tool): `health_check()` -7. **Subscription Diagnostics** (2 tools): `test_subscription_query()`, `diagnose_subscriptions()` +### Tool Categories (10 Tools, 90 Actions) +1. **`unraid_info`** (19 actions): overview, array, network, registration, connect, variables, metrics, services, display, config, online, owner, settings, server, servers, flash, ups_devices, ups_device, ups_config +2. **`unraid_array`** (12 actions): start, stop, parity_start/pause/resume/cancel/history, mount_disk, unmount_disk, clear_stats, shutdown, reboot +3. **`unraid_storage`** (6 actions): shares, disks, disk_details, unassigned, log_files, logs +4. **`unraid_docker`** (15 actions): list, details, start, stop, restart, pause, unpause, remove, update, update_all, logs, networks, network_details, port_conflicts, check_updates +5. **`unraid_vm`** (9 actions): list, details, start, stop, pause, resume, force_stop, reboot, reset +6. **`unraid_notifications`** (9 actions): overview, list, warnings, create, archive, unread, delete, delete_archived, archive_all +7. **`unraid_rclone`** (4 actions): list_remotes, config_form, create_remote, delete_remote +8. **`unraid_users`** (8 actions): me, list, get, add, delete, cloud, remote_access, origins +9. **`unraid_keys`** (5 actions): list, get, create, update, delete +10. **`unraid_health`** (3 actions): check, test_connection, diagnose ### Environment Variable Hierarchy The server loads environment variables from multiple locations in order: diff --git a/pyproject.toml b/pyproject.toml index e85caf1..5fe32c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "unraid-mcp" -version = "0.1.0" +version = "0.2.0" description = "MCP Server for Unraid API - provides tools to interact with an Unraid server's GraphQL API" authors = [ {name = "jmagar", email = "jmagar@users.noreply.github.com"} @@ -33,18 +33,6 @@ dependencies = [ "websockets>=13.1,<14.0", "rich>=14.1.0", "pytz>=2025.2", - "mypy>=1.17.1", - "ruff>=0.12.8", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.4.1", - "pytest-asyncio>=1.1.0", - "black>=25.1.0", - "ruff>=0.12.8", - "mypy>=1.17.1", - "types-python-dateutil", ] [project.urls] @@ -151,5 +139,11 @@ exclude_lines = [ [dependency-groups] dev = [ + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-cov>=7.0.0", "types-pytz>=2025.2.0.20250809", + "mypy>=1.17.1", + "ruff>=0.12.8", + "black>=25.1.0", ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..93d24b7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,50 @@ +"""Shared test fixtures and helpers for Unraid MCP server tests.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from fastmcp import FastMCP + + +@pytest.fixture +def mock_graphql_request() -> AsyncMock: + """Fixture that patches make_graphql_request at the core module. + + NOTE: Since each tool file imports make_graphql_request into its own + namespace, tool-specific tests should patch at the tool module level + (e.g., "unraid_mcp.tools.info.make_graphql_request") instead of using + this fixture. This fixture is useful for testing the core client + or for integration tests that reload modules. + """ + with patch("unraid_mcp.core.client.make_graphql_request", new_callable=AsyncMock) as mock: + yield mock + + +def make_tool_fn( + module_path: str, + register_fn_name: str, + tool_name: str, +) -> Any: + """Extract a tool function from a FastMCP registration for testing. + + This wraps the repeated pattern of creating a test FastMCP instance, + registering a tool, and extracting the inner function. Centralizing + this avoids reliance on FastMCP's private `_tool_manager._tools` API + in every test file. + + Args: + module_path: Dotted import path to the tool module (e.g., "unraid_mcp.tools.info") + register_fn_name: Name of the registration function (e.g., "register_info_tool") + tool_name: Name of the registered tool (e.g., "unraid_info") + + Returns: + The async tool function callable + """ + import importlib + + module = importlib.import_module(module_path) + register_fn = getattr(module, register_fn_name) + test_mcp = FastMCP("test") + register_fn(test_mcp) + return test_mcp._tool_manager._tools[tool_name].fn diff --git a/tests/test_array.py b/tests/test_array.py new file mode 100644 index 0000000..0f89b83 --- /dev/null +++ b/tests/test_array.py @@ -0,0 +1,77 @@ +"""Tests for unraid_array tool.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from conftest import make_tool_fn + +from unraid_mcp.core.exceptions import ToolError + + +@pytest.fixture +def _mock_graphql() -> AsyncMock: + with patch("unraid_mcp.tools.array.make_graphql_request", new_callable=AsyncMock) as mock: + yield mock + + +def _make_tool(): + return make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array") + + +class TestArrayValidation: + async def test_destructive_action_requires_confirm(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + for action in ("start", "stop", "shutdown", "reboot"): + with pytest.raises(ToolError, match="destructive"): + await tool_fn(action=action) + + async def test_disk_action_requires_disk_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + for action in ("mount_disk", "unmount_disk", "clear_stats"): + with pytest.raises(ToolError, match="disk_id"): + await tool_fn(action=action) + + +class TestArrayActions: + async def test_start_array(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"setState": {"state": "STARTED"}} + tool_fn = _make_tool() + result = await tool_fn(action="start", confirm=True) + assert result["success"] is True + assert result["action"] == "start" + _mock_graphql.assert_called_once() + + async def test_parity_start_with_correct(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"parityCheck": {"start": True}} + tool_fn = _make_tool() + result = await tool_fn(action="parity_start", correct=True) + assert result["success"] is True + call_args = _mock_graphql.call_args + assert call_args[0][1] == {"correct": True} + + async def test_parity_history(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"array": {"parityCheckStatus": {"progress": 50}}} + tool_fn = _make_tool() + result = await tool_fn(action="parity_history") + assert result["success"] is True + + async def test_mount_disk(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"mountArrayDisk": True} + tool_fn = _make_tool() + result = await tool_fn(action="mount_disk", disk_id="disk:1") + assert result["success"] is True + call_args = _mock_graphql.call_args + assert call_args[0][1] == {"id": "disk:1"} + + async def test_shutdown(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"shutdown": True} + tool_fn = _make_tool() + result = await tool_fn(action="shutdown", confirm=True) + assert result["success"] is True + assert result["action"] == "shutdown" + + async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.side_effect = RuntimeError("disk error") + tool_fn = _make_tool() + with pytest.raises(ToolError, match="disk error"): + await tool_fn(action="parity_history") diff --git a/tests/test_docker.py b/tests/test_docker.py new file mode 100644 index 0000000..c239686 --- /dev/null +++ b/tests/test_docker.py @@ -0,0 +1,178 @@ +"""Tests for unraid_docker tool.""" + +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.docker import find_container_by_identifier, get_available_container_names + +# --- Unit tests for helpers --- + + +class TestFindContainerByIdentifier: + def test_by_exact_id(self) -> None: + containers = [{"id": "abc123", "names": ["plex"]}] + assert find_container_by_identifier("abc123", containers) == containers[0] + + def test_by_exact_name(self) -> None: + containers = [{"id": "abc123", "names": ["plex"]}] + assert find_container_by_identifier("plex", containers) == containers[0] + + def test_fuzzy_match(self) -> None: + containers = [{"id": "abc123", "names": ["plex-media-server"]}] + result = find_container_by_identifier("plex", containers) + assert result == containers[0] + + def test_not_found(self) -> None: + containers = [{"id": "abc123", "names": ["plex"]}] + assert find_container_by_identifier("sonarr", containers) is None + + def test_empty_list(self) -> None: + assert find_container_by_identifier("plex", []) is None + + +class TestGetAvailableContainerNames: + def test_extracts_names(self) -> None: + containers = [ + {"names": ["plex"]}, + {"names": ["sonarr", "sonarr-v3"]}, + ] + names = get_available_container_names(containers) + assert "plex" in names + assert "sonarr" in names + assert "sonarr-v3" in names + + def test_empty(self) -> None: + assert get_available_container_names([]) == [] + + +# --- Integration tests --- + + +@pytest.fixture +def _mock_graphql() -> AsyncMock: + with patch("unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock) as mock: + yield mock + + +def _make_tool(): + return make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker") + + +class TestDockerValidation: + async def test_remove_requires_confirm(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="destructive"): + await tool_fn(action="remove", container_id="abc123") + + async def test_container_actions_require_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + for action in ("start", "stop", "details", "logs", "pause", "unpause"): + with pytest.raises(ToolError, match="container_id"): + await tool_fn(action=action) + + async def test_network_details_requires_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="network_id"): + await tool_fn(action="network_details") + + +class TestDockerActions: + async def test_list(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "docker": {"containers": [{"id": "c1", "names": ["plex"], "state": "running"}]} + } + tool_fn = _make_tool() + result = await tool_fn(action="list") + assert len(result["containers"]) == 1 + + async def test_start_container(self, _mock_graphql: AsyncMock) -> None: + # First call resolves ID, second performs start + _mock_graphql.side_effect = [ + {"docker": {"containers": [{"id": "abc123def456" * 4 + "abcd1234abcd1234:local", "names": ["plex"]}]}}, + {"docker": {"start": {"id": "abc123def456" * 4 + "abcd1234abcd1234:local", "state": "running"}}}, + ] + tool_fn = _make_tool() + result = await tool_fn(action="start", container_id="plex") + assert result["success"] is True + + async def test_networks(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"dockerNetworks": [{"id": "net:1", "name": "bridge"}]} + tool_fn = _make_tool() + result = await tool_fn(action="networks") + assert len(result["networks"]) == 1 + + async def test_port_conflicts(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"docker": {"portConflicts": []}} + tool_fn = _make_tool() + result = await tool_fn(action="port_conflicts") + assert result["port_conflicts"] == [] + + async def test_check_updates(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "docker": {"containerUpdateStatuses": [{"id": "c1", "name": "plex", "updateAvailable": True}]} + } + tool_fn = _make_tool() + result = await tool_fn(action="check_updates") + assert len(result["update_statuses"]) == 1 + + async def test_idempotent_start(self, _mock_graphql: AsyncMock) -> None: + # Resolve + idempotent success + _mock_graphql.side_effect = [ + {"docker": {"containers": [{"id": "a" * 64 + ":local", "names": ["plex"]}]}}, + {"idempotent_success": True, "docker": {}}, + ] + tool_fn = _make_tool() + result = await tool_fn(action="start", container_id="plex") + assert result["idempotent"] is True + + async def test_restart(self, _mock_graphql: AsyncMock) -> None: + cid = "a" * 64 + ":local" + _mock_graphql.side_effect = [ + {"docker": {"containers": [{"id": cid, "names": ["plex"]}]}}, + {"docker": {"stop": {"id": cid, "state": "exited"}}}, + {"docker": {"start": {"id": cid, "state": "running"}}}, + ] + tool_fn = _make_tool() + result = await tool_fn(action="restart", container_id="plex") + assert result["success"] is True + assert result["action"] == "restart" + + async def test_restart_idempotent_stop(self, _mock_graphql: AsyncMock) -> None: + cid = "a" * 64 + ":local" + _mock_graphql.side_effect = [ + {"docker": {"containers": [{"id": cid, "names": ["plex"]}]}}, + {"idempotent_success": True}, + {"docker": {"start": {"id": cid, "state": "running"}}}, + ] + tool_fn = _make_tool() + result = await tool_fn(action="restart", container_id="plex") + assert result["success"] is True + assert "note" in result + + async def test_update_all(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "docker": {"updateAllContainers": [{"id": "c1", "state": "running"}]} + } + tool_fn = _make_tool() + result = await tool_fn(action="update_all") + assert result["success"] is True + assert len(result["containers"]) == 1 + + async def test_remove_with_confirm(self, _mock_graphql: AsyncMock) -> None: + cid = "a" * 64 + ":local" + _mock_graphql.side_effect = [ + {"docker": {"containers": [{"id": cid, "names": ["old-app"]}]}}, + {"docker": {"removeContainer": True}}, + ] + tool_fn = _make_tool() + result = await tool_fn(action="remove", container_id="old-app", confirm=True) + 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() + with pytest.raises(ToolError, match="unexpected failure"): + await tool_fn(action="list") diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..72bd1f3 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,126 @@ +"""Tests for unraid_health tool.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from conftest import make_tool_fn + +from unraid_mcp.core.exceptions import ToolError + + +@pytest.fixture +def _mock_graphql() -> AsyncMock: + with patch("unraid_mcp.tools.health.make_graphql_request", new_callable=AsyncMock) as mock: + yield mock + + +def _make_tool(): + return make_tool_fn("unraid_mcp.tools.health", "register_health_tool", "unraid_health") + + +class TestHealthValidation: + async def test_invalid_action(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="Invalid action"): + await tool_fn(action="invalid") + + +class TestHealthActions: + async def test_test_connection(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"online": True} + tool_fn = _make_tool() + result = await tool_fn(action="test_connection") + assert result["status"] == "connected" + assert result["online"] is True + assert "latency_ms" in result + + async def test_check_healthy(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "info": { + "machineId": "abc123", + "time": "2026-02-08T12:00:00Z", + "versions": {"unraid": "7.2.0"}, + "os": {"uptime": 86400}, + }, + "array": {"state": "STARTED"}, + "notifications": { + "overview": {"unread": {"alert": 0, "warning": 0, "total": 3}} + }, + "docker": { + "containers": [{"id": "c1", "state": "running", "status": "Up 2 days"}] + }, + } + tool_fn = _make_tool() + result = await tool_fn(action="check") + assert result["status"] == "healthy" + assert "api_latency_ms" in result + + async def test_check_warning_on_alerts(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "info": {"machineId": "abc", "versions": {"unraid": "7.2"}, "os": {"uptime": 100}}, + "array": {"state": "STARTED"}, + "notifications": { + "overview": {"unread": {"alert": 3, "warning": 0, "total": 3}} + }, + "docker": {"containers": []}, + } + tool_fn = _make_tool() + result = await tool_fn(action="check") + assert result["status"] == "warning" + assert any("alert" in i for i in result.get("issues", [])) + + async def test_check_no_data(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {} + tool_fn = _make_tool() + result = await tool_fn(action="check") + assert result["status"] == "unhealthy" + + async def test_check_api_error(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.side_effect = Exception("Connection refused") + tool_fn = _make_tool() + result = await tool_fn(action="check") + assert result["status"] == "unhealthy" + assert "Connection refused" in result["error"] + + async def test_check_severity_never_downgrades(self, _mock_graphql: AsyncMock) -> None: + """Degraded from missing info should not be overwritten by warning from alerts.""" + _mock_graphql.return_value = { + "info": {}, + "array": {"state": "STARTED"}, + "notifications": { + "overview": {"unread": {"alert": 5, "warning": 0, "total": 5}} + }, + "docker": {"containers": []}, + } + tool_fn = _make_tool() + result = await tool_fn(action="check") + # Missing info escalates to "degraded"; alerts only escalate to "warning" + # Severity should stay at "degraded" (not downgrade to "warning") + assert result["status"] == "degraded" + + async def test_diagnose_wraps_exception(self, _mock_graphql: AsyncMock) -> None: + """When _diagnose_subscriptions raises, tool wraps in ToolError.""" + tool_fn = _make_tool() + with patch( + "unraid_mcp.tools.health._diagnose_subscriptions", + side_effect=RuntimeError("broken"), + ): + with pytest.raises(ToolError, match="broken"): + await tool_fn(action="diagnose") + + async def test_diagnose_import_error_internal(self) -> None: + """_diagnose_subscriptions catches ImportError and returns error dict.""" + import builtins + + from unraid_mcp.tools.health import _diagnose_subscriptions + + real_import = builtins.__import__ + + def fail_subscriptions(name, *args, **kwargs): + if "subscriptions" in name: + raise ImportError("no module") + return real_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=fail_subscriptions): + result = await _diagnose_subscriptions() + assert "error" in result diff --git a/tests/test_info.py b/tests/test_info.py new file mode 100644 index 0000000..2ed1fde --- /dev/null +++ b/tests/test_info.py @@ -0,0 +1,159 @@ +"""Tests for unraid_info tool.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from unraid_mcp.core.exceptions import ToolError +from unraid_mcp.tools.info import ( + _analyze_disk_health, + _process_array_status, + _process_system_info, +) + +# --- Unit tests for helper functions --- + + +class TestProcessSystemInfo: + def test_processes_os_info(self) -> None: + raw = { + "os": {"distro": "Unraid", "release": "7.2", "platform": "linux", "arch": "x86_64", "hostname": "tower", "uptime": 3600}, + "cpu": {"manufacturer": "AMD", "brand": "Ryzen", "cores": 8, "threads": 16}, + } + result = _process_system_info(raw) + assert "summary" in result + assert "details" in result + assert result["summary"]["hostname"] == "tower" + assert "AMD" in result["summary"]["cpu"] + + def test_handles_missing_fields(self) -> None: + result = _process_system_info({}) + assert result["summary"] == {"memory_summary": "Memory information not available."} + + def test_processes_memory_layout(self) -> None: + raw = {"memory": {"layout": [{"bank": "0", "type": "DDR4", "clockSpeed": 3200, "manufacturer": "G.Skill", "partNum": "XYZ"}]}} + result = _process_system_info(raw) + assert len(result["summary"]["memory_layout_details"]) == 1 + + +class TestAnalyzeDiskHealth: + def test_counts_healthy_disks(self) -> None: + disks = [{"status": "DISK_OK"}, {"status": "DISK_OK"}] + result = _analyze_disk_health(disks) + assert result["healthy"] == 2 + + def test_counts_failed_disks(self) -> None: + disks = [{"status": "DISK_DSBL"}, {"status": "DISK_INVALID"}] + result = _analyze_disk_health(disks) + assert result["failed"] == 2 + + def test_counts_warning_disks(self) -> None: + disks = [{"status": "DISK_OK", "warning": 45}] + result = _analyze_disk_health(disks) + assert result["warning"] == 1 + + def test_counts_missing_disks(self) -> None: + disks = [{"status": "DISK_NP"}] + result = _analyze_disk_health(disks) + assert result["missing"] == 1 + + def test_empty_list(self) -> None: + result = _analyze_disk_health([]) + assert result["healthy"] == 0 + + +class TestProcessArrayStatus: + def test_basic_array(self) -> None: + raw = { + "state": "STARTED", + "capacity": {"kilobytes": {"free": "1048576", "used": "524288", "total": "1572864"}}, + "parities": [{"status": "DISK_OK"}], + "disks": [{"status": "DISK_OK"}], + "caches": [], + } + result = _process_array_status(raw) + assert result["summary"]["state"] == "STARTED" + assert result["summary"]["overall_health"] == "HEALTHY" + + def test_degraded_array(self) -> None: + raw = { + "state": "STARTED", + "parities": [], + "disks": [{"status": "DISK_NP"}], + "caches": [], + } + result = _process_array_status(raw) + assert result["summary"]["overall_health"] == "DEGRADED" + + +# --- 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.mark.asyncio + async def test_overview_action(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "info": { + "os": {"distro": "Unraid", "release": "7.2", "platform": "linux", "arch": "x86_64", "hostname": "test"}, + "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 + 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 + 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 + 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 + 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 + with pytest.raises(ToolError, match="unexpected"): + await tool_fn(action="online") diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..ae06dc0 --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,90 @@ +"""Tests for unraid_keys tool.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from conftest import make_tool_fn + +from unraid_mcp.core.exceptions import ToolError + + +@pytest.fixture +def _mock_graphql() -> AsyncMock: + with patch("unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock) as mock: + yield mock + + +def _make_tool(): + return make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys") + + +class TestKeysValidation: + async def test_delete_requires_confirm(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="destructive"): + await tool_fn(action="delete", key_id="k:1") + + async def test_get_requires_key_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="key_id"): + await tool_fn(action="get") + + async def test_create_requires_name(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="name"): + await tool_fn(action="create") + + async def test_update_requires_key_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="key_id"): + await tool_fn(action="update") + + async def test_delete_requires_key_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="key_id"): + await tool_fn(action="delete", confirm=True) + + +class TestKeysActions: + async def test_list(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "apiKeys": [{"id": "k:1", "name": "mcp-key", "roles": ["admin"]}] + } + tool_fn = _make_tool() + result = await tool_fn(action="list") + assert len(result["keys"]) == 1 + + async def test_get(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"apiKey": {"id": "k:1", "name": "mcp-key", "roles": ["admin"]}} + tool_fn = _make_tool() + result = await tool_fn(action="get", key_id="k:1") + assert result["name"] == "mcp-key" + + async def test_create(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "createApiKey": {"id": "k:new", "name": "new-key", "key": "secret123", "roles": []} + } + tool_fn = _make_tool() + result = await tool_fn(action="create", name="new-key") + assert result["success"] is True + assert result["key"]["name"] == "new-key" + + async def test_create_with_roles(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "createApiKey": {"id": "k:new", "name": "admin-key", "key": "secret", "roles": ["admin"]} + } + tool_fn = _make_tool() + result = await tool_fn(action="create", name="admin-key", roles=["admin"]) + assert result["success"] is True + + async def test_update(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"updateApiKey": {"id": "k:1", "name": "renamed", "roles": []}} + tool_fn = _make_tool() + result = await tool_fn(action="update", key_id="k:1", name="renamed") + assert result["success"] is True + + async def test_delete(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"deleteApiKeys": True} + tool_fn = _make_tool() + result = await tool_fn(action="delete", key_id="k:1", confirm=True) + assert result["success"] is True diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..c40d470 --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,145 @@ +"""Tests for unraid_notifications tool.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from conftest import make_tool_fn + +from unraid_mcp.core.exceptions import ToolError + + +@pytest.fixture +def _mock_graphql() -> AsyncMock: + with patch("unraid_mcp.tools.notifications.make_graphql_request", new_callable=AsyncMock) as mock: + yield mock + + +def _make_tool(): + return make_tool_fn( + "unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications" + ) + + +class TestNotificationsValidation: + async def test_delete_requires_confirm(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="destructive"): + await tool_fn(action="delete", notification_id="n:1", notification_type="UNREAD") + + async def test_delete_archived_requires_confirm(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="destructive"): + await tool_fn(action="delete_archived") + + async def test_create_requires_fields(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="requires title"): + await tool_fn(action="create") + + async def test_archive_requires_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="notification_id"): + await tool_fn(action="archive") + + async def test_delete_requires_id_and_type(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="requires notification_id"): + await tool_fn(action="delete", confirm=True) + + +class TestNotificationsActions: + async def test_overview(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "notifications": { + "overview": { + "unread": {"info": 5, "warning": 2, "alert": 0, "total": 7}, + "archive": {"info": 10, "warning": 1, "alert": 0, "total": 11}, + } + } + } + tool_fn = _make_tool() + result = await tool_fn(action="overview") + assert result["unread"]["total"] == 7 + + async def test_list(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "notifications": { + "list": [{"id": "n:1", "title": "Test", "importance": "INFO"}] + } + } + tool_fn = _make_tool() + result = await tool_fn(action="list") + assert len(result["notifications"]) == 1 + + async def test_warnings(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "notifications": {"warningsAndAlerts": [{"id": "n:1", "importance": "WARNING"}]} + } + tool_fn = _make_tool() + result = await tool_fn(action="warnings") + assert len(result["warnings"]) == 1 + + async def test_create(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "notifications": {"createNotification": {"id": "n:new", "title": "Test", "importance": "INFO"}} + } + tool_fn = _make_tool() + result = await tool_fn( + action="create", + title="Test", + subject="Test Subject", + description="Test Desc", + importance="info", + ) + assert result["success"] is True + + async def test_archive_notification(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"notifications": {"archiveNotification": True}} + tool_fn = _make_tool() + result = await tool_fn(action="archive", notification_id="n:1") + assert result["success"] is True + + async def test_delete_with_confirm(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"notifications": {"deleteNotification": True}} + tool_fn = _make_tool() + result = await tool_fn( + action="delete", + notification_id="n:1", + notification_type="unread", + confirm=True, + ) + assert result["success"] is True + + async def test_archive_all(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"notifications": {"archiveAll": True}} + tool_fn = _make_tool() + result = await tool_fn(action="archive_all") + assert result["success"] is True + + async def test_unread_notification(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"notifications": {"unreadNotification": True}} + tool_fn = _make_tool() + result = await tool_fn(action="unread", notification_id="n:1") + assert result["success"] is True + assert result["action"] == "unread" + + async def test_list_with_importance_filter(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "notifications": { + "list": [{"id": "n:1", "title": "Alert", "importance": "WARNING"}] + } + } + tool_fn = _make_tool() + result = await tool_fn(action="list", importance="warning", limit=10, offset=5) + assert len(result["notifications"]) == 1 + call_args = _mock_graphql.call_args + filter_var = call_args[0][1]["filter"] + assert filter_var["importance"] == "WARNING" + assert filter_var["limit"] == 10 + assert filter_var["offset"] == 5 + + async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.side_effect = RuntimeError("boom") + tool_fn = _make_tool() + with pytest.raises(ToolError, match="boom"): + await tool_fn(action="overview") diff --git a/tests/test_rclone.py b/tests/test_rclone.py new file mode 100644 index 0000000..edb891a --- /dev/null +++ b/tests/test_rclone.py @@ -0,0 +1,102 @@ +"""Tests for unraid_rclone tool.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from conftest import make_tool_fn + +from unraid_mcp.core.exceptions import ToolError + + +@pytest.fixture +def _mock_graphql() -> AsyncMock: + with patch("unraid_mcp.tools.rclone.make_graphql_request", new_callable=AsyncMock) as mock: + yield mock + + +def _make_tool(): + return make_tool_fn("unraid_mcp.tools.rclone", "register_rclone_tool", "unraid_rclone") + + +class TestRcloneValidation: + async def test_delete_requires_confirm(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="destructive"): + await tool_fn(action="delete_remote", name="gdrive") + + async def test_create_requires_fields(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="requires name"): + await tool_fn(action="create_remote") + + async def test_delete_requires_name(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="name is required"): + await tool_fn(action="delete_remote", confirm=True) + + +class TestRcloneActions: + async def test_list_remotes(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "rclone": {"remotes": [{"name": "gdrive", "type": "drive"}]} + } + tool_fn = _make_tool() + result = await tool_fn(action="list_remotes") + assert len(result["remotes"]) == 1 + + async def test_config_form(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "rclone": {"configForm": {"id": "form:1", "dataSchema": {}, "uiSchema": {}}} + } + tool_fn = _make_tool() + result = await tool_fn(action="config_form") + assert result["id"] == "form:1" + + async def test_config_form_with_provider(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "rclone": {"configForm": {"id": "form:s3", "dataSchema": {}, "uiSchema": {}}} + } + tool_fn = _make_tool() + result = await tool_fn(action="config_form", provider_type="s3") + assert result["id"] == "form:s3" + call_args = _mock_graphql.call_args + assert call_args[0][1] == {"formOptions": {"providerType": "s3"}} + + async def test_create_remote(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "rclone": {"createRCloneRemote": {"name": "newremote", "type": "s3"}} + } + tool_fn = _make_tool() + result = await tool_fn( + action="create_remote", + name="newremote", + provider_type="s3", + config_data={"bucket": "mybucket"}, + ) + assert result["success"] is True + + async def test_create_remote_with_empty_config(self, _mock_graphql: AsyncMock) -> None: + """Empty config_data dict should be accepted (not rejected by truthiness).""" + _mock_graphql.return_value = { + "rclone": {"createRCloneRemote": {"name": "ftp-remote", "type": "ftp"}} + } + tool_fn = _make_tool() + result = await tool_fn( + action="create_remote", + name="ftp-remote", + provider_type="ftp", + config_data={}, + ) + assert result["success"] is True + + async def test_delete_remote(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"rclone": {"deleteRCloneRemote": True}} + tool_fn = _make_tool() + result = await tool_fn(action="delete_remote", name="gdrive", confirm=True) + assert result["success"] is True + + async def test_delete_remote_failure(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"rclone": {"deleteRCloneRemote": False}} + tool_fn = _make_tool() + with pytest.raises(ToolError, match="Failed to delete"): + await tool_fn(action="delete_remote", name="gdrive", confirm=True) diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..c1a1839 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,105 @@ +"""Tests for unraid_storage tool.""" + +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.storage import format_bytes + +# --- Unit tests for helpers --- + + +class TestFormatBytes: + def test_none(self) -> None: + assert format_bytes(None) == "N/A" + + def test_bytes(self) -> None: + assert format_bytes(512) == "512.00 B" + + def test_kilobytes(self) -> None: + assert format_bytes(2048) == "2.00 KB" + + def test_megabytes(self) -> None: + assert format_bytes(1048576) == "1.00 MB" + + def test_gigabytes(self) -> None: + assert format_bytes(1073741824) == "1.00 GB" + + def test_terabytes(self) -> None: + assert format_bytes(1099511627776) == "1.00 TB" + + +# --- Integration tests --- + + +@pytest.fixture +def _mock_graphql() -> AsyncMock: + with patch("unraid_mcp.tools.storage.make_graphql_request", new_callable=AsyncMock) as mock: + yield mock + + +def _make_tool(): + return make_tool_fn("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage") + + +class TestStorageValidation: + async def test_disk_details_requires_disk_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="disk_id"): + await tool_fn(action="disk_details") + + async def test_logs_requires_log_path(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="log_path"): + await tool_fn(action="logs") + + +class TestStorageActions: + async def test_shares(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "shares": [{"id": "s:1", "name": "media"}, {"id": "s:2", "name": "backups"}] + } + tool_fn = _make_tool() + result = await tool_fn(action="shares") + assert len(result["shares"]) == 2 + + async def test_disks(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"disks": [{"id": "d:1", "device": "sda"}]} + tool_fn = _make_tool() + result = await tool_fn(action="disks") + assert len(result["disks"]) == 1 + + async def test_disk_details(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "disk": {"id": "d:1", "device": "sda", "name": "WD", "serialNum": "SN1", "size": 1073741824, "temperature": 35} + } + tool_fn = _make_tool() + result = await tool_fn(action="disk_details", disk_id="d:1") + assert result["summary"]["temperature"] == "35C" + assert "1.00 GB" in result["summary"]["size_formatted"] + + async def test_disk_details_not_found(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"disk": None} + tool_fn = _make_tool() + with pytest.raises(ToolError, match="not found"): + await tool_fn(action="disk_details", disk_id="d:missing") + + async def test_unassigned(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"unassignedDevices": []} + tool_fn = _make_tool() + result = await tool_fn(action="unassigned") + assert result["devices"] == [] + + async def test_log_files(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"logFiles": [{"name": "syslog", "path": "/var/log/syslog"}]} + tool_fn = _make_tool() + result = await tool_fn(action="log_files") + assert len(result["log_files"]) == 1 + + async def test_logs(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"logFile": {"path": "/var/log/syslog", "content": "log line", "totalLines": 1}} + tool_fn = _make_tool() + result = await tool_fn(action="logs", log_path="/var/log/syslog") + assert result["content"] == "log line" diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..901c6ca --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,100 @@ +"""Tests for unraid_users tool.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from conftest import make_tool_fn + +from unraid_mcp.core.exceptions import ToolError + + +@pytest.fixture +def _mock_graphql() -> AsyncMock: + with patch("unraid_mcp.tools.users.make_graphql_request", new_callable=AsyncMock) as mock: + yield mock + + +def _make_tool(): + return make_tool_fn("unraid_mcp.tools.users", "register_users_tool", "unraid_users") + + +class TestUsersValidation: + async def test_delete_requires_confirm(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="destructive"): + await tool_fn(action="delete", user_id="u:1") + + async def test_get_requires_user_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="user_id"): + await tool_fn(action="get") + + async def test_add_requires_name_and_password(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="requires name and password"): + await tool_fn(action="add") + + async def test_delete_requires_user_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + with pytest.raises(ToolError, match="user_id"): + await tool_fn(action="delete", confirm=True) + + +class TestUsersActions: + async def test_me(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"me": {"id": "u:1", "name": "root", "role": "ADMIN"}} + tool_fn = _make_tool() + result = await tool_fn(action="me") + assert result["name"] == "root" + + async def test_list(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "users": [{"id": "u:1", "name": "root"}, {"id": "u:2", "name": "guest"}] + } + tool_fn = _make_tool() + result = await tool_fn(action="list") + assert len(result["users"]) == 2 + + async def test_get(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"user": {"id": "u:1", "name": "root", "role": "ADMIN"}} + tool_fn = _make_tool() + result = await tool_fn(action="get", user_id="u:1") + assert result["name"] == "root" + + async def test_add(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"addUser": {"id": "u:3", "name": "newuser", "role": "USER"}} + tool_fn = _make_tool() + result = await tool_fn(action="add", name="newuser", password="pass123") + assert result["success"] is True + + async def test_add_with_role(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"addUser": {"id": "u:3", "name": "admin2", "role": "ADMIN"}} + tool_fn = _make_tool() + result = await tool_fn(action="add", name="admin2", password="pass123", role="admin") + assert result["success"] is True + call_args = _mock_graphql.call_args + assert call_args[0][1]["input"]["role"] == "ADMIN" + + async def test_delete(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"deleteUser": True} + tool_fn = _make_tool() + result = await tool_fn(action="delete", user_id="u:2", confirm=True) + assert result["success"] is True + + async def test_cloud(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"cloud": {"status": "connected", "apiKey": "***"}} + tool_fn = _make_tool() + result = await tool_fn(action="cloud") + assert result["status"] == "connected" + + async def test_remote_access(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"remoteAccess": {"enabled": True, "url": "https://example.com"}} + tool_fn = _make_tool() + result = await tool_fn(action="remote_access") + assert result["enabled"] is True + + async def test_origins(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"allowedOrigins": ["http://localhost", "https://example.com"]} + tool_fn = _make_tool() + result = await tool_fn(action="origins") + assert len(result["origins"]) == 2 diff --git a/tests/test_vm.py b/tests/test_vm.py new file mode 100644 index 0000000..d835c4a --- /dev/null +++ b/tests/test_vm.py @@ -0,0 +1,109 @@ +"""Tests for unraid_vm tool.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from conftest import make_tool_fn + +from unraid_mcp.core.exceptions import ToolError + + +@pytest.fixture +def _mock_graphql() -> AsyncMock: + with patch("unraid_mcp.tools.virtualization.make_graphql_request", new_callable=AsyncMock) as mock: + yield mock + + +def _make_tool(): + return make_tool_fn("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm") + + +class TestVmValidation: + async def test_actions_except_list_require_vm_id(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + for action in ("details", "start", "stop", "pause", "resume", "reboot"): + with pytest.raises(ToolError, match="vm_id"): + await tool_fn(action=action) + + async def test_destructive_actions_require_confirm(self, _mock_graphql: AsyncMock) -> None: + tool_fn = _make_tool() + for action in ("force_stop", "reset"): + with pytest.raises(ToolError, match="destructive"): + 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.""" + tool_fn = _make_tool() + with pytest.raises(ToolError, match="destructive"): + await tool_fn(action="force_stop") + + +class TestVmActions: + async def test_list(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "vms": { + "domains": [ + {"id": "vm:1", "name": "Windows 11", "state": "RUNNING", "uuid": "uuid-1"}, + ] + } + } + tool_fn = _make_tool() + result = await tool_fn(action="list") + assert len(result["vms"]) == 1 + assert result["vms"][0]["name"] == "Windows 11" + + async def test_list_empty(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"vms": {"domains": []}} + tool_fn = _make_tool() + result = await tool_fn(action="list") + assert result["vms"] == [] + + async def test_list_no_vms_key(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {} + tool_fn = _make_tool() + result = await tool_fn(action="list") + assert result["vms"] == [] + + async def test_details_by_uuid(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "vms": {"domains": [{"id": "vm:1", "name": "Win11", "state": "RUNNING", "uuid": "uuid-1"}]} + } + tool_fn = _make_tool() + result = await tool_fn(action="details", vm_id="uuid-1") + assert result["name"] == "Win11" + + async def test_details_by_name(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "vms": {"domains": [{"id": "vm:1", "name": "Win11", "state": "RUNNING", "uuid": "uuid-1"}]} + } + tool_fn = _make_tool() + result = await tool_fn(action="details", vm_id="Win11") + assert result["uuid"] == "uuid-1" + + async def test_details_not_found(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = { + "vms": {"domains": [{"id": "vm:1", "name": "Win11", "state": "RUNNING", "uuid": "uuid-1"}]} + } + tool_fn = _make_tool() + with pytest.raises(ToolError, match="not found"): + await tool_fn(action="details", vm_id="nonexistent") + + async def test_start_vm(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"vm": {"start": True}} + tool_fn = _make_tool() + result = await tool_fn(action="start", vm_id="uuid-1") + assert result["success"] is True + assert result["action"] == "start" + + async def test_force_stop(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"vm": {"forceStop": True}} + tool_fn = _make_tool() + result = await tool_fn(action="force_stop", vm_id="uuid-1", confirm=True) + assert result["success"] is True + assert result["action"] == "force_stop" + + async def test_mutation_unexpected_response(self, _mock_graphql: AsyncMock) -> None: + _mock_graphql.return_value = {"vm": {}} + tool_fn = _make_tool() + with pytest.raises(ToolError, match="Failed to start"): + await tool_fn(action="start", vm_id="uuid-1") diff --git a/unraid_mcp/config/logging.py b/unraid_mcp/config/logging.py index 2af5dc3..4861abc 100644 --- a/unraid_mcp/config/logging.py +++ b/unraid_mcp/config/logging.py @@ -30,10 +30,10 @@ console = Console(stderr=True, force_terminal=True) class OverwriteFileHandler(logging.FileHandler): """Custom file handler that overwrites the log file when it reaches max size.""" - + def __init__(self, filename, max_bytes=10*1024*1024, mode='a', encoding=None, delay=False): """Initialize the handler. - + Args: filename: Path to the log file max_bytes: Maximum file size in bytes before overwriting (default: 10MB) @@ -43,7 +43,7 @@ class OverwriteFileHandler(logging.FileHandler): """ self.max_bytes = max_bytes super().__init__(filename, mode, encoding, delay) - + def emit(self, record): """Emit a record, checking file size and overwriting if needed.""" # Check file size before writing @@ -56,14 +56,14 @@ class OverwriteFileHandler(logging.FileHandler): if self.stream: self.stream.close() self.stream = None - + # Remove the old file and start fresh if os.path.exists(self.baseFilename): os.remove(self.baseFilename) - + # Reopen with truncate mode self.stream = self._open() - + # Log a marker that the file was reset reset_record = logging.LogRecord( name="UnraidMCPServer.Logging", @@ -75,11 +75,11 @@ class OverwriteFileHandler(logging.FileHandler): exc_info=None ) super().emit(reset_record) - - except (OSError, IOError): + + except OSError: # If there's an issue checking file size, just continue normally pass - + # Emit the original record super().emit(record) diff --git a/unraid_mcp/config/settings.py b/unraid_mcp/config/settings.py index e1ea719..311b4ff 100644 --- a/unraid_mcp/config/settings.py +++ b/unraid_mcp/config/settings.py @@ -29,6 +29,9 @@ for dotenv_path in dotenv_paths: load_dotenv(dotenv_path=dotenv_path) break +# Application Version +VERSION = "0.2.0" + # Core API Configuration UNRAID_API_URL = os.getenv("UNRAID_API_URL") UNRAID_API_KEY = os.getenv("UNRAID_API_KEY") diff --git a/unraid_mcp/core/client.py b/unraid_mcp/core/client.py index 243d816..0e8ec1e 100644 --- a/unraid_mcp/core/client.py +++ b/unraid_mcp/core/client.py @@ -4,19 +4,30 @@ This module provides the HTTP client interface for making GraphQL requests to the Unraid API with proper timeout handling and error management. """ +import asyncio import json from typing import Any import httpx from ..config.logging import logger -from ..config.settings import TIMEOUT_CONFIG, UNRAID_API_KEY, UNRAID_API_URL, UNRAID_VERIFY_SSL +from ..config.settings import ( + TIMEOUT_CONFIG, + UNRAID_API_KEY, + UNRAID_API_URL, + UNRAID_VERIFY_SSL, + VERSION, +) from ..core.exceptions import ToolError # HTTP timeout configuration DEFAULT_TIMEOUT = httpx.Timeout(10.0, read=30.0, connect=5.0) DISK_TIMEOUT = httpx.Timeout(10.0, read=TIMEOUT_CONFIG['disk_operations'], connect=5.0) +# Global connection pool (module-level singleton) +_http_client: httpx.AsyncClient | None = None +_client_lock = asyncio.Lock() + def is_idempotent_error(error_message: str, operation: str) -> bool: """Check if a GraphQL error represents an idempotent operation that should be treated as success. @@ -48,6 +59,49 @@ def is_idempotent_error(error_message: str, operation: str) -> bool: return False +async def get_http_client() -> httpx.AsyncClient: + """Get or create shared HTTP client with connection pooling. + + Returns: + Singleton AsyncClient instance with connection pooling enabled + """ + global _http_client + + async with _client_lock: + if _http_client is None or _http_client.is_closed: + _http_client = httpx.AsyncClient( + # Connection pool settings + limits=httpx.Limits( + max_keepalive_connections=20, + max_connections=100, + keepalive_expiry=30.0 + ), + # Default timeout (can be overridden per-request) + timeout=DEFAULT_TIMEOUT, + # SSL verification + verify=UNRAID_VERIFY_SSL, + # Connection pooling headers + headers={ + "Connection": "keep-alive", + "User-Agent": f"UnraidMCPServer/{VERSION}" + } + ) + logger.info("Created shared HTTP client with connection pooling (20 keepalive, 100 max connections)") + + return _http_client + + +async def close_http_client() -> None: + """Close the shared HTTP client (call on server shutdown).""" + global _http_client + + async with _client_lock: + if _http_client is not None: + await _http_client.aclose() + _http_client = None + logger.info("Closed shared HTTP client") + + async def make_graphql_request( query: str, variables: dict[str, Any] | None = None, @@ -78,7 +132,7 @@ async def make_graphql_request( headers = { "Content-Type": "application/json", "X-API-Key": UNRAID_API_KEY, - "User-Agent": "UnraidMCPServer/0.1.0" # Custom user-agent + "User-Agent": f"UnraidMCPServer/{VERSION}" # Custom user-agent } payload: dict[str, Any] = {"query": query} @@ -88,39 +142,54 @@ async def make_graphql_request( logger.debug(f"Making GraphQL request to {UNRAID_API_URL}:") logger.debug(f"Query: {query[:200]}{'...' if len(query) > 200 else ''}") # Log truncated query if variables: - logger.debug(f"Variables: {variables}") - - current_timeout = custom_timeout if custom_timeout is not None else DEFAULT_TIMEOUT + _SENSITIVE_KEYS = {"password", "key", "secret", "token", "apiKey"} + redacted = { + k: ("***" if k.lower() in _SENSITIVE_KEYS else v) + for k, v in (variables.get("input", variables) if isinstance(variables.get("input"), dict) else variables).items() + } + logger.debug(f"Variables: {redacted}") try: - async with httpx.AsyncClient(timeout=current_timeout, verify=UNRAID_VERIFY_SSL) as client: + # Get the shared HTTP client with connection pooling + client = await get_http_client() + + # Override timeout if custom timeout specified + if custom_timeout is not None: + response = await client.post( + UNRAID_API_URL, + json=payload, + headers=headers, + timeout=custom_timeout + ) + else: response = await client.post(UNRAID_API_URL, json=payload, headers=headers) - response.raise_for_status() # Raise an exception for HTTP error codes 4xx/5xx - response_data = response.json() - if "errors" in response_data and response_data["errors"]: - error_details = "; ".join([err.get("message", str(err)) for err in response_data["errors"]]) + response.raise_for_status() # Raise an exception for HTTP error codes 4xx/5xx - # Check if this is an idempotent error that should be treated as success - if operation_context and operation_context.get('operation'): - operation = operation_context['operation'] - if is_idempotent_error(error_details, operation): - logger.warning(f"Idempotent operation '{operation}' - treating as success: {error_details}") - # Return a success response with the current state information - return { - "idempotent_success": True, - "operation": operation, - "message": error_details, - "original_errors": response_data["errors"] - } + response_data = response.json() + if "errors" in response_data and response_data["errors"]: + error_details = "; ".join([err.get("message", str(err)) for err in response_data["errors"]]) - logger.error(f"GraphQL API returned errors: {response_data['errors']}") - # Use ToolError for GraphQL errors to provide better feedback to LLM - raise ToolError(f"GraphQL API error: {error_details}") + # Check if this is an idempotent error that should be treated as success + if operation_context and operation_context.get('operation'): + operation = operation_context['operation'] + if is_idempotent_error(error_details, operation): + logger.warning(f"Idempotent operation '{operation}' - treating as success: {error_details}") + # Return a success response with the current state information + return { + "idempotent_success": True, + "operation": operation, + "message": error_details, + "original_errors": response_data["errors"] + } - logger.debug("GraphQL request successful.") - data = response_data.get("data", {}) - return data if isinstance(data, dict) else {} # Ensure we return dict + logger.error(f"GraphQL API returned errors: {response_data['errors']}") + # Use ToolError for GraphQL errors to provide better feedback to LLM + raise ToolError(f"GraphQL API error: {error_details}") + + logger.debug("GraphQL request successful.") + data = response_data.get("data", {}) + return data if isinstance(data, dict) else {} # Ensure we return dict except httpx.HTTPStatusError as e: logger.error(f"HTTP error occurred: {e.response.status_code} - {e.response.text}") diff --git a/unraid_mcp/main.py b/unraid_mcp/main.py index e08d4cb..3d4271f 100644 --- a/unraid_mcp/main.py +++ b/unraid_mcp/main.py @@ -5,6 +5,17 @@ This is the main entry point for the Unraid MCP Server. It imports and starts the modular server implementation from unraid_mcp.server. """ +import asyncio + + +async def shutdown_cleanup() -> None: + """Cleanup resources on server shutdown.""" + try: + from .core.client import close_http_client + await close_http_client() + except Exception as e: + print(f"Error during cleanup: {e}") + def main() -> None: """Main entry point for the Unraid MCP Server.""" @@ -13,8 +24,18 @@ def main() -> None: run_server() except KeyboardInterrupt: print("\nServer stopped by user") + try: + asyncio.run(shutdown_cleanup()) + except RuntimeError: + # Event loop already closed, skip cleanup + pass except Exception as e: print(f"Server failed to start: {e}") + try: + asyncio.run(shutdown_cleanup()) + except RuntimeError: + # Event loop already closed, skip cleanup + pass raise diff --git a/unraid_mcp/server.py b/unraid_mcp/server.py index ac98a53..2564fa1 100644 --- a/unraid_mcp/server.py +++ b/unraid_mcp/server.py @@ -15,38 +15,29 @@ from .config.settings import ( UNRAID_MCP_HOST, UNRAID_MCP_PORT, UNRAID_MCP_TRANSPORT, + VERSION, ) -from .subscriptions.diagnostics import register_diagnostic_tools -from .subscriptions.manager import SubscriptionManager from .subscriptions.resources import register_subscription_resources -from .tools.docker import register_docker_tools -from .tools.health import register_health_tools -from .tools.rclone import register_rclone_tools -from .tools.storage import register_storage_tools -from .tools.system import register_system_tools -from .tools.virtualization import register_vm_tools +from .tools.array import register_array_tool +from .tools.docker import register_docker_tool +from .tools.health import register_health_tool +from .tools.info import register_info_tool +from .tools.keys import register_keys_tool +from .tools.notifications import register_notifications_tool +from .tools.rclone import register_rclone_tool +from .tools.storage import register_storage_tool +from .tools.users import register_users_tool +from .tools.virtualization import register_vm_tool # Initialize FastMCP instance mcp = FastMCP( name="Unraid MCP Server", instructions="Provides tools to interact with an Unraid server's GraphQL API.", - version="0.1.0", + version=VERSION, ) -# Initialize subscription manager -subscription_manager = SubscriptionManager() - - -async def autostart_subscriptions() -> None: - """Auto-start all subscriptions marked for auto-start in SubscriptionManager""" - logger.info("[AUTOSTART] Initiating subscription auto-start process...") - - try: - # Use the SubscriptionManager auto-start method - await subscription_manager.auto_start_all_subscriptions() - logger.info("[AUTOSTART] Auto-start process completed successfully") - except Exception as e: - logger.error(f"[AUTOSTART] Failed during auto-start process: {e}", exc_info=True) +# Note: SubscriptionManager singleton is defined in subscriptions/manager.py +# and imported by resources.py - no duplicate instance needed here def register_all_modules() -> None: @@ -54,35 +45,24 @@ def register_all_modules() -> None: try: # Register subscription resources first register_subscription_resources(mcp) - logger.info("📊 Subscription resources registered") + logger.info("Subscription resources registered") - # Register diagnostic tools - register_diagnostic_tools(mcp) - logger.info("🔧 Diagnostic tools registered") + # Register all 10 consolidated tools + register_info_tool(mcp) + register_array_tool(mcp) + register_storage_tool(mcp) + register_docker_tool(mcp) + register_vm_tool(mcp) + register_notifications_tool(mcp) + register_rclone_tool(mcp) + register_users_tool(mcp) + register_keys_tool(mcp) + register_health_tool(mcp) - # Register all tool categories - register_system_tools(mcp) - logger.info("🖥️ System tools registered") - - register_docker_tools(mcp) - logger.info("🐳 Docker tools registered") - - register_vm_tools(mcp) - logger.info("💻 Virtualization tools registered") - - register_storage_tools(mcp) - logger.info("💾 Storage tools registered") - - register_health_tools(mcp) - logger.info("🏥 Health tools registered") - - register_rclone_tools(mcp) - logger.info("☁️ RClone tools registered") - - logger.info("🎯 All modules registered successfully - Server ready!") + logger.info("All 10 tools registered successfully - Server ready!") except Exception as e: - logger.error(f"❌ Failed to register modules: {e}", exc_info=True) + logger.error(f"Failed to register modules: {e}", exc_info=True) raise @@ -106,34 +86,31 @@ def run_server() -> None: # Register all modules register_all_modules() - logger.info(f"🚀 Starting Unraid MCP Server on {UNRAID_MCP_HOST}:{UNRAID_MCP_PORT} using {UNRAID_MCP_TRANSPORT} transport...") + logger.info(f"Starting Unraid MCP Server on {UNRAID_MCP_HOST}:{UNRAID_MCP_PORT} using {UNRAID_MCP_TRANSPORT} transport...") try: - # Auto-start subscriptions on first async operation if UNRAID_MCP_TRANSPORT == "streamable-http": - # Use the recommended Streamable HTTP transport mcp.run( transport="streamable-http", host=UNRAID_MCP_HOST, port=UNRAID_MCP_PORT, - path="/mcp" # Standard path for MCP + path="/mcp" ) elif UNRAID_MCP_TRANSPORT == "sse": - # Deprecated SSE transport - log warning - logger.warning("SSE transport is deprecated and may be removed in a future version. Consider switching to 'streamable-http'.") + logger.warning("SSE transport is deprecated. Consider switching to 'streamable-http'.") mcp.run( transport="sse", host=UNRAID_MCP_HOST, port=UNRAID_MCP_PORT, - path="/mcp" # Keep custom path for SSE + path="/mcp" ) elif UNRAID_MCP_TRANSPORT == "stdio": - mcp.run() # Defaults to stdio + mcp.run() else: - logger.error(f"Unsupported MCP_TRANSPORT: {UNRAID_MCP_TRANSPORT}. Choose 'streamable-http' (recommended), 'sse' (deprecated), or 'stdio'.") + logger.error(f"Unsupported MCP_TRANSPORT: {UNRAID_MCP_TRANSPORT}. Choose 'streamable-http', 'sse', or 'stdio'.") sys.exit(1) except Exception as e: - logger.critical(f"❌ Failed to start Unraid MCP server: {e}", exc_info=True) + logger.critical(f"Failed to start Unraid MCP server: {e}", exc_info=True) sys.exit(1) diff --git a/unraid_mcp/subscriptions/diagnostics.py b/unraid_mcp/subscriptions/diagnostics.py index 3e6f48c..9f90a05 100644 --- a/unraid_mcp/subscriptions/diagnostics.py +++ b/unraid_mcp/subscriptions/diagnostics.py @@ -57,10 +57,14 @@ def register_diagnostic_tools(mcp: FastMCP) -> None: ping_timeout=10 ) as websocket: - # Send connection init + # Send connection init (using standard X-API-Key format) await websocket.send(json.dumps({ "type": "connection_init", - "payload": {"Authorization": f"Bearer {UNRAID_API_KEY}"} + "payload": { + "headers": { + "X-API-Key": UNRAID_API_KEY + } + } })) # Wait for ack diff --git a/unraid_mcp/subscriptions/manager.py b/unraid_mcp/subscriptions/manager.py index 88eff21..4825d9a 100644 --- a/unraid_mcp/subscriptions/manager.py +++ b/unraid_mcp/subscriptions/manager.py @@ -15,7 +15,7 @@ import websockets from websockets.legacy.protocol import Subprotocol from ..config.logging import logger -from ..config.settings import UNRAID_API_KEY, UNRAID_API_URL +from ..config.settings import UNRAID_API_KEY, UNRAID_API_URL, UNRAID_VERIFY_SSL from ..core.types import SubscriptionData @@ -162,7 +162,8 @@ class SubscriptionManager: subprotocols=[Subprotocol("graphql-transport-ws"), Subprotocol("graphql-ws")], ping_interval=20, ping_timeout=10, - close_timeout=10 + close_timeout=10, + ssl=UNRAID_VERIFY_SSL ) as websocket: selected_proto = websocket.subprotocol or "none" @@ -180,15 +181,10 @@ class SubscriptionManager: if UNRAID_API_KEY: logger.debug(f"[AUTH:{subscription_name}] Adding authentication payload") + # Use standard X-API-Key header format (matching HTTP client) auth_payload = { - "X-API-Key": UNRAID_API_KEY, - "x-api-key": UNRAID_API_KEY, - "authorization": f"Bearer {UNRAID_API_KEY}", - "Authorization": f"Bearer {UNRAID_API_KEY}", "headers": { - "X-API-Key": UNRAID_API_KEY, - "x-api-key": UNRAID_API_KEY, - "Authorization": f"Bearer {UNRAID_API_KEY}" + "X-API-Key": UNRAID_API_KEY } } init_payload["payload"] = auth_payload diff --git a/unraid_mcp/tools/__init__.py b/unraid_mcp/tools/__init__.py index 81ba93d..c863e40 100644 --- a/unraid_mcp/tools/__init__.py +++ b/unraid_mcp/tools/__init__.py @@ -1 +1,14 @@ -"""MCP tools organized by functional domain.""" +"""MCP tools organized by functional domain. + +10 consolidated tools with ~90 actions total: + unraid_info - System information queries (19 actions) + unraid_array - Array operations and power management (12 actions) + unraid_storage - Storage, disks, and logs (6 actions) + unraid_docker - Docker container management (15 actions) + unraid_vm - Virtual machine management (9 actions) + unraid_notifications - Notification management (9 actions) + unraid_rclone - Cloud storage remotes (4 actions) + unraid_users - User management (8 actions) + unraid_keys - API key management (5 actions) + unraid_health - Health monitoring and diagnostics (3 actions) +""" diff --git a/unraid_mcp/tools/array.py b/unraid_mcp/tools/array.py new file mode 100644 index 0000000..0359273 --- /dev/null +++ b/unraid_mcp/tools/array.py @@ -0,0 +1,161 @@ +"""Array operations and system power management. + +Provides the `unraid_array` tool with 12 actions for array lifecycle, +parity operations, disk management, and system power control. +""" + +from typing import Any, Literal + +from fastmcp import FastMCP + +from ..config.logging import logger +from ..core.client import make_graphql_request +from ..core.exceptions import ToolError + +QUERIES: dict[str, str] = { + "parity_history": """ + query GetParityHistory { + array { parityCheckStatus { progress speed errors } } + } + """, +} + +MUTATIONS: dict[str, str] = { + "start": """ + mutation StartArray { + setState(input: { desiredState: STARTED }) { state } + } + """, + "stop": """ + mutation StopArray { + setState(input: { desiredState: STOPPED }) { state } + } + """, + "parity_start": """ + mutation StartParityCheck($correct: Boolean) { + parityCheck { start(correct: $correct) } + } + """, + "parity_pause": """ + mutation PauseParityCheck { + parityCheck { pause } + } + """, + "parity_resume": """ + mutation ResumeParityCheck { + parityCheck { resume } + } + """, + "parity_cancel": """ + mutation CancelParityCheck { + parityCheck { cancel } + } + """, + "mount_disk": """ + mutation MountDisk($id: PrefixedID!) { + mountArrayDisk(id: $id) + } + """, + "unmount_disk": """ + mutation UnmountDisk($id: PrefixedID!) { + unmountArrayDisk(id: $id) + } + """, + "clear_stats": """ + mutation ClearStats($id: PrefixedID!) { + clearArrayDiskStatistics(id: $id) + } + """, + "shutdown": """ + mutation Shutdown { + shutdown + } + """, + "reboot": """ + mutation Reboot { + reboot + } + """, +} + +DESTRUCTIVE_ACTIONS = {"start", "stop", "shutdown", "reboot"} +DISK_ACTIONS = {"mount_disk", "unmount_disk", "clear_stats"} + +ARRAY_ACTIONS = Literal[ + "start", "stop", + "parity_start", "parity_pause", "parity_resume", "parity_cancel", "parity_history", + "mount_disk", "unmount_disk", "clear_stats", + "shutdown", "reboot", +] + + +def register_array_tool(mcp: FastMCP) -> None: + """Register the unraid_array tool with the FastMCP instance.""" + + @mcp.tool() + async def unraid_array( + action: ARRAY_ACTIONS, + confirm: bool = False, + disk_id: str | None = None, + correct: bool | None = None, + ) -> dict[str, Any]: + """Manage the Unraid array and system power. + + Actions: + start - Start the array (destructive, requires confirm=True) + stop - Stop the array (destructive, requires confirm=True) + parity_start - Start parity check (optional correct=True to fix errors) + parity_pause - Pause running parity check + parity_resume - Resume paused parity check + parity_cancel - Cancel running parity check + parity_history - Get parity check status/history + mount_disk - Mount an array disk (requires disk_id) + unmount_disk - Unmount an array disk (requires disk_id) + clear_stats - Clear disk statistics (requires disk_id) + shutdown - Shut down the server (destructive, requires confirm=True) + reboot - Reboot the server (destructive, requires confirm=True) + """ + all_actions = set(QUERIES) | set(MUTATIONS) + if action not in all_actions: + raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}") + + if action in DESTRUCTIVE_ACTIONS and not confirm: + raise ToolError( + f"Action '{action}' is destructive. Set confirm=True to proceed." + ) + + if action in DISK_ACTIONS and not disk_id: + raise ToolError(f"disk_id is required for '{action}' action") + + try: + logger.info(f"Executing unraid_array action={action}") + + # Read-only query + if action in QUERIES: + data = await make_graphql_request(QUERIES[action]) + return {"success": True, "action": action, "data": data} + + # Mutations + query = MUTATIONS[action] + variables: dict[str, Any] | None = None + + if action in DISK_ACTIONS: + variables = {"id": disk_id} + elif action == "parity_start" and correct is not None: + variables = {"correct": correct} + + data = await make_graphql_request(query, variables) + + return { + "success": True, + "action": action, + "data": data, + } + + except ToolError: + raise + except Exception as e: + logger.error(f"Error in unraid_array action={action}: {e}", exc_info=True) + raise ToolError(f"Failed to execute array/{action}: {str(e)}") from e + + logger.info("Array tool registered successfully") diff --git a/unraid_mcp/tools/docker.py b/unraid_mcp/tools/docker.py index 589f922..63d6e7c 100644 --- a/unraid_mcp/tools/docker.py +++ b/unraid_mcp/tools/docker.py @@ -1,11 +1,11 @@ -"""Docker container management tools. +"""Docker container management. -This module provides tools for Docker container lifecycle and management -including listing containers with caching options, start/stop operations, -and detailed container information retrieval. +Provides the `unraid_docker` tool with 15 actions for container lifecycle, +logs, networks, and update management. """ -from typing import Any +import re +from typing import Any, Literal from fastmcp import FastMCP @@ -13,376 +13,311 @@ from ..config.logging import logger from ..core.client import make_graphql_request from ..core.exceptions import ToolError +QUERIES: dict[str, str] = { + "list": """ + query ListDockerContainers { + docker { containers(skipCache: false) { + id names image state status autoStart + } } + } + """, + "details": """ + query GetContainerDetails { + docker { containers(skipCache: false) { + id names image imageId command created + ports { ip privatePort publicPort type } + sizeRootFs labels state status + hostConfig { networkMode } + networkSettings mounts autoStart + } } + } + """, + "logs": """ + query GetContainerLogs($id: PrefixedID!, $tail: Int) { + docker { logs(id: $id, tail: $tail) } + } + """, + "networks": """ + query GetDockerNetworks { + dockerNetworks { id name driver scope } + } + """, + "network_details": """ + query GetDockerNetwork($id: PrefixedID!) { + dockerNetwork(id: $id) { id name driver scope containers } + } + """, + "port_conflicts": """ + query GetPortConflicts { + docker { portConflicts { containerName port conflictsWith } } + } + """, + "check_updates": """ + query CheckContainerUpdates { + docker { containerUpdateStatuses { id name updateAvailable currentVersion latestVersion } } + } + """, +} -def find_container_by_identifier(container_identifier: str, containers: list[dict[str, Any]]) -> dict[str, Any] | None: - """Find a container by ID or name with fuzzy matching. +MUTATIONS: dict[str, str] = { + "start": """ + mutation StartContainer($id: PrefixedID!) { + docker { start(id: $id) { id names state status } } + } + """, + "stop": """ + mutation StopContainer($id: PrefixedID!) { + docker { stop(id: $id) { id names state status } } + } + """, + "pause": """ + mutation PauseContainer($id: PrefixedID!) { + docker { pause(id: $id) { id names state status } } + } + """, + "unpause": """ + mutation UnpauseContainer($id: PrefixedID!) { + docker { unpause(id: $id) { id names state status } } + } + """, + "remove": """ + mutation RemoveContainer($id: PrefixedID!) { + docker { removeContainer(id: $id) } + } + """, + "update": """ + mutation UpdateContainer($id: PrefixedID!) { + docker { updateContainer(id: $id) { id names state status } } + } + """, + "update_all": """ + mutation UpdateAllContainers { + docker { updateAllContainers { id names state status } } + } + """, +} - Args: - container_identifier: Container ID or name to find - containers: List of container dictionaries to search +DESTRUCTIVE_ACTIONS = {"remove"} +CONTAINER_ACTIONS = {"start", "stop", "restart", "pause", "unpause", "remove", "update", "details", "logs"} - Returns: - Container dictionary if found, None otherwise - """ +DOCKER_ACTIONS = Literal[ + "list", "details", "start", "stop", "restart", "pause", "unpause", + "remove", "update", "update_all", "logs", + "networks", "network_details", "port_conflicts", "check_updates", +] + +# Docker container IDs: 64 hex chars + optional suffix (e.g., ":local") +_DOCKER_ID_PATTERN = re.compile(r"^[a-f0-9]{64}(:[a-z0-9]+)?$", re.IGNORECASE) + + +def find_container_by_identifier( + identifier: str, containers: list[dict[str, Any]] +) -> dict[str, Any] | None: + """Find a container by ID or name with fuzzy matching.""" if not containers: return None - # Exact matches first - for container in containers: - if container.get("id") == container_identifier: - return container + for c in containers: + if c.get("id") == identifier: + return c + if identifier in c.get("names", []): + return c - # Check all names for exact match - names = container.get("names", []) - if container_identifier in names: - return container - - # Fuzzy matching - case insensitive partial matches - container_identifier_lower = container_identifier.lower() - for container in containers: - names = container.get("names", []) - for name in names: - if container_identifier_lower in name.lower() or name.lower() in container_identifier_lower: - logger.info(f"Found container via fuzzy match: '{container_identifier}' -> '{name}'") - return container + id_lower = identifier.lower() + for c in containers: + for name in c.get("names", []): + if id_lower in name.lower() or name.lower() in id_lower: + logger.info(f"Fuzzy match: '{identifier}' -> '{name}'") + return c return None def get_available_container_names(containers: list[dict[str, Any]]) -> list[str]: - """Extract all available container names for error reporting. - - Args: - containers: List of container dictionaries - - Returns: - List of container names - """ - names = [] - for container in containers: - container_names = container.get("names", []) - names.extend(container_names) + """Extract all container names for error messages.""" + names: list[str] = [] + for c in containers: + names.extend(c.get("names", [])) return names -def register_docker_tools(mcp: FastMCP) -> None: - """Register all Docker tools with the FastMCP instance. +async def _resolve_container_id(container_id: str) -> str: + """Resolve a container name/identifier to its actual PrefixedID.""" + if _DOCKER_ID_PATTERN.match(container_id): + return container_id - Args: - mcp: FastMCP instance to register tools with - """ - - @mcp.tool() - async def list_docker_containers() -> list[dict[str, Any]]: - """Lists all Docker containers on the Unraid system. - - Returns: - List of Docker container information dictionaries - """ - query = """ - query ListDockerContainers { - docker { - containers(skipCache: false) { - id - names - image - state - status - autoStart - } - } + logger.info(f"Resolving container identifier '{container_id}'") + list_query = """ + query ResolveContainerID { + docker { containers(skipCache: true) { id names } } } - """ - try: - logger.info("Executing list_docker_containers tool") - response_data = await make_graphql_request(query) - if response_data.get("docker"): - containers = response_data["docker"].get("containers", []) - return list(containers) if isinstance(containers, list) else [] - return [] - except Exception as e: - logger.error(f"Error in list_docker_containers: {e}", exc_info=True) - raise ToolError(f"Failed to list Docker containers: {str(e)}") from e + """ + data = await make_graphql_request(list_query) + containers = data.get("docker", {}).get("containers", []) + resolved = find_container_by_identifier(container_id, containers) + if resolved: + actual_id = str(resolved.get("id", "")) + logger.info(f"Resolved '{container_id}' -> '{actual_id}'") + return actual_id + + available = get_available_container_names(containers) + msg = f"Container '{container_id}' not found." + if available: + msg += f" Available: {', '.join(available[:10])}" + raise ToolError(msg) + + +def register_docker_tool(mcp: FastMCP) -> None: + """Register the unraid_docker tool with the FastMCP instance.""" @mcp.tool() - async def manage_docker_container(container_id: str, action: str) -> dict[str, Any]: - """Starts or stops a specific Docker container. Action must be 'start' or 'stop'. + async def unraid_docker( + action: DOCKER_ACTIONS, + container_id: str | None = None, + network_id: str | None = None, + confirm: bool = False, + tail_lines: int = 100, + ) -> dict[str, Any]: + """Manage Docker containers, networks, and updates. - Args: - container_id: Container ID to manage - action: Action to perform - 'start' or 'stop' - - Returns: - Dict containing operation result and container information + Actions: + list - List all containers + details - Detailed info for a container (requires container_id) + start - Start a container (requires container_id) + stop - Stop a container (requires container_id) + restart - Stop then start a container (requires container_id) + pause - Pause a container (requires container_id) + unpause - Unpause a container (requires container_id) + remove - Remove a container (requires container_id, confirm=True) + update - Update a container to latest image (requires container_id) + update_all - Update all containers with available updates + logs - Get container logs (requires container_id, optional tail_lines) + networks - List Docker networks + network_details - Details of a network (requires network_id) + port_conflicts - Check for port conflicts + check_updates - Check which containers have updates available """ - import asyncio + all_actions = set(QUERIES) | set(MUTATIONS) | {"restart"} + if action not in all_actions: + raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}") - if action.lower() not in ["start", "stop"]: - logger.warning(f"Invalid action '{action}' for manage_docker_container") - raise ToolError("Invalid action. Must be 'start' or 'stop'.") + if action in DESTRUCTIVE_ACTIONS and not confirm: + raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.") - mutation_name = action.lower() + if action in CONTAINER_ACTIONS and not container_id: + raise ToolError(f"container_id is required for '{action}' action") - # Step 1: Execute the operation mutation - operation_query = f""" - mutation ManageDockerContainer($id: PrefixedID!) {{ - docker {{ - {mutation_name}(id: $id) {{ - id - names - state - status - }} - }} - }} - """ - - variables = {"id": container_id} + if action == "network_details" and not network_id: + raise ToolError("network_id is required for 'network_details' action") try: - logger.info(f"Executing manage_docker_container: action={action}, id={container_id}") + logger.info(f"Executing unraid_docker action={action}") - # Step 1: Resolve container identifier to actual container ID if needed - actual_container_id = container_id - if not container_id.startswith("3cb1026338736ed07b8afec2c484e429710b0f6550dc65d0c5c410ea9d0fa6b2:"): - # This looks like a name, not a full container ID - need to resolve it - logger.info(f"Resolving container identifier '{container_id}' to actual container ID") - list_query = """ - query ResolveContainerID { - docker { - containers(skipCache: true) { - id - names - } - } + # --- Read-only queries --- + if action == "list": + data = await make_graphql_request(QUERIES["list"]) + containers = data.get("docker", {}).get("containers", []) + return {"containers": list(containers) if isinstance(containers, list) else []} + + if action == "details": + data = await make_graphql_request(QUERIES["details"]) + containers = data.get("docker", {}).get("containers", []) + container = find_container_by_identifier(container_id or "", containers) + if container: + return container + available = get_available_container_names(containers) + msg = f"Container '{container_id}' not found." + if available: + msg += f" Available: {', '.join(available[:10])}" + raise ToolError(msg) + + if action == "logs": + actual_id = await _resolve_container_id(container_id or "") + data = await make_graphql_request( + QUERIES["logs"], {"id": actual_id, "tail": tail_lines} + ) + return {"logs": data.get("docker", {}).get("logs")} + + if action == "networks": + data = await make_graphql_request(QUERIES["networks"]) + networks = data.get("dockerNetworks", []) + return {"networks": list(networks) if isinstance(networks, list) else []} + + if action == "network_details": + data = await make_graphql_request( + QUERIES["network_details"], {"id": network_id} + ) + return dict(data.get("dockerNetwork", {})) + + if action == "port_conflicts": + data = await make_graphql_request(QUERIES["port_conflicts"]) + conflicts = data.get("docker", {}).get("portConflicts", []) + return {"port_conflicts": list(conflicts) if isinstance(conflicts, list) else []} + + if action == "check_updates": + data = await make_graphql_request(QUERIES["check_updates"]) + statuses = data.get("docker", {}).get("containerUpdateStatuses", []) + return {"update_statuses": list(statuses) if isinstance(statuses, list) else []} + + # --- Mutations --- + if action == "restart": + actual_id = await _resolve_container_id(container_id or "") + # Stop (idempotent: treat "already stopped" as success) + stop_data = await make_graphql_request( + MUTATIONS["stop"], {"id": actual_id}, + operation_context={"operation": "stop"}, + ) + stop_was_idempotent = stop_data.get("idempotent_success", False) + # Start (idempotent: treat "already running" as success) + start_data = await make_graphql_request( + MUTATIONS["start"], {"id": actual_id}, + operation_context={"operation": "start"}, + ) + result = start_data.get("docker", {}).get("start", {}) + response: dict[str, Any] = { + "success": True, "action": "restart", "container": result, } - """ - list_response = await make_graphql_request(list_query) - if list_response.get("docker"): - containers = list_response["docker"].get("containers", []) - resolved_container = find_container_by_identifier(container_id, containers) - if resolved_container: - actual_container_id = str(resolved_container.get("id", "")) - logger.info(f"Resolved '{container_id}' to container ID: {actual_container_id}") - else: - available_names = get_available_container_names(containers) - error_msg = f"Container '{container_id}' not found for {action} operation." - if available_names: - error_msg += f" Available containers: {', '.join(available_names[:10])}" - raise ToolError(error_msg) + if stop_was_idempotent: + response["note"] = "Container was already stopped before restart" + return response - # Update variables with the actual container ID - variables = {"id": actual_container_id} + if action == "update_all": + data = await make_graphql_request(MUTATIONS["update_all"]) + results = data.get("docker", {}).get("updateAllContainers", []) + return {"success": True, "action": "update_all", "containers": results} - # Execute the operation with idempotent error handling - operation_context = {"operation": action} - operation_response = await make_graphql_request( - operation_query, - variables, - operation_context=operation_context - ) + # Single-container mutations + if action in MUTATIONS: + actual_id = await _resolve_container_id(container_id or "") + op_context = {"operation": action} if action in ("start", "stop") else None + data = await make_graphql_request( + MUTATIONS[action], {"id": actual_id}, + operation_context=op_context, + ) - # Handle idempotent success case - if operation_response.get("idempotent_success"): - logger.info(f"Container {action} operation was idempotent: {operation_response.get('message')}") - # Get current container state since the operation was already complete - try: - list_query = """ - query GetContainerStateAfterIdempotent($skipCache: Boolean!) { - docker { - containers(skipCache: $skipCache) { - id - names - image - state - status - autoStart - } - } + # Handle idempotent success + if data.get("idempotent_success"): + return { + "success": True, + "action": action, + "idempotent": True, + "message": f"Container already in desired state for '{action}'", } - """ - list_response = await make_graphql_request(list_query, {"skipCache": True}) - - if list_response.get("docker"): - containers = list_response["docker"].get("containers", []) - container = find_container_by_identifier(container_id, containers) - - if container: - return { - "operation_result": {"id": container_id, "names": container.get("names", [])}, - "container_details": container, - "success": True, - "message": f"Container {action} operation was already complete - current state returned", - "idempotent": True - } - - except Exception as lookup_error: - logger.warning(f"Could not retrieve container state after idempotent operation: {lookup_error}") + docker_data = data.get("docker", {}) + result = docker_data.get(action, docker_data.get("removeContainer")) return { - "operation_result": {"id": container_id}, - "container_details": None, "success": True, - "message": f"Container {action} operation was already complete", - "idempotent": True + "action": action, + "container": result, } - # Handle normal successful operation - if not (operation_response.get("docker") and operation_response["docker"].get(mutation_name)): - raise ToolError(f"Failed to execute {action} operation on container") - - operation_result = operation_response["docker"][mutation_name] - logger.info(f"Container {action} operation completed for {container_id}") - - # Step 2: Wait briefly for state to propagate, then fetch current container details - await asyncio.sleep(1.0) # Give the container state time to update - - # Step 3: Try to get updated container details with retry logic - max_retries = 3 - retry_delay = 1.0 - - for attempt in range(max_retries): - try: - # Query all containers and find the one we just operated on - list_query = """ - query GetUpdatedContainerState($skipCache: Boolean!) { - docker { - containers(skipCache: $skipCache) { - id - names - image - state - status - autoStart - } - } - } - """ - - # Skip cache to get fresh data - list_response = await make_graphql_request(list_query, {"skipCache": True}) - - if list_response.get("docker"): - containers = list_response["docker"].get("containers", []) - - # Find the container using our helper function - container = find_container_by_identifier(container_id, containers) - if container: - logger.info(f"Found updated container state for {container_id}") - return { - "operation_result": operation_result, - "container_details": container, - "success": True, - "message": f"Container {action} operation completed successfully" - } - - # If not found in this attempt, wait and retry - if attempt < max_retries - 1: - logger.warning(f"Container {container_id} not found after {action}, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})") - await asyncio.sleep(retry_delay) - retry_delay *= 1.5 # Exponential backoff - - except Exception as query_error: - logger.warning(f"Error querying updated container state (attempt {attempt + 1}): {query_error}") - if attempt < max_retries - 1: - await asyncio.sleep(retry_delay) - retry_delay *= 1.5 - else: - # On final attempt failure, still return operation success - logger.warning(f"Could not retrieve updated container details after {action}, but operation succeeded") - return { - "operation_result": operation_result, - "container_details": None, - "success": True, - "message": f"Container {action} operation completed, but updated state could not be retrieved", - "warning": "Container state query failed after operation - this may be due to timing or the container not being found in the updated state" - } - - # If we get here, all retries failed to find the container - logger.warning(f"Container {container_id} not found in any retry attempt after {action}") - return { - "operation_result": operation_result, - "container_details": None, - "success": True, - "message": f"Container {action} operation completed, but container not found in subsequent queries", - "warning": "Container not found in updated state - this may indicate the operation succeeded but container is no longer listed" - } + return {} + except ToolError: + raise except Exception as e: - logger.error(f"Error in manage_docker_container ({action}): {e}", exc_info=True) - raise ToolError(f"Failed to {action} Docker container: {str(e)}") from e + logger.error(f"Error in unraid_docker action={action}: {e}", exc_info=True) + raise ToolError(f"Failed to execute docker/{action}: {str(e)}") from e - @mcp.tool() - async def get_docker_container_details(container_identifier: str) -> dict[str, Any]: - """Retrieves detailed information for a specific Docker container by its ID or name. - - Args: - container_identifier: Container ID or name to retrieve details for - - Returns: - Dict containing detailed container information - """ - # This tool fetches all containers and then filters by ID or name. - # More detailed query for a single container if found: - detailed_query_fields = """ - id - names - image - imageId - command - created - ports { ip privatePort publicPort type } - sizeRootFs - labels # JSONObject - state - status - hostConfig { networkMode } - networkSettings # JSONObject - mounts # JSONObject array - autoStart - """ - - # Fetch all containers first - list_query = f""" - query GetAllContainerDetailsForFiltering {{ - docker {{ - containers(skipCache: false) {{ - {detailed_query_fields} - }} - }} - }} - """ - try: - logger.info(f"Executing get_docker_container_details for identifier: {container_identifier}") - response_data = await make_graphql_request(list_query) - - containers = [] - if response_data.get("docker"): - containers = response_data["docker"].get("containers", []) - - # Use our enhanced container lookup - container = find_container_by_identifier(container_identifier, containers) - if container: - logger.info(f"Found container {container_identifier}") - return container - - # Container not found - provide helpful error message with available containers - available_names = get_available_container_names(containers) - logger.warning(f"Container with identifier '{container_identifier}' not found.") - logger.info(f"Available containers: {available_names}") - - error_msg = f"Container '{container_identifier}' not found." - if available_names: - error_msg += f" Available containers: {', '.join(available_names[:10])}" # Limit to first 10 - if len(available_names) > 10: - error_msg += f" (and {len(available_names) - 10} more)" - else: - error_msg += " No containers are currently available." - - raise ToolError(error_msg) - - except Exception as e: - logger.error(f"Error in get_docker_container_details: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve Docker container details: {str(e)}") from e - - logger.info("Docker tools registered successfully") + logger.info("Docker tool registered successfully") diff --git a/unraid_mcp/tools/health.py b/unraid_mcp/tools/health.py index a719cd8..9b1229e 100644 --- a/unraid_mcp/tools/health.py +++ b/unraid_mcp/tools/health.py @@ -1,186 +1,264 @@ -"""Comprehensive health monitoring tools. +"""Health monitoring and diagnostics. -This module provides tools for comprehensive health checks of the Unraid MCP server -and the underlying Unraid system, including performance metrics, system status, -notifications, Docker services, and API responsiveness. +Provides the `unraid_health` tool with 3 actions for system health checks, +connection testing, and subscription diagnostics. """ import datetime import time -from typing import Any +from typing import Any, Literal from fastmcp import FastMCP from ..config.logging import logger -from ..config.settings import UNRAID_API_URL, UNRAID_MCP_HOST, UNRAID_MCP_PORT, UNRAID_MCP_TRANSPORT +from ..config.settings import ( + UNRAID_API_URL, + UNRAID_MCP_HOST, + UNRAID_MCP_PORT, + UNRAID_MCP_TRANSPORT, + VERSION, +) from ..core.client import make_graphql_request +from ..core.exceptions import ToolError + +HEALTH_ACTIONS = Literal["check", "test_connection", "diagnose"] + +# Severity ordering: only upgrade, never downgrade +_SEVERITY = {"healthy": 0, "warning": 1, "degraded": 2, "unhealthy": 3} -def register_health_tools(mcp: FastMCP) -> None: - """Register all health tools with the FastMCP instance. - - Args: - mcp: FastMCP instance to register tools with - """ +def register_health_tool(mcp: FastMCP) -> None: + """Register the unraid_health tool with the FastMCP instance.""" @mcp.tool() - async def health_check() -> dict[str, Any]: - """Returns comprehensive health status of the Unraid MCP server and system for monitoring purposes.""" - start_time = time.time() - health_status = "healthy" - issues = [] + async def unraid_health( + action: HEALTH_ACTIONS, + ) -> dict[str, Any]: + """Monitor Unraid MCP server and system health. + + Actions: + check - Comprehensive health check (API latency, array, notifications, Docker) + test_connection - Quick connectivity test (just checks { online }) + diagnose - Subscription system diagnostics + """ + if action not in ("check", "test_connection", "diagnose"): + raise ToolError( + f"Invalid action '{action}'. Must be one of: check, test_connection, diagnose" + ) try: - # Enhanced health check with multiple system components - comprehensive_query = """ - query ComprehensiveHealthCheck { - info { - machineId - time - versions { unraid } - os { uptime } - } - array { - state - } - notifications { - overview { - unread { alert warning total } - } - } - docker { - containers(skipCache: true) { - id - state - status - } - } - } - """ + logger.info(f"Executing unraid_health action={action}") - response_data = await make_graphql_request(comprehensive_query) - api_latency = round((time.time() - start_time) * 1000, 2) # ms - - # Base health info - health_info = { - "status": health_status, - "timestamp": datetime.datetime.utcnow().isoformat(), - "api_latency_ms": api_latency, - "server": { - "name": "Unraid MCP Server", - "version": "0.1.0", - "transport": UNRAID_MCP_TRANSPORT, - "host": UNRAID_MCP_HOST, - "port": UNRAID_MCP_PORT, - "process_uptime_seconds": time.time() - start_time # Rough estimate - } - } - - if not response_data: - health_status = "unhealthy" - issues.append("No response from Unraid API") - health_info["status"] = health_status - health_info["issues"] = issues - return health_info - - # System info analysis - info = response_data.get("info", {}) - if info: - health_info["unraid_system"] = { + if action == "test_connection": + start = time.time() + data = await make_graphql_request("query { online }") + latency = round((time.time() - start) * 1000, 2) + return { "status": "connected", - "url": UNRAID_API_URL, - "machine_id": info.get("machineId"), - "time": info.get("time"), - "version": info.get("versions", {}).get("unraid"), - "uptime": info.get("os", {}).get("uptime") - } - else: - health_status = "degraded" - issues.append("Unable to retrieve system info") - - # Array health analysis - array_info = response_data.get("array", {}) - if array_info: - array_state = array_info.get("state", "unknown") - health_info["array_status"] = { - "state": array_state, - "healthy": array_state in ["STARTED", "STOPPED"] - } - if array_state not in ["STARTED", "STOPPED"]: - health_status = "warning" - issues.append(f"Array in unexpected state: {array_state}") - else: - health_status = "warning" - issues.append("Unable to retrieve array status") - - # Notifications analysis - notifications = response_data.get("notifications", {}) - if notifications and notifications.get("overview"): - unread = notifications["overview"].get("unread", {}) - alert_count = unread.get("alert", 0) - warning_count = unread.get("warning", 0) - total_unread = unread.get("total", 0) - - health_info["notifications"] = { - "unread_total": total_unread, - "unread_alerts": alert_count, - "unread_warnings": warning_count, - "has_critical_notifications": alert_count > 0 + "online": data.get("online"), + "latency_ms": latency, } - if alert_count > 0: - health_status = "warning" - issues.append(f"{alert_count} unread alert notification(s)") + if action == "check": + return await _comprehensive_check() - # Docker services analysis - docker_info = response_data.get("docker", {}) - if docker_info and docker_info.get("containers"): - containers = docker_info["containers"] - running_containers = [c for c in containers if c.get("state") == "running"] - stopped_containers = [c for c in containers if c.get("state") == "exited"] + if action == "diagnose": + return await _diagnose_subscriptions() - health_info["docker_services"] = { - "total_containers": len(containers), - "running_containers": len(running_containers), - "stopped_containers": len(stopped_containers), - "containers_healthy": len([c for c in containers if c.get("status", "").startswith("Up")]) - } + return {} - # API performance assessment - if api_latency > 5000: # > 5 seconds - health_status = "warning" - issues.append(f"High API latency: {api_latency}ms") - elif api_latency > 10000: # > 10 seconds - health_status = "degraded" - issues.append(f"Very high API latency: {api_latency}ms") + except ToolError: + raise + except Exception as e: + logger.error(f"Error in unraid_health action={action}: {e}", exc_info=True) + raise ToolError(f"Failed to execute health/{action}: {str(e)}") from e - # Final status determination - health_info["status"] = health_status - if issues: - health_info["issues"] = issues + logger.info("Health tool registered successfully") - # Add performance metrics - health_info["performance"] = { - "api_response_time_ms": api_latency, - "health_check_duration_ms": round((time.time() - start_time) * 1000, 2) - } +async def _comprehensive_check() -> dict[str, Any]: + """Run comprehensive health check against the Unraid system.""" + start_time = time.time() + health_severity = 0 # Track as int to prevent downgrade + issues: list[str] = [] + + def _escalate(level: str) -> None: + nonlocal health_severity + health_severity = max(health_severity, _SEVERITY.get(level, 0)) + + try: + query = """ + query ComprehensiveHealthCheck { + info { + machineId time + versions { unraid } + os { uptime } + } + array { state } + notifications { + overview { unread { alert warning total } } + } + docker { + containers(skipCache: true) { id state status } + } + } + """ + data = await make_graphql_request(query) + api_latency = round((time.time() - start_time) * 1000, 2) + + health_info: dict[str, Any] = { + "status": "healthy", + "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "api_latency_ms": api_latency, + "server": { + "name": "Unraid MCP Server", + "version": VERSION, + "transport": UNRAID_MCP_TRANSPORT, + "host": UNRAID_MCP_HOST, + "port": UNRAID_MCP_PORT, + }, + } + + if not data: + health_info["status"] = "unhealthy" + health_info["issues"] = ["No response from Unraid API"] return health_info - except Exception as e: - logger.error(f"Health check failed: {e}") - return { - "status": "unhealthy", - "timestamp": datetime.datetime.utcnow().isoformat(), - "error": str(e), - "api_latency_ms": round((time.time() - start_time) * 1000, 2) if 'start_time' in locals() else None, - "server": { - "name": "Unraid MCP Server", - "version": "0.1.0", - "transport": UNRAID_MCP_TRANSPORT, - "host": UNRAID_MCP_HOST, - "port": UNRAID_MCP_PORT - } + # System info + info = data.get("info", {}) + if info: + health_info["unraid_system"] = { + "status": "connected", + "url": UNRAID_API_URL, + "machine_id": info.get("machineId"), + "version": info.get("versions", {}).get("unraid"), + "uptime": info.get("os", {}).get("uptime"), + } + else: + _escalate("degraded") + issues.append("Unable to retrieve system info") + + # Array + array_info = data.get("array", {}) + if array_info: + state = array_info.get("state", "unknown") + health_info["array_status"] = { + "state": state, + "healthy": state in ("STARTED", "STOPPED"), + } + if state not in ("STARTED", "STOPPED"): + _escalate("warning") + issues.append(f"Array in unexpected state: {state}") + else: + _escalate("warning") + issues.append("Unable to retrieve array status") + + # Notifications + notifications = data.get("notifications", {}) + if notifications and notifications.get("overview"): + unread = notifications["overview"].get("unread", {}) + alerts = unread.get("alert", 0) + health_info["notifications"] = { + "unread_total": unread.get("total", 0), + "unread_alerts": alerts, + "unread_warnings": unread.get("warning", 0), + } + if alerts > 0: + _escalate("warning") + issues.append(f"{alerts} unread alert(s)") + + # Docker + docker = data.get("docker", {}) + if docker and docker.get("containers"): + containers = docker["containers"] + health_info["docker_services"] = { + "total": len(containers), + "running": len([c for c in containers if c.get("state") == "running"]), + "stopped": len([c for c in containers if c.get("state") == "exited"]), } - logger.info("Health tools registered successfully") + # Latency assessment + if api_latency > 10000: + _escalate("degraded") + issues.append(f"Very high API latency: {api_latency}ms") + elif api_latency > 5000: + _escalate("warning") + issues.append(f"High API latency: {api_latency}ms") + + # Resolve final status from severity level + severity_to_status = {v: k for k, v in _SEVERITY.items()} + health_info["status"] = severity_to_status.get(health_severity, "healthy") + if issues: + health_info["issues"] = issues + health_info["performance"] = { + "api_response_time_ms": api_latency, + "check_duration_ms": round((time.time() - start_time) * 1000, 2), + } + + return health_info + + except Exception as e: + logger.error(f"Health check failed: {e}") + return { + "status": "unhealthy", + "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "error": str(e), + "server": { + "name": "Unraid MCP Server", + "version": VERSION, + "transport": UNRAID_MCP_TRANSPORT, + "host": UNRAID_MCP_HOST, + "port": UNRAID_MCP_PORT, + }, + } + + +async def _diagnose_subscriptions() -> dict[str, Any]: + """Import and run subscription diagnostics.""" + try: + from ..subscriptions.manager import subscription_manager + from ..subscriptions.resources import ensure_subscriptions_started + + await ensure_subscriptions_started() + + status = subscription_manager.get_subscription_status() + connection_issues: list[dict[str, Any]] = [] + + diagnostic_info: dict[str, Any] = { + "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "environment": { + "auto_start_enabled": subscription_manager.auto_start_enabled, + "max_reconnect_attempts": subscription_manager.max_reconnect_attempts, + "api_url_configured": bool(UNRAID_API_URL), + }, + "subscriptions": status, + "summary": { + "total_configured": len(subscription_manager.subscription_configs), + "active_count": len(subscription_manager.active_subscriptions), + "with_data": len(subscription_manager.resource_data), + "in_error_state": 0, + "connection_issues": connection_issues, + }, + } + + for sub_name, sub_status in status.items(): + runtime = sub_status.get("runtime", {}) + conn_state = runtime.get("connection_state", "unknown") + if conn_state in ("error", "auth_failed", "timeout", "max_retries_exceeded"): + diagnostic_info["summary"]["in_error_state"] += 1 + if runtime.get("last_error"): + connection_issues.append({ + "subscription": sub_name, + "state": conn_state, + "error": runtime["last_error"], + }) + + return diagnostic_info + + except ImportError: + return { + "error": "Subscription modules not available", + "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + except Exception as e: + raise ToolError(f"Failed to generate diagnostics: {str(e)}") from e diff --git a/unraid_mcp/tools/info.py b/unraid_mcp/tools/info.py new file mode 100644 index 0000000..acf0157 --- /dev/null +++ b/unraid_mcp/tools/info.py @@ -0,0 +1,400 @@ +"""System information and server status queries. + +Provides the `unraid_info` tool with 19 read-only actions for retrieving +system information, array status, network config, and server metadata. +""" + +from typing import Any, Literal + +from fastmcp import FastMCP + +from ..config.logging import logger +from ..core.client import make_graphql_request +from ..core.exceptions import ToolError + +# Pre-built queries keyed by action name +QUERIES: dict[str, str] = { + "overview": """ + query GetSystemInfo { + info { + os { platform distro release codename kernel arch hostname codepage logofile serial build uptime } + cpu { manufacturer brand vendor family model stepping revision voltage speed speedmin speedmax threads cores processors socket cache flags } + memory { + layout { bank type clockSpeed formFactor manufacturer partNum serialNum } + } + baseboard { manufacturer model version serial assetTag } + system { manufacturer model version serial uuid sku } + versions { kernel openssl systemOpenssl systemOpensslLib node v8 npm yarn pm2 gulp grunt git tsc mysql redis mongodb apache nginx php docker postfix postgresql perl python gcc unraid } + apps { installed started } + machineId + time + } + } + """, + "array": """ + query GetArrayStatus { + array { + id + state + capacity { + kilobytes { free used total } + disks { free used total } + } + boot { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color } + parities { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color } + disks { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color } + caches { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color } + } + } + """, + "network": """ + query GetNetworkConfig { + network { + id + accessUrls { type name ipv4 ipv6 } + } + } + """, + "registration": """ + query GetRegistrationInfo { + registration { + id type + keyFile { location contents } + state expiration updateExpiration + } + } + """, + "connect": """ + query GetConnectSettings { + connect { status sandbox flashGuid } + } + """, + "variables": """ + query GetSelectiveUnraidVariables { + vars { + id version name timeZone comment security workgroup domain domainShort + hideDotFiles localMaster enableFruit useNtp domainLogin sysModel + sysFlashSlots useSsl port portssl localTld bindMgt useTelnet porttelnet + useSsh portssh startPage startArray shutdownTimeout + shareSmbEnabled shareNfsEnabled shareAfpEnabled shareCacheEnabled + shareAvahiEnabled safeMode startMode configValid configError joinStatus + deviceCount flashGuid flashProduct flashVendor mdState mdVersion + shareCount shareSmbCount shareNfsCount shareAfpCount shareMoverActive + csrfToken + } + } + """, + "metrics": """ + query GetMetrics { + metrics { cpu { used } memory { used total } } + } + """, + "services": """ + query GetServices { + services { name state } + } + """, + "display": """ + query GetDisplay { + info { display { theme } } + } + """, + "config": """ + query GetConfig { + config { valid error } + } + """, + "online": """ + query GetOnline { online } + """, + "owner": """ + query GetOwner { + owner { username avatar url } + } + """, + "settings": """ + query GetSettings { + settings { unified { values } } + } + """, + "server": """ + query GetServer { + info { + os { hostname uptime } + versions { unraid } + machineId time + } + array { state } + online + } + """, + "servers": """ + query GetServers { + servers { id name status description ip port } + } + """, + "flash": """ + query GetFlash { + flash { id guid product vendor size } + } + """, + "ups_devices": """ + query GetUpsDevices { + upsDevices { id model status runtime charge load } + } + """, + "ups_device": """ + query GetUpsDevice($id: PrefixedID!) { + upsDeviceById(id: $id) { id model status runtime charge load voltage frequency temperature } + } + """, + "ups_config": """ + query GetUpsConfig { + upsConfiguration { enabled mode cable driver port } + } + """, +} + +INFO_ACTIONS = Literal[ + "overview", "array", "network", "registration", "connect", "variables", + "metrics", "services", "display", "config", "online", "owner", + "settings", "server", "servers", "flash", + "ups_devices", "ups_device", "ups_config", +] + + +def _process_system_info(raw_info: dict[str, Any]) -> dict[str, Any]: + """Process raw system info into summary + details.""" + summary: dict[str, Any] = {} + if raw_info.get("os"): + os_info = raw_info["os"] + summary["os"] = ( + f"{os_info.get('distro', '')} {os_info.get('release', '')} " + f"({os_info.get('platform', '')}, {os_info.get('arch', '')})" + ) + summary["hostname"] = os_info.get("hostname") + summary["uptime"] = os_info.get("uptime") + + if raw_info.get("cpu"): + cpu = raw_info["cpu"] + summary["cpu"] = ( + f"{cpu.get('manufacturer', '')} {cpu.get('brand', '')} " + f"({cpu.get('cores')} cores, {cpu.get('threads')} threads)" + ) + + if raw_info.get("memory") and raw_info["memory"].get("layout"): + mem_layout = raw_info["memory"]["layout"] + summary["memory_layout_details"] = [] + for stick in mem_layout: + summary["memory_layout_details"].append( + f"Bank {stick.get('bank', '?')}: Type {stick.get('type', '?')}, " + f"Speed {stick.get('clockSpeed', '?')}MHz, " + f"Manufacturer: {stick.get('manufacturer', '?')}, " + f"Part: {stick.get('partNum', '?')}" + ) + summary["memory_summary"] = ( + "Stick layout details retrieved. Overall total/used/free memory stats " + "are unavailable due to API limitations." + ) + else: + summary["memory_summary"] = "Memory information not available." + + return {"summary": summary, "details": raw_info} + + +def _analyze_disk_health(disks: list[dict[str, Any]]) -> dict[str, int]: + """Analyze health status of disk arrays.""" + counts = {"healthy": 0, "failed": 0, "missing": 0, "new": 0, "warning": 0, "unknown": 0} + for disk in disks: + status = disk.get("status", "").upper() + warning = disk.get("warning") + critical = disk.get("critical") + if status == "DISK_OK": + counts["warning" if (warning or critical) else "healthy"] += 1 + elif status in ("DISK_DSBL", "DISK_INVALID"): + counts["failed"] += 1 + elif status == "DISK_NP": + counts["missing"] += 1 + elif status == "DISK_NEW": + counts["new"] += 1 + else: + counts["unknown"] += 1 + return counts + + +def _process_array_status(raw: dict[str, Any]) -> dict[str, Any]: + """Process raw array data into summary + details.""" + + def format_kb(k: Any) -> str: + if k is None: + return "N/A" + k = int(k) + if k >= 1024 * 1024 * 1024: + return f"{k / (1024 * 1024 * 1024):.2f} TB" + if k >= 1024 * 1024: + return f"{k / (1024 * 1024):.2f} GB" + if k >= 1024: + return f"{k / 1024:.2f} MB" + return f"{k} KB" + + summary: dict[str, Any] = {"state": raw.get("state")} + if raw.get("capacity") and raw["capacity"].get("kilobytes"): + kb = raw["capacity"]["kilobytes"] + summary["capacity_total"] = format_kb(kb.get("total")) + summary["capacity_used"] = format_kb(kb.get("used")) + summary["capacity_free"] = format_kb(kb.get("free")) + + summary["num_parity_disks"] = len(raw.get("parities", [])) + summary["num_data_disks"] = len(raw.get("disks", [])) + summary["num_cache_pools"] = len(raw.get("caches", [])) + + health_summary: dict[str, Any] = {} + for key, label in [("parities", "parity_health"), ("disks", "data_health"), ("caches", "cache_health")]: + if raw.get(key): + health_summary[label] = _analyze_disk_health(raw[key]) + + total_failed = sum(h.get("failed", 0) for h in health_summary.values()) + total_missing = sum(h.get("missing", 0) for h in health_summary.values()) + total_warning = sum(h.get("warning", 0) for h in health_summary.values()) + + if total_failed > 0: + overall = "CRITICAL" + elif total_missing > 0: + overall = "DEGRADED" + elif total_warning > 0: + overall = "WARNING" + else: + overall = "HEALTHY" + + summary["overall_health"] = overall + summary["health_summary"] = health_summary + + return {"summary": summary, "details": raw} + + +def register_info_tool(mcp: FastMCP) -> None: + """Register the unraid_info tool with the FastMCP instance.""" + + @mcp.tool() + async def unraid_info( + action: INFO_ACTIONS, + device_id: str | None = None, + ) -> dict[str, Any]: + """Query Unraid system information. + + Actions: + overview - OS, CPU, memory, baseboard, versions + array - Array state, capacity, disk health + network - Access URLs, interfaces + registration - License type, state, expiration + connect - Unraid Connect settings + variables - System variables and configuration + metrics - CPU and memory utilization + services - Running services + display - Theme settings + config - Configuration validity + online - Server online status + owner - Server owner info + settings - All unified settings + server - Quick server summary + servers - Connected servers list + flash - Flash drive info + ups_devices - List UPS devices + ups_device - Single UPS device (requires device_id) + ups_config - UPS configuration + """ + if action not in QUERIES: + raise ToolError(f"Invalid action '{action}'. Must be one of: {list(QUERIES.keys())}") + + if action == "ups_device" and not device_id: + raise ToolError("device_id is required for ups_device action") + + query = QUERIES[action] + variables: dict[str, Any] | None = None + if action == "ups_device": + variables = {"id": device_id} + + try: + logger.info(f"Executing unraid_info action={action}") + data = await make_graphql_request(query, variables) + + # Action-specific response processing + if action == "overview": + raw = data.get("info", {}) + if not raw: + raise ToolError("No system info returned from Unraid API") + return _process_system_info(raw) + + if action == "array": + raw = data.get("array", {}) + if not raw: + raise ToolError("No array information returned from Unraid API") + return _process_array_status(raw) + + if action == "network": + return dict(data.get("network", {})) + + if action == "registration": + return dict(data.get("registration", {})) + + if action == "connect": + return dict(data.get("connect", {})) + + if action == "variables": + return dict(data.get("vars", {})) + + if action == "metrics": + return dict(data.get("metrics", {})) + + if action == "services": + services = data.get("services", []) + return {"services": list(services) if isinstance(services, list) else []} + + if action == "display": + info = data.get("info", {}) + return dict(info.get("display", {})) + + if action == "config": + return dict(data.get("config", {})) + + if action == "online": + return {"online": data.get("online")} + + if action == "owner": + return dict(data.get("owner", {})) + + if action == "settings": + settings = data.get("settings", {}) + if settings and settings.get("unified"): + return dict(settings["unified"].get("values", {})) + return {} + + if action == "server": + return data + + if action == "servers": + servers = data.get("servers", []) + return {"servers": list(servers) if isinstance(servers, list) else []} + + if action == "flash": + return dict(data.get("flash", {})) + + if action == "ups_devices": + devices = data.get("upsDevices", []) + return {"ups_devices": list(devices) if isinstance(devices, list) else []} + + if action == "ups_device": + return dict(data.get("upsDeviceById", {})) + + if action == "ups_config": + return dict(data.get("upsConfiguration", {})) + + return data + + except ToolError: + raise + except Exception as e: + logger.error(f"Error in unraid_info action={action}: {e}", exc_info=True) + raise ToolError(f"Failed to execute info/{action}: {str(e)}") from e + + logger.info("Info tool registered successfully") diff --git a/unraid_mcp/tools/keys.py b/unraid_mcp/tools/keys.py new file mode 100644 index 0000000..cf8d355 --- /dev/null +++ b/unraid_mcp/tools/keys.py @@ -0,0 +1,146 @@ +"""API key management. + +Provides the `unraid_keys` tool with 5 actions for listing, viewing, +creating, updating, and deleting API keys. +""" + +from typing import Any, Literal + +from fastmcp import FastMCP + +from ..config.logging import logger +from ..core.client import make_graphql_request +from ..core.exceptions import ToolError + +QUERIES: dict[str, str] = { + "list": """ + query ListApiKeys { + apiKeys { id name roles permissions createdAt lastUsed } + } + """, + "get": """ + query GetApiKey($id: PrefixedID!) { + apiKey(id: $id) { id name roles permissions createdAt lastUsed } + } + """, +} + +MUTATIONS: dict[str, str] = { + "create": """ + mutation CreateApiKey($input: CreateApiKeyInput!) { + createApiKey(input: $input) { id name key roles } + } + """, + "update": """ + mutation UpdateApiKey($input: UpdateApiKeyInput!) { + updateApiKey(input: $input) { id name roles } + } + """, + "delete": """ + mutation DeleteApiKeys($input: DeleteApiKeysInput!) { + deleteApiKeys(input: $input) + } + """, +} + +DESTRUCTIVE_ACTIONS = {"delete"} + +KEY_ACTIONS = Literal[ + "list", "get", "create", "update", "delete", +] + + +def register_keys_tool(mcp: FastMCP) -> None: + """Register the unraid_keys tool with the FastMCP instance.""" + + @mcp.tool() + async def unraid_keys( + action: KEY_ACTIONS, + confirm: bool = False, + key_id: str | None = None, + name: str | None = None, + roles: list[str] | None = None, + permissions: list[str] | None = None, + ) -> dict[str, Any]: + """Manage Unraid API keys. + + Actions: + list - List all API keys + get - Get a specific API key (requires key_id) + create - Create a new API key (requires name; optional roles, permissions) + update - Update an API key (requires key_id; optional name, roles) + delete - Delete API keys (requires key_id, confirm=True) + """ + all_actions = set(QUERIES) | set(MUTATIONS) + if action not in all_actions: + raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}") + + if action in DESTRUCTIVE_ACTIONS and not confirm: + raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.") + + try: + logger.info(f"Executing unraid_keys action={action}") + + if action == "list": + data = await make_graphql_request(QUERIES["list"]) + keys = data.get("apiKeys", []) + return {"keys": list(keys) if isinstance(keys, list) else []} + + if action == "get": + if not key_id: + raise ToolError("key_id is required for 'get' action") + data = await make_graphql_request(QUERIES["get"], {"id": key_id}) + return dict(data.get("apiKey", {})) + + if action == "create": + if not name: + raise ToolError("name is required for 'create' action") + input_data: dict[str, Any] = {"name": name} + if roles: + input_data["roles"] = roles + if permissions: + input_data["permissions"] = permissions + data = await make_graphql_request( + MUTATIONS["create"], {"input": input_data} + ) + return { + "success": True, + "key": data.get("createApiKey", {}), + } + + if action == "update": + if not key_id: + raise ToolError("key_id is required for 'update' action") + input_data = {"id": key_id} + if name: + input_data["name"] = name + if roles: + input_data["roles"] = roles + data = await make_graphql_request( + MUTATIONS["update"], {"input": input_data} + ) + return { + "success": True, + "key": data.get("updateApiKey", {}), + } + + if action == "delete": + if not key_id: + raise ToolError("key_id is required for 'delete' action") + data = await make_graphql_request( + MUTATIONS["delete"], {"input": {"ids": [key_id]}} + ) + return { + "success": True, + "message": f"API key '{key_id}' deleted", + } + + return {} + + except ToolError: + raise + except Exception as e: + logger.error(f"Error in unraid_keys action={action}: {e}", exc_info=True) + raise ToolError(f"Failed to execute keys/{action}: {str(e)}") from e + + logger.info("Keys tool registered successfully") diff --git a/unraid_mcp/tools/notifications.py b/unraid_mcp/tools/notifications.py new file mode 100644 index 0000000..842c160 --- /dev/null +++ b/unraid_mcp/tools/notifications.py @@ -0,0 +1,205 @@ +"""Notification management. + +Provides the `unraid_notifications` tool with 9 actions for viewing, +creating, archiving, and deleting system notifications. +""" + +from typing import Any, Literal + +from fastmcp import FastMCP + +from ..config.logging import logger +from ..core.client import make_graphql_request +from ..core.exceptions import ToolError + +QUERIES: dict[str, str] = { + "overview": """ + query GetNotificationsOverview { + notifications { + overview { + unread { info warning alert total } + archive { info warning alert total } + } + } + } + """, + "list": """ + query ListNotifications($filter: NotificationFilter!) { + notifications { + list(filter: $filter) { + id title subject description importance link type timestamp formattedTimestamp + } + } + } + """, + "warnings": """ + query GetWarningsAndAlerts { + notifications { + warningsAndAlerts { id title subject description importance type timestamp } + } + } + """, +} + +MUTATIONS: dict[str, str] = { + "create": """ + mutation CreateNotification($input: CreateNotificationInput!) { + notifications { createNotification(input: $input) { id title importance } } + } + """, + "archive": """ + mutation ArchiveNotification($id: PrefixedID!) { + notifications { archiveNotification(id: $id) } + } + """, + "unread": """ + mutation UnreadNotification($id: PrefixedID!) { + notifications { unreadNotification(id: $id) } + } + """, + "delete": """ + mutation DeleteNotification($id: PrefixedID!, $type: NotificationType!) { + notifications { deleteNotification(id: $id, type: $type) } + } + """, + "delete_archived": """ + mutation DeleteArchivedNotifications { + notifications { deleteArchivedNotifications } + } + """, + "archive_all": """ + mutation ArchiveAllNotifications($importance: NotificationImportance) { + notifications { archiveAll(importance: $importance) } + } + """, +} + +DESTRUCTIVE_ACTIONS = {"delete", "delete_archived"} + +NOTIFICATION_ACTIONS = Literal[ + "overview", "list", "warnings", + "create", "archive", "unread", "delete", "delete_archived", "archive_all", +] + + +def register_notifications_tool(mcp: FastMCP) -> None: + """Register the unraid_notifications tool with the FastMCP instance.""" + + @mcp.tool() + async def unraid_notifications( + action: NOTIFICATION_ACTIONS, + confirm: bool = False, + notification_id: str | None = None, + notification_type: str | None = None, + importance: str | None = None, + offset: int = 0, + limit: int = 20, + list_type: str = "UNREAD", + title: str | None = None, + subject: str | None = None, + description: str | None = None, + ) -> dict[str, Any]: + """Manage Unraid system notifications. + + Actions: + overview - Notification counts by severity (unread/archive) + list - List notifications with filtering (list_type=UNREAD/ARCHIVE, importance=INFO/WARNING/ALERT) + warnings - Get deduplicated unread warnings and alerts + create - Create notification (requires title, subject, description, importance) + archive - Archive a notification (requires notification_id) + unread - Mark notification as unread (requires notification_id) + delete - Delete a notification (requires notification_id, notification_type, confirm=True) + delete_archived - Delete all archived notifications (requires confirm=True) + archive_all - Archive all notifications (optional importance filter) + """ + all_actions = {**QUERIES, **MUTATIONS} + if action not in all_actions: + raise ToolError(f"Invalid action '{action}'. Must be one of: {list(all_actions.keys())}") + + if action in DESTRUCTIVE_ACTIONS and not confirm: + raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.") + + try: + logger.info(f"Executing unraid_notifications action={action}") + + if action == "overview": + data = await make_graphql_request(QUERIES["overview"]) + notifications = data.get("notifications", {}) + return dict(notifications.get("overview", {})) + + if action == "list": + filter_vars: dict[str, Any] = { + "type": list_type.upper(), + "offset": offset, + "limit": limit, + } + if importance: + filter_vars["importance"] = importance.upper() + data = await make_graphql_request( + QUERIES["list"], {"filter": filter_vars} + ) + notifications = data.get("notifications", {}) + result = notifications.get("list", []) + return {"notifications": list(result) if isinstance(result, list) else []} + + if action == "warnings": + data = await make_graphql_request(QUERIES["warnings"]) + notifications = data.get("notifications", {}) + result = notifications.get("warningsAndAlerts", []) + return {"warnings": list(result) if isinstance(result, list) else []} + + if action == "create": + if title is None or subject is None or description is None or importance is None: + raise ToolError( + "create requires title, subject, description, and importance" + ) + input_data = { + "title": title, + "subject": subject, + "description": description, + "importance": importance.upper() if importance else "INFO", + } + data = await make_graphql_request( + MUTATIONS["create"], {"input": input_data} + ) + return {"success": True, "data": data} + + if action in ("archive", "unread"): + if not notification_id: + raise ToolError(f"notification_id is required for '{action}' action") + data = await make_graphql_request( + MUTATIONS[action], {"id": notification_id} + ) + return {"success": True, "action": action, "data": data} + + if action == "delete": + if not notification_id or not notification_type: + raise ToolError( + "delete requires notification_id and notification_type" + ) + data = await make_graphql_request( + MUTATIONS["delete"], + {"id": notification_id, "type": notification_type.upper()}, + ) + return {"success": True, "action": "delete", "data": data} + + if action == "delete_archived": + data = await make_graphql_request(MUTATIONS["delete_archived"]) + return {"success": True, "action": "delete_archived", "data": data} + + if action == "archive_all": + variables: dict[str, Any] | None = None + if importance: + variables = {"importance": importance.upper()} + data = await make_graphql_request(MUTATIONS["archive_all"], variables) + return {"success": True, "action": "archive_all", "data": data} + + return {} + + except ToolError: + raise + except Exception as e: + logger.error(f"Error in unraid_notifications action={action}: {e}", exc_info=True) + raise ToolError(f"Failed to execute notifications/{action}: {str(e)}") from e + + logger.info("Notifications tool registered successfully") diff --git a/unraid_mcp/tools/rclone.py b/unraid_mcp/tools/rclone.py index 94703ec..15cddcd 100644 --- a/unraid_mcp/tools/rclone.py +++ b/unraid_mcp/tools/rclone.py @@ -1,11 +1,10 @@ -"""RClone cloud storage remote management tools. +"""RClone cloud storage remote management. -This module provides tools for managing RClone remotes including listing existing -remotes, getting configuration forms, creating new remotes, and deleting remotes -for various cloud storage providers (S3, Google Drive, Dropbox, FTP, etc.). +Provides the `unraid_rclone` tool with 4 actions for managing +cloud storage remotes (S3, Google Drive, Dropbox, FTP, etc.). """ -from typing import Any +from typing import Any, Literal from fastmcp import FastMCP @@ -13,166 +12,121 @@ from ..config.logging import logger from ..core.client import make_graphql_request from ..core.exceptions import ToolError +QUERIES: dict[str, str] = { + "list_remotes": """ + query ListRCloneRemotes { + rclone { remotes { name type parameters config } } + } + """, + "config_form": """ + query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) { + rclone { configForm(formOptions: $formOptions) { id dataSchema uiSchema } } + } + """, +} -def register_rclone_tools(mcp: FastMCP) -> None: - """Register all RClone tools with the FastMCP instance. +MUTATIONS: dict[str, str] = { + "create_remote": """ + mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) { + rclone { createRCloneRemote(input: $input) { name type parameters } } + } + """, + "delete_remote": """ + mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) { + rclone { deleteRCloneRemote(input: $input) } + } + """, +} - Args: - mcp: FastMCP instance to register tools with - """ +DESTRUCTIVE_ACTIONS = {"delete_remote"} + +RCLONE_ACTIONS = Literal[ + "list_remotes", "config_form", "create_remote", "delete_remote", +] + + +def register_rclone_tool(mcp: FastMCP) -> None: + """Register the unraid_rclone tool with the FastMCP instance.""" @mcp.tool() - async def list_rclone_remotes() -> list[dict[str, Any]]: - """Retrieves all configured RClone remotes with their configuration details.""" + async def unraid_rclone( + action: RCLONE_ACTIONS, + confirm: bool = False, + name: str | None = None, + provider_type: str | None = None, + config_data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Manage RClone cloud storage remotes. + + Actions: + list_remotes - List all configured remotes + config_form - Get config form schema (optional provider_type for specific provider) + create_remote - Create a new remote (requires name, provider_type, config_data) + delete_remote - Delete a remote (requires name, confirm=True) + """ + all_actions = set(QUERIES) | set(MUTATIONS) + if action not in all_actions: + raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}") + + if action in DESTRUCTIVE_ACTIONS and not confirm: + raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.") + try: - query = """ - query ListRCloneRemotes { - rclone { - remotes { - name - type - parameters - config - } - } - } - """ + logger.info(f"Executing unraid_rclone action={action}") - response_data = await make_graphql_request(query) + if action == "list_remotes": + data = await make_graphql_request(QUERIES["list_remotes"]) + remotes = data.get("rclone", {}).get("remotes", []) + return {"remotes": list(remotes) if isinstance(remotes, list) else []} - if "rclone" in response_data and "remotes" in response_data["rclone"]: - remotes = response_data["rclone"]["remotes"] - logger.info(f"Retrieved {len(remotes)} RClone remotes") - return list(remotes) if isinstance(remotes, list) else [] + if action == "config_form": + variables: dict[str, Any] = {} + if provider_type: + variables["formOptions"] = {"providerType": provider_type} + data = await make_graphql_request( + QUERIES["config_form"], variables or None + ) + form = data.get("rclone", {}).get("configForm", {}) + if not form: + raise ToolError("No RClone config form data received") + return dict(form) - return [] - - except Exception as e: - logger.error(f"Failed to list RClone remotes: {str(e)}") - raise ToolError(f"Failed to list RClone remotes: {str(e)}") from e - - @mcp.tool() - async def get_rclone_config_form(provider_type: str | None = None) -> dict[str, Any]: - """ - Get RClone configuration form schema for setting up new remotes. - - Args: - provider_type: Optional provider type to get specific form (e.g., 's3', 'drive', 'dropbox') - """ - try: - query = """ - query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) { - rclone { - configForm(formOptions: $formOptions) { - id - dataSchema - uiSchema - } - } - } - """ - - variables = {} - if provider_type: - variables["formOptions"] = {"providerType": provider_type} - - response_data = await make_graphql_request(query, variables) - - if "rclone" in response_data and "configForm" in response_data["rclone"]: - form_data = response_data["rclone"]["configForm"] - logger.info(f"Retrieved RClone config form for {provider_type or 'general'}") - return dict(form_data) if isinstance(form_data, dict) else {} - - raise ToolError("No RClone config form data received") - - except Exception as e: - logger.error(f"Failed to get RClone config form: {str(e)}") - raise ToolError(f"Failed to get RClone config form: {str(e)}") from e - - @mcp.tool() - async def create_rclone_remote(name: str, provider_type: str, config_data: dict[str, Any]) -> dict[str, Any]: - """ - Create a new RClone remote with the specified configuration. - - Args: - name: Name for the new remote - provider_type: Type of provider (e.g., 's3', 'drive', 'dropbox', 'ftp') - config_data: Configuration parameters specific to the provider type - """ - try: - mutation = """ - mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) { - rclone { - createRCloneRemote(input: $input) { - name - type - parameters - } - } - } - """ - - variables = { - "input": { - "name": name, - "type": provider_type, - "config": config_data - } - } - - response_data = await make_graphql_request(mutation, variables) - - if "rclone" in response_data and "createRCloneRemote" in response_data["rclone"]: - remote_info = response_data["rclone"]["createRCloneRemote"] - logger.info(f"Successfully created RClone remote: {name}") + if action == "create_remote": + if name is None or provider_type is None or config_data is None: + raise ToolError( + "create_remote requires name, provider_type, and config_data" + ) + data = await make_graphql_request( + MUTATIONS["create_remote"], + {"input": {"name": name, "type": provider_type, "config": config_data}}, + ) + remote = data.get("rclone", {}).get("createRCloneRemote", {}) return { "success": True, - "message": f"RClone remote '{name}' created successfully", - "remote": remote_info + "message": f"Remote '{name}' created successfully", + "remote": remote, } - raise ToolError("Failed to create RClone remote") - - except Exception as e: - logger.error(f"Failed to create RClone remote {name}: {str(e)}") - raise ToolError(f"Failed to create RClone remote {name}: {str(e)}") from e - - @mcp.tool() - async def delete_rclone_remote(name: str) -> dict[str, Any]: - """ - Delete an existing RClone remote by name. - - Args: - name: Name of the remote to delete - """ - try: - mutation = """ - mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) { - rclone { - deleteRCloneRemote(input: $input) - } - } - """ - - variables = { - "input": { - "name": name - } - } - - response_data = await make_graphql_request(mutation, variables) - - if "rclone" in response_data and response_data["rclone"]["deleteRCloneRemote"]: - logger.info(f"Successfully deleted RClone remote: {name}") + if action == "delete_remote": + if not name: + raise ToolError("name is required for 'delete_remote' action") + data = await make_graphql_request( + MUTATIONS["delete_remote"], {"input": {"name": name}} + ) + success = data.get("rclone", {}).get("deleteRCloneRemote", False) + if not success: + raise ToolError(f"Failed to delete remote '{name}'") return { "success": True, - "message": f"RClone remote '{name}' deleted successfully" + "message": f"Remote '{name}' deleted successfully", } - raise ToolError(f"Failed to delete RClone remote '{name}'") + return {} + except ToolError: + raise except Exception as e: - logger.error(f"Failed to delete RClone remote {name}: {str(e)}") - raise ToolError(f"Failed to delete RClone remote {name}: {str(e)}") from e + logger.error(f"Error in unraid_rclone action={action}: {e}", exc_info=True) + raise ToolError(f"Failed to execute rclone/{action}: {str(e)}") from e - logger.info("RClone tools registered successfully") + logger.info("RClone tool registered successfully") diff --git a/unraid_mcp/tools/storage.py b/unraid_mcp/tools/storage.py index 03aed9b..79833c7 100644 --- a/unraid_mcp/tools/storage.py +++ b/unraid_mcp/tools/storage.py @@ -1,277 +1,159 @@ -"""Storage, disk, and notification management tools. +"""Storage and disk management. -This module provides tools for managing user shares, notifications, -log files, physical disks with SMART data, and system storage operations -with custom timeout configurations for disk-intensive operations. +Provides the `unraid_storage` tool with 6 actions for shares, physical disks, +unassigned devices, log files, and log content retrieval. """ -from typing import Any +from typing import Any, Literal -import httpx from fastmcp import FastMCP from ..config.logging import logger -from ..core.client import make_graphql_request +from ..core.client import DISK_TIMEOUT, make_graphql_request from ..core.exceptions import ToolError - -def register_storage_tools(mcp: FastMCP) -> None: - """Register all storage tools with the FastMCP instance. - - Args: - mcp: FastMCP instance to register tools with - """ - - @mcp.tool() - async def get_shares_info() -> list[dict[str, Any]]: - """Retrieves information about user shares.""" - query = """ +QUERIES: dict[str, str] = { + "shares": """ query GetSharesInfo { shares { - id - name - free - used - size - include - exclude - cache - nameOrig - comment - allocator - splitLevel - floor - cow - color - luksStatus + id name free used size include exclude cache nameOrig + comment allocator splitLevel floor cow color luksStatus } } - """ - try: - logger.info("Executing get_shares_info tool") - response_data = await make_graphql_request(query) - shares = response_data.get("shares", []) - return list(shares) if isinstance(shares, list) else [] - except Exception as e: - logger.error(f"Error in get_shares_info: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve shares information: {str(e)}") from e - - @mcp.tool() - async def get_notifications_overview() -> dict[str, Any]: - """Retrieves an overview of system notifications (unread and archive counts by severity).""" - query = """ - query GetNotificationsOverview { - notifications { - overview { - unread { info warning alert total } - archive { info warning alert total } - } - } + """, + "disks": """ + query ListPhysicalDisks { + disks { id device name } } - """ - try: - logger.info("Executing get_notifications_overview tool") - response_data = await make_graphql_request(query) - if response_data.get("notifications"): - overview = response_data["notifications"].get("overview", {}) - return dict(overview) if isinstance(overview, dict) else {} - return {} - except Exception as e: - logger.error(f"Error in get_notifications_overview: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve notifications overview: {str(e)}") from e - - @mcp.tool() - async def list_notifications( - type: str, - offset: int, - limit: int, - importance: str | None = None - ) -> list[dict[str, Any]]: - """Lists notifications with filtering. Type: UNREAD/ARCHIVE. Importance: INFO/WARNING/ALERT.""" - query = """ - query ListNotifications($filter: NotificationFilter!) { - notifications { - list(filter: $filter) { - id - title - subject - description - importance - link - type - timestamp - formattedTimestamp - } - } - } - """ - variables = { - "filter": { - "type": type.upper(), - "offset": offset, - "limit": limit, - "importance": importance.upper() if importance else None - } - } - # Remove null importance from variables if not provided, as GraphQL might be strict - if not importance: - del variables["filter"]["importance"] - - try: - logger.info(f"Executing list_notifications: type={type}, offset={offset}, limit={limit}, importance={importance}") - response_data = await make_graphql_request(query, variables) - if response_data.get("notifications"): - notifications_list = response_data["notifications"].get("list", []) - return list(notifications_list) if isinstance(notifications_list, list) else [] - return [] - except Exception as e: - logger.error(f"Error in list_notifications: {e}", exc_info=True) - raise ToolError(f"Failed to list notifications: {str(e)}") from e - - @mcp.tool() - async def list_available_log_files() -> list[dict[str, Any]]: - """Lists all available log files that can be queried.""" - query = """ - query ListLogFiles { - logFiles { - name - path - size - modifiedAt - } - } - """ - try: - logger.info("Executing list_available_log_files tool") - response_data = await make_graphql_request(query) - log_files = response_data.get("logFiles", []) - return list(log_files) if isinstance(log_files, list) else [] - except Exception as e: - logger.error(f"Error in list_available_log_files: {e}", exc_info=True) - raise ToolError(f"Failed to list available log files: {str(e)}") from e - - @mcp.tool() - async def get_logs(log_file_path: str, tail_lines: int = 100) -> dict[str, Any]: - """Retrieves content from a specific log file, defaulting to the last 100 lines.""" - # The Unraid GraphQL API Query.logFile takes 'lines' and 'startLine'. - # To implement 'tail_lines', we would ideally need to know the total lines first, - # then calculate startLine. However, Query.logFile itself returns totalLines. - # A simple approach for 'tail' is to request a large number of lines if totalLines is not known beforehand, - # and let the API handle it, or make two calls (one to get totalLines, then another). - # For now, let's assume 'lines' parameter in Query.logFile effectively means tail if startLine is not given. - # If not, this tool might need to be smarter or the API might not directly support 'tail' easily. - # The SDL for LogFileContent implies it returns startLine, so it seems aware of ranges. - - # Let's try fetching with just 'lines' to see if it acts as a tail, - # or if we need Query.logFiles first to get totalLines for calculation. - # For robust tailing, one might need to fetch totalLines first, then calculate start_line for the tail. - # Simplified: query for the last 'tail_lines'. If the API doesn't support tailing this way, we may need adjustment. - # The current plan is to pass 'lines=tail_lines' directly. - - query = """ - query GetLogContent($path: String!, $lines: Int) { - logFile(path: $path, lines: $lines) { - path - content - totalLines - startLine - } - } - """ - variables = {"path": log_file_path, "lines": tail_lines} - try: - logger.info(f"Executing get_logs for {log_file_path}, tail_lines={tail_lines}") - response_data = await make_graphql_request(query, variables) - log_file = response_data.get("logFile", {}) - return dict(log_file) if isinstance(log_file, dict) else {} - except Exception as e: - logger.error(f"Error in get_logs for {log_file_path}: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve logs from {log_file_path}: {str(e)}") from e - - @mcp.tool() - async def list_physical_disks() -> list[dict[str, Any]]: - """Lists all physical disks recognized by the Unraid system.""" - # Querying an extremely minimal set of fields for diagnostics - query = """ - query ListPhysicalDisksMinimal { - disks { - id - device - name - } - } - """ - try: - logger.info("Executing list_physical_disks tool with minimal query and increased timeout") - # Increased read timeout for this potentially slow query - long_timeout = httpx.Timeout(10.0, read=90.0, connect=5.0) - response_data = await make_graphql_request(query, custom_timeout=long_timeout) - disks = response_data.get("disks", []) - return list(disks) if isinstance(disks, list) else [] - except Exception as e: - logger.error(f"Error in list_physical_disks: {e}", exc_info=True) - raise ToolError(f"Failed to list physical disks: {str(e)}") from e - - @mcp.tool() - async def get_disk_details(disk_id: str) -> dict[str, Any]: - """Retrieves detailed SMART information and partition data for a specific physical disk.""" - # Enhanced query with more comprehensive disk information - query = """ + """, + "disk_details": """ query GetDiskDetails($id: PrefixedID!) { disk(id: $id) { - id - device - name - serialNum - size - temperature + id device name serialNum size temperature } } + """, + "unassigned": """ + query GetUnassignedDevices { + unassignedDevices { id device name size type } + } + """, + "log_files": """ + query ListLogFiles { + logFiles { name path size modifiedAt } + } + """, + "logs": """ + query GetLogContent($path: String!, $lines: Int) { + logFile(path: $path, lines: $lines) { + path content totalLines startLine + } + } + """, +} + +STORAGE_ACTIONS = Literal[ + "shares", "disks", "disk_details", "unassigned", "log_files", "logs", +] + + +def format_bytes(bytes_value: int | None) -> str: + """Format byte values into human-readable sizes.""" + if bytes_value is None: + return "N/A" + value = float(int(bytes_value)) + for unit in ["B", "KB", "MB", "GB", "TB", "PB"]: + if value < 1024.0: + return f"{value:.2f} {unit}" + value /= 1024.0 + return f"{value:.2f} EB" + + +def register_storage_tool(mcp: FastMCP) -> None: + """Register the unraid_storage tool with the FastMCP instance.""" + + @mcp.tool() + async def unraid_storage( + action: STORAGE_ACTIONS, + disk_id: str | None = None, + log_path: str | None = None, + tail_lines: int = 100, + ) -> dict[str, Any]: + """Manage Unraid storage, disks, and logs. + + Actions: + shares - List all user shares with capacity info + disks - List all physical disks + disk_details - Detailed SMART info for a disk (requires disk_id) + unassigned - List unassigned devices + log_files - List available log files + logs - Retrieve log content (requires log_path, optional tail_lines) """ - variables = {"id": disk_id} + if action not in QUERIES: + raise ToolError(f"Invalid action '{action}'. Must be one of: {list(QUERIES.keys())}") + + if action == "disk_details" and not disk_id: + raise ToolError("disk_id is required for 'disk_details' action") + + if action == "logs" and not log_path: + raise ToolError("log_path is required for 'logs' action") + + query = QUERIES[action] + variables: dict[str, Any] | None = None + custom_timeout = DISK_TIMEOUT if action == "disks" else None + + if action == "disk_details": + variables = {"id": disk_id} + elif action == "logs": + variables = {"path": log_path, "lines": tail_lines} + try: - logger.info(f"Executing get_disk_details for disk: {disk_id}") - response_data = await make_graphql_request(query, variables) - raw_disk = response_data.get("disk", {}) + logger.info(f"Executing unraid_storage action={action}") + data = await make_graphql_request(query, variables, custom_timeout=custom_timeout) - if not raw_disk: - raise ToolError(f"Disk '{disk_id}' not found") + if action == "shares": + shares = data.get("shares", []) + return {"shares": list(shares) if isinstance(shares, list) else []} - # Process disk information for human-readable output - def format_bytes(bytes_value: int | None) -> str: - if bytes_value is None: - return "N/A" - value = float(int(bytes_value)) - for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB']: - if value < 1024.0: - return f"{value:.2f} {unit}" - value /= 1024.0 - return f"{value:.2f} EB" + if action == "disks": + disks = data.get("disks", []) + return {"disks": list(disks) if isinstance(disks, list) else []} - summary = { - 'disk_id': raw_disk.get('id'), - 'device': raw_disk.get('device'), - 'name': raw_disk.get('name'), - 'serial_number': raw_disk.get('serialNum'), - 'size_formatted': format_bytes(raw_disk.get('size')), - 'temperature': f"{raw_disk.get('temperature')}°C" if raw_disk.get('temperature') else 'N/A', - 'interface_type': raw_disk.get('interfaceType'), - 'smart_status': raw_disk.get('smartStatus'), - 'is_spinning': raw_disk.get('isSpinning'), - 'power_on_hours': raw_disk.get('powerOnHours'), - 'reallocated_sectors': raw_disk.get('reallocatedSectorCount'), - 'partition_count': len(raw_disk.get('partitions', [])), - 'total_partition_size': format_bytes(sum(p.get('size', 0) for p in raw_disk.get('partitions', []) if p.get('size'))) - } + if action == "disk_details": + raw = data.get("disk", {}) + if not raw: + raise ToolError(f"Disk '{disk_id}' not found") + summary = { + "disk_id": raw.get("id"), + "device": raw.get("device"), + "name": raw.get("name"), + "serial_number": raw.get("serialNum"), + "size_formatted": format_bytes(raw.get("size")), + "temperature": ( + f"{raw.get('temperature')}C" + if raw.get("temperature") + else "N/A" + ), + } + return {"summary": summary, "details": raw} - return { - 'summary': summary, - 'partitions': raw_disk.get('partitions', []), - 'details': raw_disk - } + if action == "unassigned": + devices = data.get("unassignedDevices", []) + return {"devices": list(devices) if isinstance(devices, list) else []} + if action == "log_files": + files = data.get("logFiles", []) + return {"log_files": list(files) if isinstance(files, list) else []} + + if action == "logs": + return dict(data.get("logFile", {})) + + return data + + except ToolError: + raise except Exception as e: - logger.error(f"Error in get_disk_details for {disk_id}: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve disk details for {disk_id}: {str(e)}") from e + logger.error(f"Error in unraid_storage action={action}: {e}", exc_info=True) + raise ToolError(f"Failed to execute storage/{action}: {str(e)}") from e - logger.info("Storage tools registered successfully") + logger.info("Storage tool registered successfully") diff --git a/unraid_mcp/tools/system.py b/unraid_mcp/tools/system.py deleted file mode 100644 index 00147cc..0000000 --- a/unraid_mcp/tools/system.py +++ /dev/null @@ -1,392 +0,0 @@ -"""System information and array status tools. - -This module provides tools for retrieving core Unraid system information, -array status with health analysis, network configuration, registration info, -and system variables. -""" - -from typing import Any - -from fastmcp import FastMCP - -from ..config.logging import logger -from ..core.client import make_graphql_request -from ..core.exceptions import ToolError - - -# Standalone functions for use by subscription resources -async def _get_system_info() -> dict[str, Any]: - """Standalone function to get system info - used by subscriptions and tools.""" - query = """ - query GetSystemInfo { - info { - os { platform distro release codename kernel arch hostname codepage logofile serial build uptime } - cpu { manufacturer brand vendor family model stepping revision voltage speed speedmin speedmax threads cores processors socket cache flags } - memory { - # Avoid fetching problematic fields that cause type errors - layout { bank type clockSpeed formFactor manufacturer partNum serialNum } - } - baseboard { manufacturer model version serial assetTag } - system { manufacturer model version serial uuid sku } - versions { kernel openssl systemOpenssl systemOpensslLib node v8 npm yarn pm2 gulp grunt git tsc mysql redis mongodb apache nginx php docker postfix postgresql perl python gcc unraid } - apps { installed started } - # Remove devices section as it has non-nullable fields that might be null - machineId - time - } - } - """ - try: - logger.info("Executing get_system_info") - response_data = await make_graphql_request(query) - raw_info = response_data.get("info", {}) - if not raw_info: - raise ToolError("No system info returned from Unraid API") - - # Process for human-readable output - summary: dict[str, Any] = {} - if raw_info.get('os'): - os_info = raw_info['os'] - summary['os'] = f"{os_info.get('distro', '')} {os_info.get('release', '')} ({os_info.get('platform', '')}, {os_info.get('arch', '')})" - summary['hostname'] = os_info.get('hostname') - summary['uptime'] = os_info.get('uptime') - - if raw_info.get('cpu'): - cpu_info = raw_info['cpu'] - summary['cpu'] = f"{cpu_info.get('manufacturer', '')} {cpu_info.get('brand', '')} ({cpu_info.get('cores')} cores, {cpu_info.get('threads')} threads)" - - if raw_info.get('memory') and raw_info['memory'].get('layout'): - mem_layout = raw_info['memory']['layout'] - summary['memory_layout_details'] = [] # Renamed for clarity - # The API is not returning 'size' for individual sticks in the layout, even if queried. - # So, we cannot calculate total from layout currently. - for stick in mem_layout: - # stick_size = stick.get('size') # This is None in the actual API response - summary['memory_layout_details'].append( - f"Bank {stick.get('bank', '?')}: Type {stick.get('type', '?')}, Speed {stick.get('clockSpeed', '?')}MHz, Manufacturer: {stick.get('manufacturer','?')}, Part: {stick.get('partNum', '?')}" - ) - summary['memory_summary'] = "Stick layout details retrieved. Overall total/used/free memory stats are unavailable due to API limitations (Int overflow or data not provided by API)." - else: - summary['memory_summary'] = "Memory information (layout or stats) not available or failed to retrieve." - - # Include a key for the full details if needed by an LLM for deeper dives - return {"summary": summary, "details": raw_info} - - except Exception as e: - logger.error(f"Error in get_system_info: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve system information: {str(e)}") from e - - -async def _get_array_status() -> dict[str, Any]: - """Standalone function to get array status - used by subscriptions and tools.""" - query = """ - query GetArrayStatus { - array { - id - state - capacity { - kilobytes { free used total } - disks { free used total } - } - boot { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color } - parities { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color } - disks { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color } - caches { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color } - } - } - """ - try: - logger.info("Executing get_array_status") - response_data = await make_graphql_request(query) - raw_array_info = response_data.get("array", {}) - if not raw_array_info: - raise ToolError("No array information returned from Unraid API") - - summary: dict[str, Any] = {} - summary['state'] = raw_array_info.get('state') - - if raw_array_info.get('capacity') and raw_array_info['capacity'].get('kilobytes'): - kb_cap = raw_array_info['capacity']['kilobytes'] - # Helper to format KB into TB/GB/MB - def format_kb(k: Any) -> str: - if k is None: - return "N/A" - k = int(k) # Values are strings in SDL for PrefixedID containing types like capacity - if k >= 1024*1024*1024: - return f"{k / (1024*1024*1024):.2f} TB" - if k >= 1024*1024: - return f"{k / (1024*1024):.2f} GB" - if k >= 1024: - return f"{k / 1024:.2f} MB" - return f"{k} KB" - - summary['capacity_total'] = format_kb(kb_cap.get('total')) - summary['capacity_used'] = format_kb(kb_cap.get('used')) - summary['capacity_free'] = format_kb(kb_cap.get('free')) - - summary['num_parity_disks'] = len(raw_array_info.get('parities', [])) - summary['num_data_disks'] = len(raw_array_info.get('disks', [])) - summary['num_cache_pools'] = len(raw_array_info.get('caches', [])) # Note: caches are pools, not individual cache disks - - # Enhanced: Add disk health summary - def analyze_disk_health(disks: list[dict[str, Any]], disk_type: str) -> dict[str, int]: - """Analyze health status of disk arrays""" - if not disks: - return {} - - health_counts = { - 'healthy': 0, - 'failed': 0, - 'missing': 0, - 'new': 0, - 'warning': 0, - 'unknown': 0 - } - - for disk in disks: - status = disk.get('status', '').upper() - warning = disk.get('warning') - critical = disk.get('critical') - - if status == 'DISK_OK': - if warning or critical: - health_counts['warning'] += 1 - else: - health_counts['healthy'] += 1 - elif status in ['DISK_DSBL', 'DISK_INVALID']: - health_counts['failed'] += 1 - elif status == 'DISK_NP': - health_counts['missing'] += 1 - elif status == 'DISK_NEW': - health_counts['new'] += 1 - else: - health_counts['unknown'] += 1 - - return health_counts - - # Analyze health for each disk type - health_summary = {} - if raw_array_info.get('parities'): - health_summary['parity_health'] = analyze_disk_health(raw_array_info['parities'], 'parity') - if raw_array_info.get('disks'): - health_summary['data_health'] = analyze_disk_health(raw_array_info['disks'], 'data') - if raw_array_info.get('caches'): - health_summary['cache_health'] = analyze_disk_health(raw_array_info['caches'], 'cache') - - # Overall array health assessment - total_failed = sum(h.get('failed', 0) for h in health_summary.values()) - total_missing = sum(h.get('missing', 0) for h in health_summary.values()) - total_warning = sum(h.get('warning', 0) for h in health_summary.values()) - - if total_failed > 0: - overall_health = "CRITICAL" - elif total_missing > 0: - overall_health = "DEGRADED" - elif total_warning > 0: - overall_health = "WARNING" - else: - overall_health = "HEALTHY" - - summary['overall_health'] = overall_health - summary['health_summary'] = health_summary - - return {"summary": summary, "details": raw_array_info} - - except Exception as e: - logger.error(f"Error in get_array_status: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve array status: {str(e)}") from e - - -def register_system_tools(mcp: FastMCP) -> None: - """Register all system tools with the FastMCP instance. - - Args: - mcp: FastMCP instance to register tools with - """ - - @mcp.tool() - async def get_system_info() -> dict[str, Any]: - """Retrieves comprehensive information about the Unraid system, OS, CPU, memory, and baseboard.""" - return await _get_system_info() - - @mcp.tool() - async def get_array_status() -> dict[str, Any]: - """Retrieves the current status of the Unraid storage array, including its state, capacity, and details of all disks.""" - return await _get_array_status() - - @mcp.tool() - async def get_network_config() -> dict[str, Any]: - """Retrieves network configuration details, including access URLs.""" - query = """ - query GetNetworkConfig { - network { - id - accessUrls { type name ipv4 ipv6 } - } - } - """ - try: - logger.info("Executing get_network_config tool") - response_data = await make_graphql_request(query) - network = response_data.get("network", {}) - return dict(network) if isinstance(network, dict) else {} - except Exception as e: - logger.error(f"Error in get_network_config: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve network configuration: {str(e)}") from e - - @mcp.tool() - async def get_registration_info() -> dict[str, Any]: - """Retrieves Unraid registration details.""" - query = """ - query GetRegistrationInfo { - registration { - id - type - keyFile { location contents } - state - expiration - updateExpiration - } - } - """ - try: - logger.info("Executing get_registration_info tool") - response_data = await make_graphql_request(query) - registration = response_data.get("registration", {}) - return dict(registration) if isinstance(registration, dict) else {} - except Exception as e: - logger.error(f"Error in get_registration_info: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve registration information: {str(e)}") from e - - @mcp.tool() - async def get_connect_settings() -> dict[str, Any]: - """Retrieves settings related to Unraid Connect.""" - # Based on actual schema: settings.unified.values contains the JSON settings - query = """ - query GetConnectSettingsForm { - settings { - unified { - values - } - } - } - """ - try: - logger.info("Executing get_connect_settings tool") - response_data = await make_graphql_request(query) - - # Navigate down to the unified settings values - if response_data.get("settings") and response_data["settings"].get("unified"): - values = response_data["settings"]["unified"].get("values", {}) - # Filter for Connect-related settings if values is a dict - if isinstance(values, dict): - # Look for connect-related keys in the unified settings - connect_settings = {} - for key, value in values.items(): - if 'connect' in key.lower() or key in ['accessType', 'forwardType', 'port']: - connect_settings[key] = value - return connect_settings if connect_settings else values - return dict(values) if isinstance(values, dict) else {} - return {} - except Exception as e: - logger.error(f"Error in get_connect_settings: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve Unraid Connect settings: {str(e)}") from e - - @mcp.tool() - async def get_unraid_variables() -> dict[str, Any]: - """Retrieves a selection of Unraid system variables and settings. - Note: Many variables are omitted due to API type issues (Int overflow/NaN). - """ - # Querying a smaller, curated set of fields to avoid Int overflow and NaN issues - # pending Unraid API schema fixes for the full Vars type. - query = """ - query GetSelectiveUnraidVariables { - vars { - id - version - name - timeZone - comment - security - workgroup - domain - domainShort - hideDotFiles - localMaster - enableFruit - useNtp - # ntpServer1, ntpServer2, ... are strings, should be okay but numerous - domainLogin # Boolean - sysModel # String - # sysArraySlots, sysCacheSlots are Int, were problematic (NaN) - sysFlashSlots # Int, might be okay if small and always set - useSsl # Boolean - port # Int, usually small - portssl # Int, usually small - localTld # String - bindMgt # Boolean - useTelnet # Boolean - porttelnet # Int, usually small - useSsh # Boolean - portssh # Int, usually small - startPage # String - startArray # Boolean - # spindownDelay, queueDepth are Int, potentially okay if always set - # defaultFormat, defaultFsType are String - shutdownTimeout # Int, potentially okay - # luksKeyfile is String - # pollAttributes, pollAttributesDefault, pollAttributesStatus are Int/String, were problematic (NaN or type) - # nrRequests, nrRequestsDefault, nrRequestsStatus were problematic - # mdNumStripes, mdNumStripesDefault, mdNumStripesStatus were problematic - # mdSyncWindow, mdSyncWindowDefault, mdSyncWindowStatus were problematic - # mdSyncThresh, mdSyncThreshDefault, mdSyncThreshStatus were problematic - # mdWriteMethod, mdWriteMethodDefault, mdWriteMethodStatus were problematic - # shareDisk, shareUser, shareUserInclude, shareUserExclude are String arrays/String - shareSmbEnabled # Boolean - shareNfsEnabled # Boolean - shareAfpEnabled # Boolean - # shareInitialOwner, shareInitialGroup are String - shareCacheEnabled # Boolean - # shareCacheFloor is String (numeric string?) - # shareMoverSchedule, shareMoverLogging are String - # fuseRemember, fuseRememberDefault, fuseRememberStatus are String/Boolean, were problematic - # fuseDirectio, fuseDirectioDefault, fuseDirectioStatus are String/Boolean, were problematic - shareAvahiEnabled # Boolean - # shareAvahiSmbName, shareAvahiSmbModel, shareAvahiAfpName, shareAvahiAfpModel are String - safeMode # Boolean - startMode # String - configValid # Boolean - configError # String - joinStatus # String - deviceCount # Int, might be okay - flashGuid # String - flashProduct # String - flashVendor # String - # regCheck, regFile, regGuid, regTy, regState, regTo, regTm, regTm2, regGen are varied, mostly String/Int - # sbName, sbVersion, sbUpdated, sbEvents, sbState, sbClean, sbSynced, sbSyncErrs, sbSynced2, sbSyncExit are varied - # mdColor, mdNumDisks, mdNumDisabled, mdNumInvalid, mdNumMissing, mdNumNew, mdNumErased are Int, potentially okay if counts - # mdResync, mdResyncCorr, mdResyncPos, mdResyncDb, mdResyncDt, mdResyncAction are varied (Int/Boolean/String) - # mdResyncSize was an overflow - mdState # String (enum) - mdVersion # String - # cacheNumDevices, cacheSbNumDisks were problematic (NaN) - # fsState, fsProgress, fsCopyPrcnt, fsNumMounted, fsNumUnmountable, fsUnmountableMask are varied - shareCount # Int, might be okay - shareSmbCount # Int, might be okay - shareNfsCount # Int, might be okay - shareAfpCount # Int, might be okay - shareMoverActive # Boolean - csrfToken # String - } - } - """ - try: - logger.info("Executing get_unraid_variables tool with a selective query") - response_data = await make_graphql_request(query) - vars_data = response_data.get("vars", {}) - return dict(vars_data) if isinstance(vars_data, dict) else {} - except Exception as e: - logger.error(f"Error in get_unraid_variables: {e}", exc_info=True) - raise ToolError(f"Failed to retrieve Unraid variables: {str(e)}") from e - - logger.info("System tools registered successfully") diff --git a/unraid_mcp/tools/users.py b/unraid_mcp/tools/users.py new file mode 100644 index 0000000..e74025d --- /dev/null +++ b/unraid_mcp/tools/users.py @@ -0,0 +1,163 @@ +"""User management. + +Provides the `unraid_users` tool with 8 actions for managing users, +cloud access, remote access settings, and allowed origins. +""" + +from typing import Any, Literal + +from fastmcp import FastMCP + +from ..config.logging import logger +from ..core.client import make_graphql_request +from ..core.exceptions import ToolError + +QUERIES: dict[str, str] = { + "me": """ + query GetMe { + me { id name role email } + } + """, + "list": """ + query ListUsers { + users { id name role email } + } + """, + "get": """ + query GetUser($id: PrefixedID!) { + user(id: $id) { id name role email } + } + """, + "cloud": """ + query GetCloud { + cloud { status apiKey error } + } + """, + "remote_access": """ + query GetRemoteAccess { + remoteAccess { enabled url } + } + """, + "origins": """ + query GetAllowedOrigins { + allowedOrigins + } + """, +} + +MUTATIONS: dict[str, str] = { + "add": """ + mutation AddUser($input: AddUserInput!) { + addUser(input: $input) { id name role } + } + """, + "delete": """ + mutation DeleteUser($id: PrefixedID!) { + deleteUser(id: $id) + } + """, +} + +DESTRUCTIVE_ACTIONS = {"delete"} + +USER_ACTIONS = Literal[ + "me", "list", "get", "add", "delete", "cloud", "remote_access", "origins", +] + + +def register_users_tool(mcp: FastMCP) -> None: + """Register the unraid_users tool with the FastMCP instance.""" + + @mcp.tool() + async def unraid_users( + action: USER_ACTIONS, + confirm: bool = False, + user_id: str | None = None, + name: str | None = None, + password: str | None = None, + role: str | None = None, + ) -> dict[str, Any]: + """Manage Unraid users and access settings. + + Actions: + me - Get current authenticated user info + list - List all users + get - Get a specific user (requires user_id) + add - Add a new user (requires name, password; optional role) + delete - Delete a user (requires user_id, confirm=True) + cloud - Get Unraid Connect cloud status + remote_access - Get remote access settings + origins - Get allowed origins + """ + all_actions = set(QUERIES) | set(MUTATIONS) + if action not in all_actions: + raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}") + + if action in DESTRUCTIVE_ACTIONS and not confirm: + raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.") + + try: + logger.info(f"Executing unraid_users action={action}") + + if action == "me": + data = await make_graphql_request(QUERIES["me"]) + return dict(data.get("me", {})) + + if action == "list": + data = await make_graphql_request(QUERIES["list"]) + users = data.get("users", []) + return {"users": list(users) if isinstance(users, list) else []} + + if action == "get": + if not user_id: + raise ToolError("user_id is required for 'get' action") + data = await make_graphql_request(QUERIES["get"], {"id": user_id}) + return dict(data.get("user", {})) + + if action == "add": + if not name or not password: + raise ToolError("add requires name and password") + input_data: dict[str, Any] = {"name": name, "password": password} + if role: + input_data["role"] = role.upper() + data = await make_graphql_request( + MUTATIONS["add"], {"input": input_data} + ) + return { + "success": True, + "user": data.get("addUser", {}), + } + + if action == "delete": + if not user_id: + raise ToolError("user_id is required for 'delete' action") + data = await make_graphql_request( + MUTATIONS["delete"], {"id": user_id} + ) + return { + "success": True, + "message": f"User '{user_id}' deleted", + } + + if action == "cloud": + data = await make_graphql_request(QUERIES["cloud"]) + return dict(data.get("cloud", {})) + + if action == "remote_access": + data = await make_graphql_request(QUERIES["remote_access"]) + return dict(data.get("remoteAccess", {})) + + if action == "origins": + data = await make_graphql_request(QUERIES["origins"]) + origins = data.get("allowedOrigins", []) + return {"origins": list(origins) if isinstance(origins, list) else []} + + return {} + + except ToolError: + raise + except Exception as e: + logger.error(f"Error in unraid_users action={action}: {e}", exc_info=True) + raise ToolError(f"Failed to execute users/{action}: {str(e)}") from e + + logger.info("Users tool registered successfully") diff --git a/unraid_mcp/tools/virtualization.py b/unraid_mcp/tools/virtualization.py index 45fedce..4926fa9 100644 --- a/unraid_mcp/tools/virtualization.py +++ b/unraid_mcp/tools/virtualization.py @@ -1,11 +1,10 @@ -"""Virtual machine management tools. +"""Virtual machine management. -This module provides tools for VM lifecycle management and monitoring -including listing VMs, VM operations (start/stop/pause/reboot/etc), -and detailed VM information retrieval. +Provides the `unraid_vm` tool with 9 actions for VM lifecycle management +including start, stop, pause, resume, force stop, reboot, and reset. """ -from typing import Any +from typing import Any, Literal from fastmcp import FastMCP @@ -13,150 +12,148 @@ from ..config.logging import logger from ..core.client import make_graphql_request from ..core.exceptions import ToolError - -def register_vm_tools(mcp: FastMCP) -> None: - """Register all VM tools with the FastMCP instance. - - Args: - mcp: FastMCP instance to register tools with - """ - - @mcp.tool() - async def list_vms() -> list[dict[str, Any]]: - """Lists all Virtual Machines (VMs) on the Unraid system and their current state. - - Returns: - List of VM information dictionaries with UUID, name, and state - """ - query = """ +QUERIES: dict[str, str] = { + "list": """ query ListVMs { - vms { - id - domains { - id - name - state - uuid - } - } + vms { id domains { id name state uuid } } } - """ - try: - logger.info("Executing list_vms tool") - response_data = await make_graphql_request(query) - logger.info(f"VM query response: {response_data}") - if response_data.get("vms") and response_data["vms"].get("domains"): - vms = response_data["vms"]["domains"] - logger.info(f"Found {len(vms)} VMs") - return list(vms) if isinstance(vms, list) else [] - else: - logger.info("No VMs found in domains field") - return [] - except Exception as e: - logger.error(f"Error in list_vms: {e}", exc_info=True) - error_msg = str(e) - if "VMs are not available" in error_msg: - raise ToolError("VMs are not available on this Unraid server. This could mean: 1) VM support is not enabled, 2) VM service is not running, or 3) no VMs are configured. Check Unraid VM settings.") from e - else: - raise ToolError(f"Failed to list virtual machines: {error_msg}") from e - - @mcp.tool() - async def manage_vm(vm_uuid: str, action: str) -> dict[str, Any]: - """Manages a VM: start, stop, pause, resume, force_stop, reboot, reset. Uses VM UUID. - - Args: - vm_uuid: UUID of the VM to manage - action: Action to perform - one of: start, stop, pause, resume, forceStop, reboot, reset - - Returns: - Dict containing operation success status and details - """ - valid_actions = ["start", "stop", "pause", "resume", "forceStop", "reboot", "reset"] # Added reset operation - if action not in valid_actions: - logger.warning(f"Invalid action '{action}' for manage_vm") - raise ToolError(f"Invalid action. Must be one of {valid_actions}.") - - mutation_name = action - query = f""" - mutation ManageVM($id: PrefixedID!) {{ - vm {{ - {mutation_name}(id: $id) - }} - }} - """ - variables = {"id": vm_uuid} - try: - logger.info(f"Executing manage_vm tool: action={action}, uuid={vm_uuid}") - response_data = await make_graphql_request(query, variables) - if response_data.get("vm") and mutation_name in response_data["vm"]: - # Mutations for VM return Boolean for success - success = response_data["vm"][mutation_name] - return {"success": success, "action": action, "vm_uuid": vm_uuid} - raise ToolError(f"Failed to {action} VM or unexpected response structure.") - except Exception as e: - logger.error(f"Error in manage_vm ({action}): {e}", exc_info=True) - raise ToolError(f"Failed to {action} virtual machine: {str(e)}") from e - - @mcp.tool() - async def get_vm_details(vm_identifier: str) -> dict[str, Any]: - """Retrieves detailed information for a specific VM by its UUID or name. - - Args: - vm_identifier: VM UUID or name to retrieve details for - - Returns: - Dict containing detailed VM information - """ - # Make direct GraphQL call instead of calling list_vms() tool - query = """ + """, + "details": """ query GetVmDetails { - vms { - domains { - id - name - state - uuid - } - domain { - id - name - state - uuid - } - } + vms { domains { id name state uuid } } } + """, +} + +MUTATIONS: dict[str, str] = { + "start": """ + mutation StartVM($id: PrefixedID!) { vm { start(id: $id) } } + """, + "stop": """ + mutation StopVM($id: PrefixedID!) { vm { stop(id: $id) } } + """, + "pause": """ + mutation PauseVM($id: PrefixedID!) { vm { pause(id: $id) } } + """, + "resume": """ + mutation ResumeVM($id: PrefixedID!) { vm { resume(id: $id) } } + """, + "force_stop": """ + mutation ForceStopVM($id: PrefixedID!) { vm { forceStop(id: $id) } } + """, + "reboot": """ + mutation RebootVM($id: PrefixedID!) { vm { reboot(id: $id) } } + """, + "reset": """ + mutation ResetVM($id: PrefixedID!) { vm { reset(id: $id) } } + """, +} + +# Map action names to their GraphQL field names +_MUTATION_FIELDS: dict[str, str] = { + "start": "start", + "stop": "stop", + "pause": "pause", + "resume": "resume", + "force_stop": "forceStop", + "reboot": "reboot", + "reset": "reset", +} + +DESTRUCTIVE_ACTIONS = {"force_stop", "reset"} + +VM_ACTIONS = Literal[ + "list", "details", + "start", "stop", "pause", "resume", "force_stop", "reboot", "reset", +] + + +def register_vm_tool(mcp: FastMCP) -> None: + """Register the unraid_vm tool with the FastMCP instance.""" + + @mcp.tool() + async def unraid_vm( + action: VM_ACTIONS, + vm_id: str | None = None, + confirm: bool = False, + ) -> dict[str, Any]: + """Manage Unraid virtual machines. + + Actions: + list - List all VMs with state + details - Detailed info for a VM (requires vm_id: UUID, PrefixedID, or name) + start - Start a VM (requires vm_id) + stop - Gracefully stop a VM (requires vm_id) + pause - Pause a VM (requires vm_id) + resume - Resume a paused VM (requires vm_id) + force_stop - Force stop a VM (requires vm_id, confirm=True) + reboot - Reboot a VM (requires vm_id) + reset - Reset a VM (requires vm_id, confirm=True) """ + all_actions = set(QUERIES) | set(MUTATIONS) + if action not in all_actions: + raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}") + + if action in DESTRUCTIVE_ACTIONS and not confirm: + raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.") + + if action != "list" and not vm_id: + raise ToolError(f"vm_id is required for '{action}' action") + try: - logger.info(f"Executing get_vm_details for identifier: {vm_identifier}") - response_data = await make_graphql_request(query) + logger.info(f"Executing unraid_vm action={action}") - if response_data.get("vms"): - vms_data = response_data["vms"] - # Try to get VMs from either domains or domain field - vms = vms_data.get("domains") or vms_data.get("domain") or [] + if action == "list": + data = await make_graphql_request(QUERIES["list"]) + if data.get("vms") and data["vms"].get("domains"): + vms = data["vms"]["domains"] + return {"vms": list(vms) if isinstance(vms, list) else []} + return {"vms": []} - if vms: - for vm_data in vms: - if (vm_data.get("uuid") == vm_identifier or - vm_data.get("id") == vm_identifier or - vm_data.get("name") == vm_identifier): - logger.info(f"Found VM {vm_identifier}") - return dict(vm_data) if isinstance(vm_data, dict) else {} + if action == "details": + data = await make_graphql_request(QUERIES["details"]) + if data.get("vms"): + vms = data["vms"].get("domains") or [] + for vm in vms: + if ( + vm.get("uuid") == vm_id + or vm.get("id") == vm_id + or vm.get("name") == vm_id + ): + return dict(vm) if isinstance(vm, dict) else {} + available = [ + f"{v.get('name')} (UUID: {v.get('uuid')})" for v in vms + ] + raise ToolError( + f"VM '{vm_id}' not found. Available: {', '.join(available)}" + ) + raise ToolError("No VM data returned from server") - logger.warning(f"VM with identifier '{vm_identifier}' not found.") - available_vms = [f"{vm.get('name')} (UUID: {vm.get('uuid')}, ID: {vm.get('id')})" for vm in vms] - raise ToolError(f"VM '{vm_identifier}' not found. Available VMs: {', '.join(available_vms)}") - else: - raise ToolError("No VMs available or VMs not accessible") - else: - raise ToolError("No VMs data returned from server") + # Mutations + if action in MUTATIONS: + data = await make_graphql_request( + MUTATIONS[action], {"id": vm_id} + ) + field = _MUTATION_FIELDS[action] + if data.get("vm") and field in data["vm"]: + return { + "success": data["vm"][field], + "action": action, + "vm_id": vm_id, + } + raise ToolError(f"Failed to {action} VM or unexpected response") + return {} + + except ToolError: + raise except Exception as e: - logger.error(f"Error in get_vm_details: {e}", exc_info=True) - error_msg = str(e) - if "VMs are not available" in error_msg: - raise ToolError("VMs are not available on this Unraid server. This could mean: 1) VM support is not enabled, 2) VM service is not running, or 3) no VMs are configured. Check Unraid VM settings.") from e - else: - raise ToolError(f"Failed to retrieve VM details: {error_msg}") from e + logger.error(f"Error in unraid_vm action={action}: {e}", exc_info=True) + msg = str(e) + if "VMs are not available" in msg: + raise ToolError( + "VMs not available on this server. Check VM support is enabled." + ) from e + raise ToolError(f"Failed to execute vm/{action}: {msg}") from e - logger.info("VM tools registered successfully") + logger.info("VM tool registered successfully") diff --git a/uv.lock b/uv.lock index 7af68f8..ea24f1a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -267,6 +267,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/07/1c8099563a8a6c389a31c2d0aa1497cee86d6248bb4b9ba5e779215db9f9/coverage-7.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4f345f7265cdbdb5ec2521ffff15fa49de6d6c39abf89fc7ad68aa9e3a55f0", size = 219143, upload-time = "2026-02-03T13:59:40.459Z" }, + { url = "https://files.pythonhosted.org/packages/69/39/a892d44af7aa092cab70e0cc5cdbba18eeccfe1d6930695dab1742eef9e9/coverage-7.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96c3be8bae9d0333e403cc1a8eb078a7f928b5650bae94a18fb4820cc993fb9b", size = 219663, upload-time = "2026-02-03T13:59:41.951Z" }, + { url = "https://files.pythonhosted.org/packages/9a/25/9669dcf4c2bb4c3861469e6db20e52e8c11908cf53c14ec9b12e9fd4d602/coverage-7.13.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d6f4a21328ea49d38565b55599e1c02834e76583a6953e5586d65cb1efebd8f8", size = 246424, upload-time = "2026-02-03T13:59:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/f3/68/d9766c4e298aca62ea5d9543e1dd1e4e1439d7284815244d8b7db1840bfb/coverage-7.13.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc970575799a9d17d5c3fafd83a0f6ccf5d5117cdc9ad6fbd791e9ead82418b0", size = 248228, upload-time = "2026-02-03T13:59:44.816Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e2/eea6cb4a4bd443741adf008d4cccec83a1f75401df59b6559aca2bdd9710/coverage-7.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ff33b652b3556b05e204ae20793d1f872161b0fa5ec8a9ac76f8430e152ed6", size = 250103, upload-time = "2026-02-03T13:59:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/db/77/664280ecd666c2191610842177e2fab9e5dbdeef97178e2078fed46a3d2c/coverage-7.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7df8759ee57b9f3f7b66799b7660c282f4375bef620ade1686d6a7b03699e75f", size = 247107, upload-time = "2026-02-03T13:59:48.53Z" }, + { url = "https://files.pythonhosted.org/packages/2b/df/2a672eab99e0d0eba52d8a63e47dc92245eee26954d1b2d3c8f7d372151f/coverage-7.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f45c9bcb16bee25a798ccba8a2f6a1251b19de6a0d617bb365d7d2f386c4e20e", size = 248143, upload-time = "2026-02-03T13:59:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/a5/dc/a104e7a87c13e57a358b8b9199a8955676e1703bb372d79722b54978ae45/coverage-7.13.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:318b2e4753cbf611061e01b6cc81477e1cdfeb69c36c4a14e6595e674caadb56", size = 246148, upload-time = "2026-02-03T13:59:52.025Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/e113d3a58dc20b03b7e59aed1e53ebc9ca6167f961876443e002b10e3ae9/coverage-7.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:24db3959de8ee394eeeca89ccb8ba25305c2da9a668dd44173394cbd5aa0777f", size = 246414, upload-time = "2026-02-03T13:59:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/3f/60/a3fd0a6e8d89b488396019a2268b6a1f25ab56d6d18f3be50f35d77b47dc/coverage-7.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be14d0622125edef21b3a4d8cd2d138c4872bf6e38adc90fd92385e3312f406a", size = 247023, upload-time = "2026-02-03T13:59:55.454Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/de4840bb939dbb22ba0648a6d8069fa91c9cf3b3fca8b0d1df461e885b3d/coverage-7.13.3-cp310-cp310-win32.whl", hash = "sha256:53be4aab8ddef18beb6188f3a3fdbf4d1af2277d098d4e618be3a8e6c88e74be", size = 221751, upload-time = "2026-02-03T13:59:57.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/87/233ff8b7ef62fb63f58c78623b50bef69681111e0c4d43504f422d88cda4/coverage-7.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:bfeee64ad8b4aae3233abb77eb6b52b51b05fa89da9645518671b9939a78732b", size = 222686, upload-time = "2026-02-03T13:59:58.825Z" }, + { url = "https://files.pythonhosted.org/packages/ec/09/1ac74e37cf45f17eb41e11a21854f7f92a4c2d6c6098ef4a1becb0c6d8d3/coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73", size = 219276, upload-time = "2026-02-03T14:00:00.296Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cb/71908b08b21beb2c437d0d5870c4ec129c570ca1b386a8427fcdb11cf89c/coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00", size = 219776, upload-time = "2026-02-03T14:00:02.414Z" }, + { url = "https://files.pythonhosted.org/packages/09/85/c4f3dd69232887666a2c0394d4be21c60ea934d404db068e6c96aa59cd87/coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2", size = 250196, upload-time = "2026-02-03T14:00:04.197Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cc/560ad6f12010344d0778e268df5ba9aa990aacccc310d478bf82bf3d302c/coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c", size = 252111, upload-time = "2026-02-03T14:00:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/f0/66/3193985fb2c58e91f94cfbe9e21a6fdf941e9301fe2be9e92c072e9c8f8c/coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b", size = 254217, upload-time = "2026-02-03T14:00:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c5/78/f0f91556bf1faa416792e537c523c5ef9db9b1d32a50572c102b3d7c45b3/coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0", size = 250318, upload-time = "2026-02-03T14:00:09.224Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/fc654e45e837d137b2c1f3a2cc09b4aea1e8b015acd2f774fa0f3d2ddeba/coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14", size = 251909, upload-time = "2026-02-03T14:00:10.712Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/ab53063992add8a9ca0463c9d92cce5994a29e17affd1c2daa091b922a93/coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4", size = 249971, upload-time = "2026-02-03T14:00:12.402Z" }, + { url = "https://files.pythonhosted.org/packages/29/25/83694b81e46fcff9899694a1b6f57573429cdd82b57932f09a698f03eea5/coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad", size = 249692, upload-time = "2026-02-03T14:00:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/d68fc304301f4cb4bf6aefa0045310520789ca38dabdfba9dbecd3f37919/coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222", size = 250597, upload-time = "2026-02-03T14:00:15.461Z" }, + { url = "https://files.pythonhosted.org/packages/8d/85/240ad396f914df361d0f71e912ddcedb48130c71b88dc4193fe3c0306f00/coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb", size = 221773, upload-time = "2026-02-03T14:00:17.462Z" }, + { url = "https://files.pythonhosted.org/packages/2f/71/165b3a6d3d052704a9ab52d11ea64ef3426745de517dda44d872716213a7/coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301", size = 222711, upload-time = "2026-02-03T14:00:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/51/d0/0ddc9c5934cdd52639c5df1f1eb0fdab51bb52348f3a8d1c7db9c600d93a/coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba", size = 221377, upload-time = "2026-02-03T14:00:20.968Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" }, + { url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" }, + { url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" }, + { url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" }, + { url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" }, + { url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" }, + { url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" }, + { url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" }, + { url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" }, + { url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" }, + { url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" }, + { url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" }, + { url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" }, + { url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" }, + { url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "46.0.1" @@ -1082,6 +1186,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -1514,15 +1632,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] -[[package]] -name = "types-python-dateutil" -version = "2.9.0.20250822" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/0a/775f8551665992204c756be326f3575abba58c4a3a52eef9909ef4536428/types_python_dateutil-2.9.0.20250822.tar.gz", hash = "sha256:84c92c34bd8e68b117bff742bc00b692a1e8531262d4507b33afcc9f7716cd53", size = 16084, upload-time = "2025-08-22T03:02:00.613Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/d9/a29dfa84363e88b053bf85a8b7f212a04f0d7343a4d24933baa45c06e08b/types_python_dateutil-2.9.0.20250822-py3-none-any.whl", hash = "sha256:849d52b737e10a6dc6621d2bd7940ec7c65fcb69e6aa2882acf4e56b2b508ddc", size = 17892, upload-time = "2025-08-22T03:01:59.436Z" }, -] - [[package]] name = "types-pytz" version = "2025.2.0.20250809" @@ -1555,59 +1664,52 @@ wheels = [ [[package]] name = "unraid-mcp" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, { name = "fastmcp" }, { name = "httpx" }, - { name = "mypy" }, { name = "python-dotenv" }, { name = "pytz" }, { name = "rich" }, - { name = "ruff" }, { name = "uvicorn" }, { name = "websockets" }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ { name = "black" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, - { name = "types-python-dateutil" }, -] - -[package.dev-dependencies] -dev = [ { name = "types-pytz" }, ] [package.metadata] requires-dist = [ - { name = "black", marker = "extra == 'dev'", specifier = ">=25.1.0" }, { name = "fastapi", specifier = ">=0.116.1" }, { name = "fastmcp", specifier = ">=2.11.2" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "mypy", specifier = ">=1.17.1" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.17.1" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.1.0" }, { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "pytz", specifier = ">=2025.2" }, { name = "rich", specifier = ">=14.1.0" }, - { name = "ruff", specifier = ">=0.12.8" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12.8" }, - { name = "types-python-dateutil", marker = "extra == 'dev'" }, { name = "uvicorn", specifier = ">=0.35.0" }, { name = "websockets", specifier = ">=13.1,<14.0" }, ] -provides-extras = ["dev"] [package.metadata.requires-dev] -dev = [{ name = "types-pytz", specifier = ">=2025.2.0.20250809" }] +dev = [ + { name = "black", specifier = ">=25.1.0" }, + { name = "mypy", specifier = ">=1.17.1" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.12.8" }, + { name = "types-pytz", specifier = ">=2025.2.0.20250809" }, +] [[package]] name = "urllib3"