fix(tools): remove 10 dead actions referencing mutations absent from live API

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.
This commit is contained in:
Jacob Magar
2026-03-15 23:21:25 -04:00
parent c37d4b1c5a
commit 4b43c47091
8 changed files with 59 additions and 661 deletions

View File

@@ -91,9 +91,6 @@ KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str] | str]] = {
"tool_name": "unraid_settings", "tool_name": "unraid_settings",
"actions": { "actions": {
"configure_ups", "configure_ups",
"setup_remote_access",
"enable_dynamic_remote_access",
"update_ssh",
}, },
"runtime_set": SETTINGS_DESTRUCTIVE, "runtime_set": SETTINGS_DESTRUCTIVE,
}, },
@@ -222,7 +219,6 @@ _DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [
), ),
# Settings # Settings
("settings", "configure_ups", {"ups_config": {"mode": "slave"}}), ("settings", "configure_ups", {"ups_config": {"mode": "slave"}}),
("settings", "update_ssh", {"ssh_enabled": True, "ssh_port": 22}),
# Plugins # Plugins
("plugins", "remove", {"names": ["my-plugin"]}), ("plugins", "remove", {"names": ["my-plugin"]}),
] ]
@@ -461,16 +457,6 @@ class TestConfirmAllowsExecution:
) )
assert result["success"] is True 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: async def test_array_remove_disk_with_confirm(self, _mock_array_graphql: AsyncMock) -> None:
_mock_array_graphql.return_value = {"array": {"removeDiskFromArray": {"state": "STOPPED"}}} _mock_array_graphql.return_value = {"array": {"removeDiskFromArray": {"state": "STOPPED"}}}
tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array") tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array")

View File

