mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user