From 4b43c470913453361b64bde641d85c5f3cded084 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Sun, 15 Mar 2026 23:21:25 -0400 Subject: [PATCH] fix(tools): remove 10 dead actions referencing mutations absent from live API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.py: drop update_temperature, update_time, update_api, connect_sign_in, connect_sign_out, setup_remote_access, enable_dynamic_remote_access, update_ssh — all 8 reference mutations confirmed absent from Unraid API v4.29.2. Keep update + configure_ups. info.py: drop update_server (updateServerIdentity not in Mutation type) and update_ssh (duplicate of removed settings action). MUTATIONS is now empty; DESTRUCTIVE_ACTIONS is now an empty set. notifications.py: drop create_unique (notifyIfUnique not in Mutation type). Tests: remove corresponding test classes, add parametrized regression tests asserting removed actions are not in each tool's Literal type, update KNOWN_DESTRUCTIVE and _DESTRUCTIVE_TEST_CASES in safety audit, update schema coverage assertions. 858 tests passing, 0 failures. --- tests/safety/test_destructive_guards.py | 14 -- tests/schema/test_query_validation.py | 9 - tests/test_info.py | 67 +----- tests/test_notifications.py | 40 +--- tests/test_settings.py | 260 ++++-------------------- unraid_mcp/tools/info.py | 64 +----- unraid_mcp/tools/notifications.py | 37 ---- unraid_mcp/tools/settings.py | 229 +-------------------- 8 files changed, 59 insertions(+), 661 deletions(-) diff --git a/tests/safety/test_destructive_guards.py b/tests/safety/test_destructive_guards.py index 72611ee..fe80309 100644 --- a/tests/safety/test_destructive_guards.py +++ b/tests/safety/test_destructive_guards.py @@ -91,9 +91,6 @@ KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str] | str]] = { "tool_name": "unraid_settings", "actions": { "configure_ups", - "setup_remote_access", - "enable_dynamic_remote_access", - "update_ssh", }, "runtime_set": SETTINGS_DESTRUCTIVE, }, @@ -222,7 +219,6 @@ _DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [ ), # Settings ("settings", "configure_ups", {"ups_config": {"mode": "slave"}}), - ("settings", "update_ssh", {"ssh_enabled": True, "ssh_port": 22}), # Plugins ("plugins", "remove", {"names": ["my-plugin"]}), ] @@ -461,16 +457,6 @@ class TestConfirmAllowsExecution: ) assert result["success"] is True - async def test_settings_update_ssh_with_confirm( - self, _mock_settings_graphql: AsyncMock - ) -> None: - _mock_settings_graphql.return_value = {"updateSshSettings": {"useSsh": True, "portssh": 22}} - tool_fn = make_tool_fn( - "unraid_mcp.tools.settings", "register_settings_tool", "unraid_settings" - ) - result = await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True, ssh_port=22) - assert result["success"] is True - async def test_array_remove_disk_with_confirm(self, _mock_array_graphql: AsyncMock) -> None: _mock_array_graphql.return_value = {"array": {"removeDiskFromArray": {"state": "STOPPED"}}} tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array") diff --git a/tests/schema/test_query_validation.py b/tests/schema/test_query_validation.py index 484e281..950ab2f 100644 --- a/tests/schema/test_query_validation.py +++ b/tests/schema/test_query_validation.py @@ -575,7 +575,6 @@ class TestNotificationMutations: expected = { "create", - "create_unique", "archive", "unread", "delete", @@ -739,14 +738,6 @@ class TestSettingsMutations: expected = { "update", "configure_ups", - "update_temperature", - "update_time", - "update_api", - "connect_sign_in", - "connect_sign_out", - "setup_remote_access", - "enable_dynamic_remote_access", - "update_ssh", } assert set(MUTATIONS.keys()) == expected diff --git a/tests/test_info.py b/tests/test_info.py index 54e4259..e79dd39 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -1,6 +1,7 @@ """Tests for unraid_info tool.""" from collections.abc import Generator +from typing import get_args from unittest.mock import AsyncMock, patch import pytest @@ -8,6 +9,7 @@ from conftest import make_tool_fn from unraid_mcp.core.exceptions import ToolError from unraid_mcp.tools.info import ( + INFO_ACTIONS, _analyze_disk_health, _process_array_status, _process_system_info, @@ -303,62 +305,13 @@ class TestInfoNetworkErrors: await tool_fn(action="network") -class TestInfoMutations: - async def test_update_server_requires_name(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="server_name"): - await tool_fn(action="update_server") +# --------------------------------------------------------------------------- +# Regression: removed actions must not appear in INFO_ACTIONS +# --------------------------------------------------------------------------- - async def test_update_server_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "updateServerIdentity": { - "id": "s:1", - "name": "tootie", - "comment": None, - "status": "online", - } - } - tool_fn = _make_tool() - result = await tool_fn(action="update_server", server_name="tootie") - assert result["success"] is True - assert result["data"]["name"] == "tootie" - async def test_update_server_passes_optional_fields(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "updateServerIdentity": {"id": "s:1", "name": "x", "comment": None, "status": "online"} - } - tool_fn = _make_tool() - await tool_fn(action="update_server", server_name="x", sys_model="custom") - assert _mock_graphql.call_args[0][1]["sysModel"] == "custom" - - async def test_update_ssh_requires_confirm(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="confirm=True"): - await tool_fn(action="update_ssh", ssh_enabled=True, ssh_port=22) - - async def test_update_ssh_requires_enabled(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="ssh_enabled"): - await tool_fn(action="update_ssh", confirm=True, ssh_port=22) - - async def test_update_ssh_requires_port(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="ssh_port"): - await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True) - - async def test_update_ssh_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "updateSshSettings": {"id": "s:1", "useSsh": True, "portssh": 22} - } - tool_fn = _make_tool() - result = await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True, ssh_port=22) - assert result["success"] is True - assert result["data"]["useSsh"] is True - - async def test_update_ssh_passes_correct_input(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "updateSshSettings": {"id": "s:1", "useSsh": False, "portssh": 2222} - } - tool_fn = _make_tool() - await tool_fn(action="update_ssh", confirm=True, ssh_enabled=False, ssh_port=2222) - assert _mock_graphql.call_args[0][1] == {"input": {"enabled": False, "port": 2222}} +@pytest.mark.parametrize("action", ["update_server", "update_ssh"]) +def test_removed_info_actions_are_gone(action: str) -> None: + assert action not in get_args(INFO_ACTIONS), ( + f"{action} references a non-existent mutation and must not be in INFO_ACTIONS" + ) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 70aec8d..4472c4a 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -17,6 +17,12 @@ def test_warnings_action_removed() -> None: ) +def test_create_unique_action_removed() -> None: + assert "create_unique" not in get_args(NOTIFICATION_ACTIONS), ( + "create_unique references notifyIfUnique which is not in live API" + ) + + @pytest.fixture def _mock_graphql() -> Generator[AsyncMock, None, None]: with patch( @@ -265,40 +271,6 @@ class TestNewNotificationMutations: with pytest.raises(ToolError, match="notification_ids"): await tool_fn(action="archive_many") - async def test_create_unique_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "notifyIfUnique": {"id": "n:1", "title": "Test", "importance": "INFO"} - } - tool_fn = _make_tool() - result = await tool_fn( - action="create_unique", - title="Test", - subject="Subj", - description="Desc", - importance="info", - ) - assert result["success"] is True - - async def test_create_unique_returns_none_when_duplicate( - self, _mock_graphql: AsyncMock - ) -> None: - _mock_graphql.return_value = {"notifyIfUnique": None} - tool_fn = _make_tool() - result = await tool_fn( - action="create_unique", - title="T", - subject="S", - description="D", - importance="info", - ) - assert result["success"] is True - assert result["duplicate"] is True - - async def test_create_unique_requires_fields(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="requires title"): - await tool_fn(action="create_unique") - async def test_unarchive_many_success(self, _mock_graphql: AsyncMock) -> None: _mock_graphql.return_value = { "unarchiveNotifications": { diff --git a/tests/test_settings.py b/tests/test_settings.py index f9d5393..17900f5 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,15 +1,14 @@ """Tests for the unraid_settings tool.""" -from __future__ import annotations - from collections.abc import Generator +from typing import get_args from unittest.mock import AsyncMock, patch import pytest from fastmcp import FastMCP from unraid_mcp.core.exceptions import ToolError -from unraid_mcp.tools.settings import register_settings_tool +from unraid_mcp.tools.settings import SETTINGS_ACTIONS, register_settings_tool @pytest.fixture @@ -27,6 +26,35 @@ def _make_tool() -> AsyncMock: return tool.fn # type: ignore[union-attr] +# --------------------------------------------------------------------------- +# Regression: removed actions must not appear in SETTINGS_ACTIONS +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "action", + [ + "update_temperature", + "update_time", + "update_api", + "connect_sign_in", + "connect_sign_out", + "setup_remote_access", + "enable_dynamic_remote_access", + "update_ssh", + ], +) +def test_removed_settings_actions_are_gone(action: str) -> None: + assert action not in get_args(SETTINGS_ACTIONS), ( + f"{action} references a non-existent mutation and must not be in SETTINGS_ACTIONS" + ) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + class TestSettingsValidation: """Tests for action validation and destructive guard.""" @@ -42,26 +70,10 @@ class TestSettingsValidation: with pytest.raises(ToolError, match="confirm=True"): await tool_fn(action="configure_ups", ups_config={"mode": "slave"}) - async def test_destructive_setup_remote_access_requires_confirm( - self, _mock_graphql: AsyncMock - ) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="confirm=True"): - await tool_fn(action="setup_remote_access", access_type="STATIC") - async def test_destructive_enable_dynamic_remote_access_requires_confirm( - self, _mock_graphql: AsyncMock - ) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="confirm=True"): - await tool_fn( - action="enable_dynamic_remote_access", access_url_type="WAN", dynamic_enabled=True - ) - - async def test_destructive_update_ssh_requires_confirm(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="confirm=True"): - await tool_fn(action="update_ssh", ssh_enabled=True, ssh_port=22) +# --------------------------------------------------------------------------- +# update +# --------------------------------------------------------------------------- class TestSettingsUpdate: @@ -81,54 +93,10 @@ class TestSettingsUpdate: assert result["success"] is True assert result["action"] == "update" - async def test_update_temperature_requires_config(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="temperature_config is required"): - await tool_fn(action="update_temperature") - async def test_update_temperature_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"updateTemperatureConfig": True} - tool_fn = _make_tool() - result = await tool_fn(action="update_temperature", temperature_config={"unit": "C"}) - assert result["success"] is True - assert result["action"] == "update_temperature" - - -class TestSystemTime: - """Tests for update_time action.""" - - async def test_update_time_requires_at_least_one_field(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="update_time requires"): - await tool_fn(action="update_time") - - async def test_update_time_with_timezone(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "updateSystemTime": { - "currentTime": "2026-03-13T00:00:00Z", - "timeZone": "America/New_York", - "useNtp": True, - "ntpServers": [], - } - } - tool_fn = _make_tool() - result = await tool_fn(action="update_time", time_zone="America/New_York") - assert result["success"] is True - assert result["action"] == "update_time" - - async def test_update_time_with_ntp(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "updateSystemTime": {"useNtp": True, "ntpServers": ["0.pool.ntp.org"]} - } - tool_fn = _make_tool() - result = await tool_fn(action="update_time", use_ntp=True, ntp_servers=["0.pool.ntp.org"]) - assert result["success"] is True - - async def test_update_time_manual(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"updateSystemTime": {"currentTime": "2026-03-13T12:00:00Z"}} - tool_fn = _make_tool() - result = await tool_fn(action="update_time", manual_datetime="2026-03-13T12:00:00Z") - assert result["success"] is True +# --------------------------------------------------------------------------- +# configure_ups +# --------------------------------------------------------------------------- class TestUpsConfig: @@ -147,157 +115,3 @@ class TestUpsConfig: ) assert result["success"] is True assert result["action"] == "configure_ups" - - -class TestApiSettings: - """Tests for update_api action.""" - - async def test_update_api_requires_at_least_one_field(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="update_api requires"): - await tool_fn(action="update_api") - - async def test_update_api_with_port(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = { - "updateApiSettings": {"accessType": "STATIC", "forwardType": "NONE", "port": 8080} - } - tool_fn = _make_tool() - result = await tool_fn(action="update_api", port=8080) - assert result["success"] is True - assert result["action"] == "update_api" - - async def test_update_api_with_access_type(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"updateApiSettings": {"accessType": "STATIC"}} - tool_fn = _make_tool() - result = await tool_fn(action="update_api", access_type="STATIC") - assert result["success"] is True - - -class TestConnectActions: - """Tests for connect_sign_in and connect_sign_out actions.""" - - async def test_connect_sign_in_requires_api_key(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="api_key is required"): - await tool_fn(action="connect_sign_in") - - async def test_connect_sign_in_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"connectSignIn": True} - tool_fn = _make_tool() - result = await tool_fn(action="connect_sign_in", api_key="test-api-key-abc123") - assert result["success"] is True - assert result["action"] == "connect_sign_in" - - async def test_connect_sign_in_with_user_info(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"connectSignIn": True} - tool_fn = _make_tool() - result = await tool_fn( - action="connect_sign_in", - api_key="test-api-key", - username="testuser", - email="test@example.com", - avatar="https://example.com/avatar.png", - ) - assert result["success"] is True - - async def test_connect_sign_out_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"connectSignOut": True} - tool_fn = _make_tool() - result = await tool_fn(action="connect_sign_out") - assert result["success"] is True - assert result["action"] == "connect_sign_out" - - -class TestRemoteAccess: - """Tests for setup_remote_access and enable_dynamic_remote_access actions.""" - - async def test_setup_remote_access_requires_access_type(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="access_type is required"): - await tool_fn(action="setup_remote_access", confirm=True) - - async def test_setup_remote_access_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"setupRemoteAccess": True} - tool_fn = _make_tool() - result = await tool_fn(action="setup_remote_access", confirm=True, access_type="STATIC") - assert result["success"] is True - assert result["action"] == "setup_remote_access" - - async def test_setup_remote_access_with_port(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"setupRemoteAccess": True} - tool_fn = _make_tool() - result = await tool_fn( - action="setup_remote_access", - confirm=True, - access_type="STATIC", - forward_type="UPNP", - port=9999, - ) - assert result["success"] is True - - async def test_enable_dynamic_requires_url_type(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="access_url_type is required"): - await tool_fn(action="enable_dynamic_remote_access", confirm=True, dynamic_enabled=True) - - async def test_enable_dynamic_requires_dynamic_enabled(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="dynamic_enabled is required"): - await tool_fn( - action="enable_dynamic_remote_access", confirm=True, access_url_type="WAN" - ) - - async def test_enable_dynamic_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"enableDynamicRemoteAccess": True} - tool_fn = _make_tool() - result = await tool_fn( - action="enable_dynamic_remote_access", - confirm=True, - access_url_type="WAN", - dynamic_enabled=True, - ) - assert result["success"] is True - assert result["action"] == "enable_dynamic_remote_access" - - async def test_enable_dynamic_with_optional_fields(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"enableDynamicRemoteAccess": True} - tool_fn = _make_tool() - result = await tool_fn( - action="enable_dynamic_remote_access", - confirm=True, - access_url_type="WAN", - dynamic_enabled=False, - access_url_name="myserver", - access_url_ipv4="1.2.3.4", - access_url_ipv6="::1", - ) - assert result["success"] is True - - -class TestSshSettings: - """Tests for update_ssh action.""" - - async def test_update_ssh_requires_ssh_enabled(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="ssh_enabled is required"): - await tool_fn(action="update_ssh", confirm=True, ssh_port=22) - - async def test_update_ssh_requires_ssh_port(self, _mock_graphql: AsyncMock) -> None: - tool_fn = _make_tool() - with pytest.raises(ToolError, match="ssh_port is required"): - await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True) - - async def test_update_ssh_success(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"updateSshSettings": {"useSsh": True, "portssh": 22}} - tool_fn = _make_tool() - result = await tool_fn(action="update_ssh", confirm=True, ssh_enabled=True, ssh_port=22) - assert result["success"] is True - assert result["action"] == "update_ssh" - - async def test_update_ssh_disable(self, _mock_graphql: AsyncMock) -> None: - _mock_graphql.return_value = {"updateSshSettings": {"useSsh": False, "portssh": 22}} - tool_fn = _make_tool() - result = await tool_fn(action="update_ssh", confirm=True, ssh_enabled=False, ssh_port=22) - assert result["success"] is True - call_vars = _mock_graphql.call_args[0][1] - assert call_vars["input"] == {"enabled": False, "port": 22} diff --git a/unraid_mcp/tools/info.py b/unraid_mcp/tools/info.py index 9a9b5ba..fef6799 100644 --- a/unraid_mcp/tools/info.py +++ b/unraid_mcp/tools/info.py @@ -153,22 +153,9 @@ QUERIES: dict[str, str] = { """, } -MUTATIONS: dict[str, str] = { - "update_server": """ - mutation UpdateServerIdentity($name: String!, $comment: String, $sysModel: String) { - updateServerIdentity(name: $name, comment: $comment, sysModel: $sysModel) { - id name comment status - } - } - """, - "update_ssh": """ - mutation UpdateSshSettings($input: UpdateSshInput!) { - updateSshSettings(input: $input) { id useSsh portssh } - } - """, -} +MUTATIONS: dict[str, str] = {} -DESTRUCTIVE_ACTIONS = {"update_ssh"} +DESTRUCTIVE_ACTIONS: set[str] = set() ALL_ACTIONS = set(QUERIES) | set(MUTATIONS) INFO_ACTIONS = Literal[ @@ -191,8 +178,6 @@ INFO_ACTIONS = Literal[ "ups_devices", "ups_device", "ups_config", - "update_server", - "update_ssh", ] if set(get_args(INFO_ACTIONS)) != ALL_ACTIONS: @@ -324,13 +309,7 @@ def register_info_tool(mcp: FastMCP) -> None: @mcp.tool() async def unraid_info( action: INFO_ACTIONS, - confirm: bool = False, device_id: str | None = None, - server_name: str | None = None, - server_comment: str | None = None, - sys_model: str | None = None, - ssh_enabled: bool | None = None, - ssh_port: int | None = None, ) -> dict[str, Any]: """Query Unraid system information. @@ -354,52 +333,13 @@ def register_info_tool(mcp: FastMCP) -> None: ups_devices - List UPS devices ups_device - Single UPS device (requires device_id) ups_config - UPS configuration - update_server - Update server name, comment, and model (requires server_name) - update_ssh - Enable/disable SSH and set port (requires ssh_enabled, ssh_port) """ 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 == "ups_device" and not device_id: raise ToolError("device_id is required for ups_device action") - # Mutation handlers — must return before query = QUERIES[action] - if action == "update_server": - if server_name is None: - raise ToolError("server_name is required for 'update_server' action") - variables_mut: dict[str, Any] = {"name": server_name} - if server_comment is not None: - variables_mut["comment"] = server_comment - if sys_model is not None: - variables_mut["sysModel"] = sys_model - with tool_error_handler("info", action, logger): - logger.info("Executing unraid_info action=update_server") - data = await make_graphql_request(MUTATIONS["update_server"], variables_mut) - return { - "success": True, - "action": "update_server", - "data": data.get("updateServerIdentity"), - } - - if action == "update_ssh": - if ssh_enabled is None: - raise ToolError("ssh_enabled is required for 'update_ssh' action") - if ssh_port is None: - raise ToolError("ssh_port is required for 'update_ssh' action") - with tool_error_handler("info", action, logger): - logger.info("Executing unraid_info action=update_ssh") - data = await make_graphql_request( - MUTATIONS["update_ssh"], {"input": {"enabled": ssh_enabled, "port": ssh_port}} - ) - return { - "success": True, - "action": "update_ssh", - "data": data.get("updateSshSettings"), - } - # connect is not available on all Unraid API versions if action == "connect": raise ToolError( diff --git a/unraid_mcp/tools/notifications.py b/unraid_mcp/tools/notifications.py index 82051c5..7ad7fb6 100644 --- a/unraid_mcp/tools/notifications.py +++ b/unraid_mcp/tools/notifications.py @@ -83,11 +83,6 @@ MUTATIONS: dict[str, str] = { } } """, - "create_unique": """ - mutation NotifyIfUnique($input: NotificationData!) { - notifyIfUnique(input: $input) { id title importance } - } - """, "unarchive_many": """ mutation UnarchiveNotifications($ids: [PrefixedID!]!) { unarchiveNotifications(ids: $ids) { @@ -128,7 +123,6 @@ NOTIFICATION_ACTIONS = Literal[ "delete_archived", "archive_all", "archive_many", - "create_unique", "unarchive_many", "unarchive_all", "recalculate", @@ -173,7 +167,6 @@ def register_notifications_tool(mcp: FastMCP) -> None: delete_archived - Delete all archived notifications (requires confirm=True) archive_all - Archive all notifications (optional importance filter) archive_many - Archive multiple notifications by ID (requires notification_ids) - create_unique - Create notification only if no equivalent unread exists (requires title, subject, description, importance) unarchive_many - Move notifications back to unread (requires notification_ids) unarchive_all - Move all archived notifications to unread (optional importance filter) recalculate - Recompute overview counts from disk @@ -284,36 +277,6 @@ def register_notifications_tool(mcp: FastMCP) -> None: ) return {"success": True, "action": "archive_many", "data": data} - if action == "create_unique": - if title is None or subject is None or description is None or importance is None: - raise ToolError( - "create_unique requires title, subject, description, and importance" - ) - if importance.upper() not in _VALID_IMPORTANCE: - raise ToolError( - f"importance must be one of: {', '.join(sorted(_VALID_IMPORTANCE))}. " - f"Got: '{importance}'" - ) - if len(title) > 200: - raise ToolError(f"title must be at most 200 characters (got {len(title)})") - if len(subject) > 500: - raise ToolError(f"subject must be at most 500 characters (got {len(subject)})") - if len(description) > 2000: - raise ToolError( - f"description must be at most 2000 characters (got {len(description)})" - ) - input_data = { - "title": title, - "subject": subject, - "description": description, - "importance": importance.upper(), - } - data = await make_graphql_request(MUTATIONS["create_unique"], {"input": input_data}) - notification = data.get("notifyIfUnique") - if notification is None: - return {"success": True, "duplicate": True, "data": None} - return {"success": True, "duplicate": False, "data": notification} - if action == "unarchive_many": if not notification_ids: raise ToolError("notification_ids is required for 'unarchive_many' action") diff --git a/unraid_mcp/tools/settings.py b/unraid_mcp/tools/settings.py index 5100314..f3291a8 100644 --- a/unraid_mcp/tools/settings.py +++ b/unraid_mcp/tools/settings.py @@ -1,7 +1,7 @@ -"""System settings, time, UPS, and remote access mutations. +"""System settings and UPS mutations. -Provides the `unraid_settings` tool with 9 actions for updating system -configuration, time settings, UPS, API settings, and Unraid Connect. +Provides the `unraid_settings` tool with 2 actions for updating system +configuration and UPS monitoring. """ from typing import Any, Literal, get_args @@ -19,72 +19,21 @@ MUTATIONS: dict[str, str] = { updateSettings(input: $input) { restartRequired values warnings } } """, - "update_temperature": """ - mutation UpdateTemperatureConfig($input: TemperatureConfigInput!) { - updateTemperatureConfig(input: $input) - } - """, - "update_time": """ - mutation UpdateSystemTime($input: UpdateSystemTimeInput!) { - updateSystemTime(input: $input) { currentTime timeZone useNtp ntpServers } - } - """, "configure_ups": """ mutation ConfigureUps($config: UPSConfigInput!) { configureUps(config: $config) } """, - "update_api": """ - mutation UpdateApiSettings($input: ConnectSettingsInput!) { - updateApiSettings(input: $input) { accessType forwardType port } - } - """, - "connect_sign_in": """ - mutation ConnectSignIn($input: ConnectSignInInput!) { - connectSignIn(input: $input) - } - """, - "connect_sign_out": """ - mutation ConnectSignOut { - connectSignOut - } - """, - "setup_remote_access": """ - mutation SetupRemoteAccess($input: SetupRemoteAccessInput!) { - setupRemoteAccess(input: $input) - } - """, - "enable_dynamic_remote_access": """ - mutation EnableDynamicRemoteAccess($input: EnableDynamicRemoteAccessInput!) { - enableDynamicRemoteAccess(input: $input) - } - """, - "update_ssh": """ - mutation UpdateSshSettings($input: UpdateSshInput!) { - updateSshSettings(input: $input) { useSsh portssh } - } - """, } DESTRUCTIVE_ACTIONS = { "configure_ups", - "setup_remote_access", - "enable_dynamic_remote_access", - "update_ssh", } ALL_ACTIONS = set(MUTATIONS) SETTINGS_ACTIONS = Literal[ "configure_ups", - "connect_sign_in", - "connect_sign_out", - "enable_dynamic_remote_access", - "setup_remote_access", "update", - "update_api", - "update_ssh", - "update_temperature", - "update_time", ] if set(get_args(SETTINGS_ACTIONS)) != ALL_ACTIONS: @@ -104,40 +53,13 @@ def register_settings_tool(mcp: FastMCP) -> None: action: SETTINGS_ACTIONS, confirm: bool = False, settings_input: dict[str, Any] | None = None, - temperature_config: dict[str, Any] | None = None, - time_zone: str | None = None, - use_ntp: bool | None = None, - ntp_servers: list[str] | None = None, - manual_datetime: str | None = None, ups_config: dict[str, Any] | None = None, - access_type: str | None = None, - forward_type: str | None = None, - port: int | None = None, - api_key: str | None = None, - username: str | None = None, - email: str | None = None, - avatar: str | None = None, - access_url_type: str | None = None, - access_url_name: str | None = None, - access_url_ipv4: str | None = None, - access_url_ipv6: str | None = None, - dynamic_enabled: bool | None = None, - ssh_enabled: bool | None = None, - ssh_port: int | None = None, ) -> dict[str, Any]: - """Update Unraid system settings, time, UPS, and remote access configuration. + """Update Unraid system settings and UPS configuration. Actions: update - Update system settings (requires settings_input dict) - update_temperature - Update temperature sensor config (requires temperature_config dict) - update_time - Update time/timezone/NTP (requires at least one of: time_zone, use_ntp, ntp_servers, manual_datetime) configure_ups - Configure UPS monitoring (requires ups_config dict, confirm=True) - update_api - Update API/Connect settings (requires at least one of: access_type, forward_type, port) - connect_sign_in - Sign in to Unraid Connect (requires api_key) - connect_sign_out - Sign out from Unraid Connect - setup_remote_access - Configure remote access (requires access_type, confirm=True) - enable_dynamic_remote_access - Enable/disable dynamic remote access (requires access_url_type, dynamic_enabled, confirm=True) - update_ssh - Enable/disable SSH and set port (requires ssh_enabled, ssh_port, confirm=True) """ if action not in ALL_ACTIONS: raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(ALL_ACTIONS)}") @@ -154,41 +76,6 @@ def register_settings_tool(mcp: FastMCP) -> None: data = await make_graphql_request(MUTATIONS["update"], {"input": settings_input}) return {"success": True, "action": "update", "data": data.get("updateSettings")} - if action == "update_temperature": - if temperature_config is None: - raise ToolError( - "temperature_config is required for 'update_temperature' action" - ) - data = await make_graphql_request( - MUTATIONS["update_temperature"], {"input": temperature_config} - ) - return { - "success": True, - "action": "update_temperature", - "result": data.get("updateTemperatureConfig"), - } - - if action == "update_time": - time_input: dict[str, Any] = {} - if time_zone is not None: - time_input["timeZone"] = time_zone - if use_ntp is not None: - time_input["useNtp"] = use_ntp - if ntp_servers is not None: - time_input["ntpServers"] = ntp_servers - if manual_datetime is not None: - time_input["manualDateTime"] = manual_datetime - if not time_input: - raise ToolError( - "update_time requires at least one of: time_zone, use_ntp, ntp_servers, manual_datetime" - ) - data = await make_graphql_request(MUTATIONS["update_time"], {"input": time_input}) - return { - "success": True, - "action": "update_time", - "data": data.get("updateSystemTime"), - } - if action == "configure_ups": if ups_config is None: raise ToolError("ups_config is required for 'configure_ups' action") @@ -201,114 +88,6 @@ def register_settings_tool(mcp: FastMCP) -> None: "result": data.get("configureUps"), } - if action == "update_api": - api_input: dict[str, Any] = {} - if access_type is not None: - api_input["accessType"] = access_type - if forward_type is not None: - api_input["forwardType"] = forward_type - if port is not None: - api_input["port"] = port - if not api_input: - raise ToolError( - "update_api requires at least one of: access_type, forward_type, port" - ) - data = await make_graphql_request(MUTATIONS["update_api"], {"input": api_input}) - return { - "success": True, - "action": "update_api", - "data": data.get("updateApiSettings"), - } - - if action == "connect_sign_in": - if not api_key: - raise ToolError("api_key is required for 'connect_sign_in' action") - sign_in_input: dict[str, Any] = {"apiKey": api_key} - user_info: dict[str, Any] = {} - if username: - user_info["preferred_username"] = username - if email: - user_info["email"] = email - if avatar: - user_info["avatar"] = avatar - if user_info: - sign_in_input["userInfo"] = user_info - data = await make_graphql_request( - MUTATIONS["connect_sign_in"], {"input": sign_in_input} - ) - return { - "success": True, - "action": "connect_sign_in", - "result": data.get("connectSignIn"), - } - - if action == "connect_sign_out": - data = await make_graphql_request(MUTATIONS["connect_sign_out"]) - return { - "success": True, - "action": "connect_sign_out", - "result": data.get("connectSignOut"), - } - - if action == "setup_remote_access": - if not access_type: - raise ToolError("access_type is required for 'setup_remote_access' action") - remote_input: dict[str, Any] = {"accessType": access_type} - if forward_type is not None: - remote_input["forwardType"] = forward_type - if port is not None: - remote_input["port"] = port - data = await make_graphql_request( - MUTATIONS["setup_remote_access"], {"input": remote_input} - ) - return { - "success": True, - "action": "setup_remote_access", - "result": data.get("setupRemoteAccess"), - } - - if action == "enable_dynamic_remote_access": - if not access_url_type: - raise ToolError( - "access_url_type is required for 'enable_dynamic_remote_access' action" - ) - if dynamic_enabled is None: - raise ToolError( - "dynamic_enabled is required for 'enable_dynamic_remote_access' action" - ) - url_input: dict[str, Any] = {"type": access_url_type} - if access_url_name is not None: - url_input["name"] = access_url_name - if access_url_ipv4 is not None: - url_input["ipv4"] = access_url_ipv4 - if access_url_ipv6 is not None: - url_input["ipv6"] = access_url_ipv6 - dra_vars = {"input": {"url": url_input, "enabled": dynamic_enabled}} - data = await make_graphql_request( - MUTATIONS["enable_dynamic_remote_access"], - dra_vars, - ) - return { - "success": True, - "action": "enable_dynamic_remote_access", - "result": data.get("enableDynamicRemoteAccess"), - } - - if action == "update_ssh": - if ssh_enabled is None: - raise ToolError("ssh_enabled is required for 'update_ssh' action") - if ssh_port is None: - raise ToolError("ssh_port is required for 'update_ssh' action") - data = await make_graphql_request( - MUTATIONS["update_ssh"], - {"input": {"enabled": ssh_enabled, "port": ssh_port}}, - ) - return { - "success": True, - "action": "update_ssh", - "data": data.get("updateSshSettings"), - } - raise ToolError(f"Unhandled action '{action}' — this is a bug") logger.info("Settings tool registered successfully")