diff --git a/tests/safety/test_destructive_guards.py b/tests/safety/test_destructive_guards.py index d95b2aa..9b5116e 100644 --- a/tests/safety/test_destructive_guards.py +++ b/tests/safety/test_destructive_guards.py @@ -89,7 +89,12 @@ KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str] | str]] = { "module": "unraid_mcp.tools.settings", "register_fn": "register_settings_tool", "tool_name": "unraid_settings", - "actions": {"configure_ups", "setup_remote_access", "enable_dynamic_remote_access"}, + "actions": { + "configure_ups", + "setup_remote_access", + "enable_dynamic_remote_access", + "update_ssh", + }, "runtime_set": SETTINGS_DESTRUCTIVE, }, "plugins": { @@ -217,6 +222,7 @@ _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"]}), ] @@ -455,6 +461,16 @@ 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 eb68719..a1c1ff2 100644 --- a/tests/schema/test_query_validation.py +++ b/tests/schema/test_query_validation.py @@ -765,6 +765,7 @@ class TestSettingsMutations: "connect_sign_out", "setup_remote_access", "enable_dynamic_remote_access", + "update_ssh", } assert set(MUTATIONS.keys()) == expected diff --git a/tests/test_settings.py b/tests/test_settings.py index b35a803..f9d5393 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -58,6 +58,11 @@ class TestSettingsValidation: 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: """Tests for update action.""" @@ -267,3 +272,32 @@ class TestRemoteAccess: 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/settings.py b/unraid_mcp/tools/settings.py index 49f0ad7..5100314 100644 --- a/unraid_mcp/tools/settings.py +++ b/unraid_mcp/tools/settings.py @@ -59,21 +59,32 @@ MUTATIONS: dict[str, str] = { 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"} +DESTRUCTIVE_ACTIONS = { + "configure_ups", + "setup_remote_access", + "enable_dynamic_remote_access", + "update_ssh", +} ALL_ACTIONS = set(MUTATIONS) SETTINGS_ACTIONS = Literal[ - "update", - "update_temperature", - "update_time", "configure_ups", - "update_api", "connect_sign_in", "connect_sign_out", - "setup_remote_access", "enable_dynamic_remote_access", + "setup_remote_access", + "update", + "update_api", + "update_ssh", + "update_temperature", + "update_time", ] if set(get_args(SETTINGS_ACTIONS)) != ALL_ACTIONS: @@ -111,6 +122,8 @@ def register_settings_tool(mcp: FastMCP) -> 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. @@ -124,6 +137,7 @@ def register_settings_tool(mcp: FastMCP) -> None: 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)}") @@ -280,6 +294,21 @@ def register_settings_tool(mcp: FastMCP) -> None: "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")