@@ -575,7 +575,6 @@ class TestNotificationMutations:
expected = { expected = {
"create", "create",
"create_unique",
"archive", "archive",
"unread", "unread",
"delete", "delete",
@@ -739,14 +738,6 @@ class TestSettingsMutations:
expected = { expected = {
"update", "update",
"configure_ups", "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 assert set(MUTATIONS.keys()) == expected

View File

@@ -1,6 +1,7 @@
"""Tests for unraid_info tool.""" """Tests for unraid_info tool."""
from collections.abc import Generator from collections.abc import Generator
from typing import get_args
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@@ -8,6 +9,7 @@ from conftest import make_tool_fn
from unraid_mcp.core.exceptions import ToolError from unraid_mcp.core.exceptions import ToolError
from unraid_mcp.tools.info import ( from unraid_mcp.tools.info import (
INFO_ACTIONS,
_analyze_disk_health, _analyze_disk_health,
_process_array_status, _process_array_status,
_process_system_info, _process_system_info,
@@ -303,62 +305,13 @@ class TestInfoNetworkErrors:
await tool_fn(action="network") await tool_fn(action="network")
class TestInfoMutations: # ---------------------------------------------------------------------------
async def test_update_server_requires_name(self, _mock_graphql: AsyncMock) -> None: # Regression: removed actions must not appear in INFO_ACTIONS
tool_fn = _make_tool() # ---------------------------------------------------------------------------
with pytest.raises(ToolError, match="server_name"):
await tool_fn(action="update_server")
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: @pytest.mark.parametrize("action", ["update_server", "update_ssh"])
_mock_graphql.return_value = { def test_removed_info_actions_are_gone(action: str) -> None:
"updateServerIdentity": {"id": "s:1", "name": "x", "comment": None, "status": "online"} assert action not in get_args(INFO_ACTIONS), (
} f"{action} references a non-existent mutation and must not be in INFO_ACTIONS"
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}}

View File

@@ -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 @pytest.fixture
def _mock_graphql() -> Generator[AsyncMock, None, None]: def _mock_graphql() -> Generator[AsyncMock, None, None]:
with patch( with patch(
@@ -265,40 +271,6 @@ class TestNewNotificationMutations:
with pytest.raises(ToolError, match="notification_ids"): with pytest.raises(ToolError, match="notification_ids"):
await tool_fn(action="archive_many") 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: async def test_unarchive_many_success(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = { _mock_graphql.return_value = {
"unarchiveNotifications": { "unarchiveNotifications": {

View File

@@ -1,15 +1,14 @@
"""Tests for the unraid_settings tool.""" """Tests for the unraid_settings tool."""
from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from typing import get_args
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from fastmcp import FastMCP from fastmcp import FastMCP
from unraid_mcp.core.exceptions import ToolError 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 @pytest.fixture
@@ -27,6 +26,35 @@ def _make_tool() -> AsyncMock:
return tool.fn # type: ignore[union-attr] 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: class TestSettingsValidation:
"""Tests for action validation and destructive guard.""" """Tests for action validation and destructive guard."""
@@ -42,26 +70,10 @@ class TestSettingsValidation:
with pytest.raises(ToolError, match="confirm=True"): with pytest.raises(ToolError, match="confirm=True"):
await tool_fn(action="configure_ups", ups_config={"mode": "slave"}) 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 # update
) -> 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)
class TestSettingsUpdate: class TestSettingsUpdate:
@@ -81,54 +93,10 @@ class TestSettingsUpdate:
assert result["success"] is True assert result["success"] is True
assert result["action"] == "update" 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} # configure_ups
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
class TestUpsConfig: class TestUpsConfig:
@@ -147,157 +115,3 @@ class TestUpsConfig:
) )
assert result["success"] is True assert result["success"] is True
assert result["action"] == "configure_ups" 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}

View File

@@ -153,22 +153,9 @@ QUERIES: dict[str, str] = {
""", """,
} }
MUTATIONS: 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 }
}
""",
}
DESTRUCTIVE_ACTIONS = {"update_ssh"} DESTRUCTIVE_ACTIONS: set[str] = set()
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS) ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
INFO_ACTIONS = Literal[ INFO_ACTIONS = Literal[
@@ -191,8 +178,6 @@ INFO_ACTIONS = Literal[
"ups_devices", "ups_devices",
"ups_device", "ups_device",
"ups_config", "ups_config",
"update_server",
"update_ssh",
] ]
if set(get_args(INFO_ACTIONS)) != ALL_ACTIONS: if set(get_args(INFO_ACTIONS)) != ALL_ACTIONS:
@@ -324,13 +309,7 @@ def register_info_tool(mcp: FastMCP) -> None:
@mcp.tool() @mcp.tool()
async def unraid_info( async def unraid_info(
action: INFO_ACTIONS, action: INFO_ACTIONS,
confirm: bool = False,
device_id: str | None = None, 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]: ) -> dict[str, Any]:
"""Query Unraid system information. """Query Unraid system information.
@@ -354,52 +333,13 @@ def register_info_tool(mcp: FastMCP) -> None:
ups_devices - List UPS devices ups_devices - List UPS devices
ups_device - Single UPS device (requires device_id) ups_device - Single UPS device (requires device_id)
ups_config - UPS configuration 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: if action not in ALL_ACTIONS:
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(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: if action == "ups_device" and not device_id:
raise ToolError("device_id is required for ups_device action") 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 # connect is not available on all Unraid API versions
if action == "connect": if action == "connect":
raise ToolError( raise ToolError(

View File

@@ -83,11 +83,6 @@ MUTATIONS: dict[str, str] = {
} }
} }
""", """,
"create_unique": """
mutation NotifyIfUnique($input: NotificationData!) {
notifyIfUnique(input: $input) { id title importance }
}
""",
"unarchive_many": """ "unarchive_many": """
mutation UnarchiveNotifications($ids: [PrefixedID!]!) { mutation UnarchiveNotifications($ids: [PrefixedID!]!) {
unarchiveNotifications(ids: $ids) { unarchiveNotifications(ids: $ids) {
@@ -128,7 +123,6 @@ NOTIFICATION_ACTIONS = Literal[
"delete_archived", "delete_archived",
"archive_all", "archive_all",
"archive_many", "archive_many",
"create_unique",
"unarchive_many", "unarchive_many",
"unarchive_all", "unarchive_all",
"recalculate", "recalculate",
@@ -173,7 +167,6 @@ def register_notifications_tool(mcp: FastMCP) -> None:
delete_archived - Delete all archived notifications (requires confirm=True) delete_archived - Delete all archived notifications (requires confirm=True)
archive_all - Archive all notifications (optional importance filter) archive_all - Archive all notifications (optional importance filter)
archive_many - Archive multiple notifications by ID (requires notification_ids) 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_many - Move notifications back to unread (requires notification_ids)
unarchive_all - Move all archived notifications to unread (optional importance filter) unarchive_all - Move all archived notifications to unread (optional importance filter)
recalculate - Recompute overview counts from disk 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} 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 action == "unarchive_many":
if not notification_ids: if not notification_ids:
raise ToolError("notification_ids is required for 'unarchive_many' action") raise ToolError("notification_ids is required for 'unarchive_many' action")

View File

@@ -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 Provides the `unraid_settings` tool with 2 actions for updating system
configuration, time settings, UPS, API settings, and Unraid Connect. configuration and UPS monitoring.
""" """
from typing import Any, Literal, get_args from typing import Any, Literal, get_args
@@ -19,72 +19,21 @@ MUTATIONS: dict[str, str] = {
updateSettings(input: $input) { restartRequired values warnings } 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": """ "configure_ups": """
mutation ConfigureUps($config: UPSConfigInput!) { mutation ConfigureUps($config: UPSConfigInput!) {
configureUps(config: $config) 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 = { DESTRUCTIVE_ACTIONS = {
"configure_ups", "configure_ups",
"setup_remote_access",
"enable_dynamic_remote_access",
"update_ssh",
} }
ALL_ACTIONS = set(MUTATIONS) ALL_ACTIONS = set(MUTATIONS)
SETTINGS_ACTIONS = Literal[ SETTINGS_ACTIONS = Literal[
"configure_ups", "configure_ups",
"connect_sign_in",
"connect_sign_out",
"enable_dynamic_remote_access",
"setup_remote_access",
"update", "update",
"update_api",
"update_ssh",
"update_temperature",
"update_time",
] ]
if set(get_args(SETTINGS_ACTIONS)) != ALL_ACTIONS: if set(get_args(SETTINGS_ACTIONS)) != ALL_ACTIONS:
@@ -104,40 +53,13 @@ def register_settings_tool(mcp: FastMCP) -> None:
action: SETTINGS_ACTIONS, action: SETTINGS_ACTIONS,
confirm: bool = False, confirm: bool = False,
settings_input: dict[str, Any] | None = None, 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, 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]: ) -> dict[str, Any]:
"""Update Unraid system settings, time, UPS, and remote access configuration. """Update Unraid system settings and UPS configuration.
Actions: Actions:
update - Update system settings (requires settings_input dict) 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) 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: if action not in ALL_ACTIONS:
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(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}) data = await make_graphql_request(MUTATIONS["update"], {"input": settings_input})
return {"success": True, "action": "update", "data": data.get("updateSettings")} 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 action == "configure_ups":
if ups_config is None: if ups_config is None:
raise ToolError("ups_config is required for 'configure_ups' action") 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"), "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") raise ToolError(f"Unhandled action '{action}' — this is a bug")
logger.info("Settings tool registered successfully") logger.info("Settings tool registered successfully")